1import { describe, expect, it } from "@effect/vitest";
2import type { DeviceActionInput } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4
5import { readDeviceDetail, runDeviceAction, supportsAction } from "./DeviceActions.ts";
6import type { DeviceHostReady } from "./DeviceHost.ts";
7
8type Call = { command: string; args: ReadonlyArray<string>; stdin?: string };
9
10const makeReady = (
11 respond: (call: Call) => { stdout?: string; stderr?: string; code?: number } = () => ({}),
12 helpers: DeviceHostReady["helpers"] = {
13 serveSimAxSettings: "/hub/simax/serve-sim-ax-settings",
14 serveSimCli: "/hub/serve-sim.js",
15 },
16) => {
17 const calls: Call[] = [];
18 const ready: DeviceHostReady = {
19 nodePath: process.execPath,
20 hub: { origin: "http://127.0.0.1:1" },
21 helpers,
22 run: (command, args, options) => {
23 const call = { command, args, ...(options?.stdin ? { stdin: options.stdin } : {}) };
24 calls.push(call);
25 const result = respond(call);
26 return Effect.succeed({
27 stdout: result.stdout ?? "",
28 stderr: result.stderr ?? "",
29 code: result.code ?? 0,
30 });
31 },
32 };
33 return { ready, calls };
34};
35
36const udid = "SIM-1";
37
38describe("supportsAction", () => {
39 it("advertises platform-specific toggles", () => {
40 const toggle = (setting: Extract<DeviceActionInput, { type: "setToggle" }>["setting"]) =>
41 ({ type: "setToggle", deviceId: udid, setting, value: true }) as const;
42 expect(supportsAction("ios", toggle("voiceOver"))).toBe(true);
43 expect(supportsAction("android", toggle("voiceOver"))).toBe(false);
44 expect(supportsAction("android", toggle("networkEnabled"))).toBe(true);
45 expect(supportsAction("ios", toggle("networkEnabled"))).toBe(false);
46 expect(supportsAction("ios", { type: "setLiquidGlass", deviceId: udid, value: "clear" })).toBe(
47 true,
48 );
49 expect(
50 supportsAction("android", { type: "setLiquidGlass", deviceId: udid, value: "clear" }),
51 ).toBe(false);
52 expect(
53 supportsAction("android", { type: "setOrientation", deviceId: udid, value: "portrait" }),
54 ).toBe(true);
55 expect(
56 supportsAction("ios", { type: "setOrientation", deviceId: udid, value: "portrait" }),
57 ).toBe(false);
58 });
59});
60
61describe("runDeviceAction", () => {
62 it.effect("maps shared text sizes onto simctl content-size categories", () =>
63 Effect.gen(function* () {
64 const { ready, calls } = makeReady();
65 yield* runDeviceAction(ready, "ios", {
66 type: "setTextSize",
67 deviceId: udid,
68 value: "extra-large",
69 });
70 expect(calls).toEqual([
71 { command: "xcrun", args: ["simctl", "ui", udid, "content_size", "accessibility-large"] },
72 ]);
73 }),
74 );
75
76 it.effect("maps shared text sizes onto Android font_scale", () =>
77 Effect.gen(function* () {
78 const { ready, calls } = makeReady();
79 yield* runDeviceAction(ready, "android", {
80 type: "setTextSize",
81 deviceId: "emulator-5554",
82 value: "large",
83 });
84 expect(calls[0]?.args).toEqual([
85 "-s",
86 "emulator-5554",
87 "shell",
88 "settings",
89 "put",
90 "system",
91 "font_scale",
92 "1.15",
93 ]);
94 }),
95 );
96
97 it.effect(
98 "rotates emulators through the accelerometer and physical devices through the lock",
99 () =>
100 Effect.gen(function* () {
101 const emulator = makeReady();
102 yield* runDeviceAction(emulator.ready, "android", {
103 type: "setOrientation",
104 deviceId: "emulator-5554",
105 value: "landscape_left",
106 });
107 expect(emulator.calls.at(-1)?.args).toEqual([
108 "-s",
109 "emulator-5554",
110 "emu",
111 "sensor",
112 "set",
113 "acceleration",
114 "9.81:0:0",
115 ]);
116 const phone = makeReady();
117 yield* runDeviceAction(phone.ready, "android", {
118 type: "setOrientation",
119 deviceId: "R5CT1234",
120 value: "landscape_right",
121 });
122 expect(phone.calls).toEqual([
123 {
124 command: "adb",
125 args: ["-s", "R5CT1234", "shell", "cmd", "window", "user-rotation", "lock", "3"],
126 },
127 ]);
128 }),
129 );
130
131 it.effect("runs accessibility toggles through the bundled helper via simctl spawn", () =>
132 Effect.gen(function* () {
133 const { ready, calls } = makeReady();
134 yield* runDeviceAction(ready, "ios", {
135 type: "setToggle",
136 deviceId: udid,
137 setting: "voiceOver",
138 value: true,
139 });
140 expect(calls).toEqual([
141 {
142 command: "xcrun",
143 args: [
144 "simctl",
145 "spawn",
146 udid,
147 "/hub/simax/serve-sim-ax-settings",
148 "set",
149 "voiceover",
150 "on",
151 ],
152 },
153 ]);
154 }),
155 );
156
157 it.effect("fails clearly when the helper is missing", () =>
158 Effect.gen(function* () {
159 const { ready } = makeReady(() => ({}), { serveSimAxSettings: null, serveSimCli: null });
160 const error = yield* Effect.flip(
161 runDeviceAction(ready, "ios", {
162 type: "setColorFilter",
163 deviceId: udid,
164 value: "grayscale",
165 }),
166 );
167 expect(error._tag).toBe("DeviceActionUnavailableError");
168 expect(error.message).toContain("requires a helper");
169 }),
170 );
171
172 it.effect("rejects actions the platform does not support without running anything", () =>
173 Effect.gen(function* () {
174 const { ready, calls } = makeReady();
175 const error = yield* Effect.flip(
176 runDeviceAction(ready, "android", {
177 type: "sendPush",
178 deviceId: "emulator-5554",
179 appId: "com.example",
180 payload: "hi",
181 }),
182 );
183 expect(error.message).toContain("not supported on android");
184 expect(calls).toEqual([]);
185 }),
186 );
187
188 it.effect("surfaces non-zero exit codes as operation errors", () =>
189 Effect.gen(function* () {
190 const { ready } = makeReady(() => ({ code: 1, stderr: "Invalid device: SIM-1" }));
191 const error = yield* Effect.flip(
192 runDeviceAction(ready, "ios", { type: "setAppearance", deviceId: udid, value: "dark" }),
193 );
194 expect(error.operation).toBe("appearance");
195 expect(error.message).toContain("exit code 1");
196 expect(error.message).not.toContain("Invalid device: SIM-1");
197 expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({
198 stderr: "Invalid device: SIM-1",
199 });
200 }),
201 );
202
203 it.effect("wraps a bare push string in an APNs alert and feeds it on stdin", () =>
204 Effect.gen(function* () {
205 const { ready, calls } = makeReady();
206 yield* runDeviceAction(ready, "ios", {
207 type: "sendPush",
208 deviceId: udid,
209 appId: "com.example.app",
210 payload: "Hello",
211 });
212 expect(calls[0]?.args).toEqual(["simctl", "push", udid, "com.example.app", "-"]);
213 expect(calls[0]?.stdin).toBe('{"aps":{"alert":"Hello"}}');
214 }),
215 );
216});
217
218describe("readDeviceDetail", () => {
219 it.effect("reads iOS settings from simctl and the accessibility helper", () =>
220 Effect.gen(function* () {
221 const { ready } = makeReady((call) => {
222 const key = call.args.join(" ");
223 if (key.endsWith("ui SIM-1 appearance")) return { stdout: "dark\n" };
224 if (key.endsWith("ui SIM-1 content_size")) return { stdout: "extra-extra-large\n" };
225 if (key.endsWith("ui SIM-1 increase_contrast")) return { stdout: "enabled\n" };
226 if (key.includes("serve-sim-ax-settings status")) {
227 return {
228 stdout:
229 '{"reduce-motion":"on","reduce-transparency":"off","show-borders":"off","voiceover":"off","liquid-glass":"tinted","color-filter":"grayscale"}',
230 };
231 }
232 return { code: 1 };
233 });
234 const detail = yield* readDeviceDetail(ready, "ios", udid);
235 expect(detail.settings).toEqual({
236 appearance: "dark",
237 textSize: "large",
238 increaseContrast: true,
239 reduceMotion: true,
240 reduceTransparency: false,
241 showBorders: false,
242 voiceOver: false,
243 liquidGlass: "tinted",
244 colorFilter: "grayscale",
245 });
246 }),
247 );
248
249 it.effect("degrades unreadable values to unknown instead of failing", () =>
250 Effect.gen(function* () {
251 const { ready } = makeReady(() => ({ code: 1, stderr: "boom" }));
252 const detail = yield* readDeviceDetail(ready, "ios", udid);
253 expect(detail.settings).toEqual({});
254 expect(detail.foregroundApp).toBeNull();
255 }),
256 );
257
258 it.effect("reads Android settings and the focused package", () =>
259 Effect.gen(function* () {
260 const { ready } = makeReady((call) => {
261 const key = call.args.join(" ");
262 if (key.endsWith("cmd uimode night")) return { stdout: "Night mode: yes\n" };
263 if (key.endsWith("font_scale")) return { stdout: "0.85\n" };
264 if (key.endsWith("animator_duration_scale")) return { stdout: "0\n" };
265 if (key.endsWith("wifi_on")) return { stdout: "1\n" };
266 if (key.endsWith("dumpsys window")) {
267 return {
268 stdout:
269 " mFocusedApp=ActivityRecord{155579877 u0 com.example.app/.MainActivity t15}\n mCurrentFocus=Window{1a2b u0 com.example.app/com.example.app.MainActivity}\n",
270 };
271 }
272 return { code: 1 };
273 });
274 const detail = yield* readDeviceDetail(ready, "android", "emulator-5554");
275 expect(detail.settings).toEqual({
276 appearance: "dark",
277 textSize: "small",
278 reduceMotion: true,
279 networkEnabled: true,
280 });
281 expect(detail.foregroundApp).toEqual({ id: "com.example.app" });
282 }),
283 );
284});