import { describe, expect, it } from "@effect/vitest";import type { DeviceActionInput } from "@t3tools/contracts";import * as Effect from "effect/Effect"; import { readDeviceDetail, runDeviceAction, supportsAction } from "./DeviceActions.ts";import type { DeviceHostReady } from "./DeviceHost.ts"; type Call = { command: string; args: ReadonlyArray<string>; stdin?: string }; const makeReady = ( respond: (call: Call) => { stdout?: string; stderr?: string; code?: number } = () => ({}), helpers: DeviceHostReady["helpers"] = { serveSimAxSettings: "/hub/simax/serve-sim-ax-settings", serveSimCli: "/hub/serve-sim.js", },) => { const calls: Call[] = []; const ready: DeviceHostReady = { nodePath: process.execPath, hub: { origin: "http://127.0.0.1:1" }, helpers, run: (command, args, options) => { const call = { command, args, ...(options?.stdin ? { stdin: options.stdin } : {}) }; calls.push(call); const result = respond(call); return Effect.succeed({ stdout: result.stdout ?? "", stderr: result.stderr ?? "", code: result.code ?? 0, }); }, }; return { ready, calls };}; const udid = "SIM-1"; describe("supportsAction", () => { it("advertises platform-specific toggles", () => { const toggle = (setting: Extract<DeviceActionInput, { type: "setToggle" }>["setting"]) => ({ type: "setToggle", deviceId: udid, setting, value: true }) as const; expect(supportsAction("ios", toggle("voiceOver"))).toBe(true); expect(supportsAction("android", toggle("voiceOver"))).toBe(false); expect(supportsAction("android", toggle("networkEnabled"))).toBe(true); expect(supportsAction("ios", toggle("networkEnabled"))).toBe(false); expect(supportsAction("ios", { type: "setLiquidGlass", deviceId: udid, value: "clear" })).toBe( true, ); expect( supportsAction("android", { type: "setLiquidGlass", deviceId: udid, value: "clear" }), ).toBe(false); expect( supportsAction("android", { type: "setOrientation", deviceId: udid, value: "portrait" }), ).toBe(true); expect( supportsAction("ios", { type: "setOrientation", deviceId: udid, value: "portrait" }), ).toBe(false); });}); describe("runDeviceAction", () => { it.effect("maps shared text sizes onto simctl content-size categories", () => Effect.gen(function* () { const { ready, calls } = makeReady(); yield* runDeviceAction(ready, "ios", { type: "setTextSize", deviceId: udid, value: "extra-large", }); expect(calls).toEqual([ { command: "xcrun", args: ["simctl", "ui", udid, "content_size", "accessibility-large"] }, ]); }), ); it.effect("maps shared text sizes onto Android font_scale", () => Effect.gen(function* () { const { ready, calls } = makeReady(); yield* runDeviceAction(ready, "android", { type: "setTextSize", deviceId: "emulator-5554", value: "large", }); expect(calls[0]?.args).toEqual([ "-s", "emulator-5554", "shell", "settings", "put", "system", "font_scale", "1.15", ]); }), ); it.effect( "rotates emulators through the accelerometer and physical devices through the lock", () => Effect.gen(function* () { const emulator = makeReady(); yield* runDeviceAction(emulator.ready, "android", { type: "setOrientation", deviceId: "emulator-5554", value: "landscape_left", }); expect(emulator.calls.at(-1)?.args).toEqual([ "-s", "emulator-5554", "emu", "sensor", "set", "acceleration", "9.81:0:0", ]); const phone = makeReady(); yield* runDeviceAction(phone.ready, "android", { type: "setOrientation", deviceId: "R5CT1234", value: "landscape_right", }); expect(phone.calls).toEqual([ { command: "adb", args: ["-s", "R5CT1234", "shell", "cmd", "window", "user-rotation", "lock", "3"], }, ]); }), ); it.effect("runs accessibility toggles through the bundled helper via simctl spawn", () => Effect.gen(function* () { const { ready, calls } = makeReady(); yield* runDeviceAction(ready, "ios", { type: "setToggle", deviceId: udid, setting: "voiceOver", value: true, }); expect(calls).toEqual([ { command: "xcrun", args: [ "simctl", "spawn", udid, "/hub/simax/serve-sim-ax-settings", "set", "voiceover", "on", ], }, ]); }), ); it.effect("fails clearly when the helper is missing", () => Effect.gen(function* () { const { ready } = makeReady(() => ({}), { serveSimAxSettings: null, serveSimCli: null }); const error = yield* Effect.flip( runDeviceAction(ready, "ios", { type: "setColorFilter", deviceId: udid, value: "grayscale", }), ); expect(error._tag).toBe("DeviceActionUnavailableError"); expect(error.message).toContain("requires a helper"); }), ); it.effect("rejects actions the platform does not support without running anything", () => Effect.gen(function* () { const { ready, calls } = makeReady(); const error = yield* Effect.flip( runDeviceAction(ready, "android", { type: "sendPush", deviceId: "emulator-5554", appId: "com.example", payload: "hi", }), ); expect(error.message).toContain("not supported on android"); expect(calls).toEqual([]); }), ); it.effect("surfaces non-zero exit codes as operation errors", () => Effect.gen(function* () { const { ready } = makeReady(() => ({ code: 1, stderr: "Invalid device: SIM-1" })); const error = yield* Effect.flip( runDeviceAction(ready, "ios", { type: "setAppearance", deviceId: udid, value: "dark" }), ); expect(error.operation).toBe("appearance"); expect(error.message).toContain("exit code 1"); expect(error.message).not.toContain("Invalid device: SIM-1"); expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({ stderr: "Invalid device: SIM-1", }); }), ); it.effect("wraps a bare push string in an APNs alert and feeds it on stdin", () => Effect.gen(function* () { const { ready, calls } = makeReady(); yield* runDeviceAction(ready, "ios", { type: "sendPush", deviceId: udid, appId: "com.example.app", payload: "Hello", }); expect(calls[0]?.args).toEqual(["simctl", "push", udid, "com.example.app", "-"]); expect(calls[0]?.stdin).toBe('{"aps":{"alert":"Hello"}}'); }), );}); describe("readDeviceDetail", () => { it.effect("reads iOS settings from simctl and the accessibility helper", () => Effect.gen(function* () { const { ready } = makeReady((call) => { const key = call.args.join(" "); if (key.endsWith("ui SIM-1 appearance")) return { stdout: "dark\n" }; if (key.endsWith("ui SIM-1 content_size")) return { stdout: "extra-extra-large\n" }; if (key.endsWith("ui SIM-1 increase_contrast")) return { stdout: "enabled\n" }; if (key.includes("serve-sim-ax-settings status")) { return { stdout: '{"reduce-motion":"on","reduce-transparency":"off","show-borders":"off","voiceover":"off","liquid-glass":"tinted","color-filter":"grayscale"}', }; } return { code: 1 }; }); const detail = yield* readDeviceDetail(ready, "ios", udid); expect(detail.settings).toEqual({ appearance: "dark", textSize: "large", increaseContrast: true, reduceMotion: true, reduceTransparency: false, showBorders: false, voiceOver: false, liquidGlass: "tinted", colorFilter: "grayscale", }); }), ); it.effect("degrades unreadable values to unknown instead of failing", () => Effect.gen(function* () { const { ready } = makeReady(() => ({ code: 1, stderr: "boom" })); const detail = yield* readDeviceDetail(ready, "ios", udid); expect(detail.settings).toEqual({}); expect(detail.foregroundApp).toBeNull(); }), ); it.effect("reads Android settings and the focused package", () => Effect.gen(function* () { const { ready } = makeReady((call) => { const key = call.args.join(" "); if (key.endsWith("cmd uimode night")) return { stdout: "Night mode: yes\n" }; if (key.endsWith("font_scale")) return { stdout: "0.85\n" }; if (key.endsWith("animator_duration_scale")) return { stdout: "0\n" }; if (key.endsWith("wifi_on")) return { stdout: "1\n" }; if (key.endsWith("dumpsys window")) { return { stdout: " mFocusedApp=ActivityRecord{155579877 u0 com.example.app/.MainActivity t15}\n mCurrentFocus=Window{1a2b u0 com.example.app/com.example.app.MainActivity}\n", }; } return { code: 1 }; }); const detail = yield* readDeviceDetail(ready, "android", "emulator-5554"); expect(detail.settings).toEqual({ appearance: "dark", textSize: "small", reduceMotion: true, networkEnabled: true, }); expect(detail.foregroundApp).toEqual({ id: "com.example.app" }); }), );});
import { describe, expect, it } from "@effect/vitest";
import type { DeviceActionInput } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { readDeviceDetail, runDeviceAction, supportsAction } from "./DeviceActions.ts";
import type { DeviceHostReady } from "./DeviceHost.ts";
type Call = { command: string; args: ReadonlyArray<string>; stdin?: string };
const makeReady = (
respond: (call: Call) => { stdout?: string; stderr?: string; code?: number } = () => ({}),
helpers: DeviceHostReady["helpers"] = {
serveSimAxSettings: "/hub/simax/serve-sim-ax-settings",
serveSimCli: "/hub/serve-sim.js",
},
) => {
const calls: Call[] = [];
const ready: DeviceHostReady = {
nodePath: process.execPath,
hub: { origin: "http://127.0.0.1:1" },
helpers,
run: (command, args, options) => {
const call = { command, args, ...(options?.stdin ? { stdin: options.stdin } : {}) };
calls.push(call);
const result = respond(call);
return Effect.succeed({
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
code: result.code ?? 0,
});
},
};
return { ready, calls };
};
const udid = "SIM-1";
describe("supportsAction", () => {
it("advertises platform-specific toggles", () => {
const toggle = (setting: Extract<DeviceActionInput, { type: "setToggle" }>["setting"]) =>
({ type: "setToggle", deviceId: udid, setting, value: true }) as const;
expect(supportsAction("ios", toggle("voiceOver"))).toBe(true);
expect(supportsAction("android", toggle("voiceOver"))).toBe(false);
expect(supportsAction("android", toggle("networkEnabled"))).toBe(true);
expect(supportsAction("ios", toggle("networkEnabled"))).toBe(false);
expect(supportsAction("ios", { type: "setLiquidGlass", deviceId: udid, value: "clear" })).toBe(
true,
);
expect(
supportsAction("android", { type: "setLiquidGlass", deviceId: udid, value: "clear" }),
).toBe(false);
expect(
supportsAction("android", { type: "setOrientation", deviceId: udid, value: "portrait" }),
).toBe(true);
expect(
supportsAction("ios", { type: "setOrientation", deviceId: udid, value: "portrait" }),
).toBe(false);
});
});
describe("runDeviceAction", () => {
it.effect("maps shared text sizes onto simctl content-size categories", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
yield* runDeviceAction(ready, "ios", {
type: "setTextSize",
deviceId: udid,
value: "extra-large",
});
expect(calls).toEqual([
{ command: "xcrun", args: ["simctl", "ui", udid, "content_size", "accessibility-large"] },
]);
}),
);
it.effect("maps shared text sizes onto Android font_scale", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
yield* runDeviceAction(ready, "android", {
type: "setTextSize",
deviceId: "emulator-5554",
value: "large",
});
expect(calls[0]?.args).toEqual([
"-s",
"emulator-5554",
"shell",
"settings",
"put",
"system",
"font_scale",
"1.15",
]);
}),
);
it.effect(
"rotates emulators through the accelerometer and physical devices through the lock",
() =>
Effect.gen(function* () {
const emulator = makeReady();
yield* runDeviceAction(emulator.ready, "android", {
type: "setOrientation",
deviceId: "emulator-5554",
value: "landscape_left",
});
expect(emulator.calls.at(-1)?.args).toEqual([
"-s",
"emulator-5554",
"emu",
"sensor",
"set",
"acceleration",
"9.81:0:0",
]);
const phone = makeReady();
yield* runDeviceAction(phone.ready, "android", {
type: "setOrientation",
deviceId: "R5CT1234",
value: "landscape_right",
});
expect(phone.calls).toEqual([
{
command: "adb",
args: ["-s", "R5CT1234", "shell", "cmd", "window", "user-rotation", "lock", "3"],
},
]);
}),
);
it.effect("runs accessibility toggles through the bundled helper via simctl spawn", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
yield* runDeviceAction(ready, "ios", {
type: "setToggle",
deviceId: udid,
setting: "voiceOver",
value: true,
});
expect(calls).toEqual([
{
command: "xcrun",
args: [
"simctl",
"spawn",
udid,
"/hub/simax/serve-sim-ax-settings",
"set",
"voiceover",
"on",
],
},
]);
}),
);
it.effect("fails clearly when the helper is missing", () =>
Effect.gen(function* () {
const { ready } = makeReady(() => ({}), { serveSimAxSettings: null, serveSimCli: null });
const error = yield* Effect.flip(
runDeviceAction(ready, "ios", {
type: "setColorFilter",
deviceId: udid,
value: "grayscale",
}),
);
expect(error._tag).toBe("DeviceActionUnavailableError");
expect(error.message).toContain("requires a helper");
}),
);
it.effect("rejects actions the platform does not support without running anything", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
const error = yield* Effect.flip(
runDeviceAction(ready, "android", {
type: "sendPush",
deviceId: "emulator-5554",
appId: "com.example",
payload: "hi",
}),
);
expect(error.message).toContain("not supported on android");
expect(calls).toEqual([]);
}),
);
it.effect("surfaces non-zero exit codes as operation errors", () =>
Effect.gen(function* () {
const { ready } = makeReady(() => ({ code: 1, stderr: "Invalid device: SIM-1" }));
const error = yield* Effect.flip(
runDeviceAction(ready, "ios", { type: "setAppearance", deviceId: udid, value: "dark" }),
);
expect(error.operation).toBe("appearance");
expect(error.message).toContain("exit code 1");
expect(error.message).not.toContain("Invalid device: SIM-1");
expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({
stderr: "Invalid device: SIM-1",
});
}),
);
it.effect("wraps a bare push string in an APNs alert and feeds it on stdin", () =>
Effect.gen(function* () {
const { ready, calls } = makeReady();
yield* runDeviceAction(ready, "ios", {
type: "sendPush",
deviceId: udid,
appId: "com.example.app",
payload: "Hello",
});
expect(calls[0]?.args).toEqual(["simctl", "push", udid, "com.example.app", "-"]);
expect(calls[0]?.stdin).toBe('{"aps":{"alert":"Hello"}}');
}),
);
});
describe("readDeviceDetail", () => {
it.effect("reads iOS settings from simctl and the accessibility helper", () =>
Effect.gen(function* () {
const { ready } = makeReady((call) => {
const key = call.args.join(" ");
if (key.endsWith("ui SIM-1 appearance")) return { stdout: "dark\n" };
if (key.endsWith("ui SIM-1 content_size")) return { stdout: "extra-extra-large\n" };
if (key.endsWith("ui SIM-1 increase_contrast")) return { stdout: "enabled\n" };
if (key.includes("serve-sim-ax-settings status")) {
return {
stdout:
'{"reduce-motion":"on","reduce-transparency":"off","show-borders":"off","voiceover":"off","liquid-glass":"tinted","color-filter":"grayscale"}',
};
}
return { code: 1 };
});
const detail = yield* readDeviceDetail(ready, "ios", udid);
expect(detail.settings).toEqual({
appearance: "dark",
textSize: "large",
increaseContrast: true,
reduceMotion: true,
reduceTransparency: false,
showBorders: false,
voiceOver: false,
liquidGlass: "tinted",
colorFilter: "grayscale",
});
}),
);
it.effect("degrades unreadable values to unknown instead of failing", () =>
Effect.gen(function* () {
const { ready } = makeReady(() => ({ code: 1, stderr: "boom" }));
const detail = yield* readDeviceDetail(ready, "ios", udid);
expect(detail.settings).toEqual({});
expect(detail.foregroundApp).toBeNull();
}),
);
it.effect("reads Android settings and the focused package", () =>
Effect.gen(function* () {
const { ready } = makeReady((call) => {
const key = call.args.join(" ");
if (key.endsWith("cmd uimode night")) return { stdout: "Night mode: yes\n" };
if (key.endsWith("font_scale")) return { stdout: "0.85\n" };
if (key.endsWith("animator_duration_scale")) return { stdout: "0\n" };
if (key.endsWith("wifi_on")) return { stdout: "1\n" };
if (key.endsWith("dumpsys window")) {
return {
stdout:
" mFocusedApp=ActivityRecord{155579877 u0 com.example.app/.MainActivity t15}\n mCurrentFocus=Window{1a2b u0 com.example.app/com.example.app.MainActivity}\n",
};
}
return { code: 1 };
});
const detail = yield* readDeviceDetail(ready, "android", "emulator-5554");
expect(detail.settings).toEqual({
appearance: "dark",
textSize: "small",
reduceMotion: true,
networkEnabled: true,
});
expect(detail.foregroundApp).toEqual({ id: "com.example.app" });
}),
);
});