Skip to content
Source
Sign inSign up

DeviceActions.test.ts

235 lines8.5 KB

1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as ChildProcess from "effect/process/ChildProcess";
6import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
7​
8import { readDeviceDetail, runDeviceAction } from "./DeviceActions.ts";
9import type { DeviceHostReady } from "./DeviceHost.ts";
10​
11type Call = { command: string; args: ReadonlyArray<string>; stdin?: string };
12​
13const makeReady = (
14 respond: (call: Call) => { stdout?: string; stderr?: string; code?: number } = () => ({}),
15 helpers: DeviceHostReady["helpers"] = {
16 serveSimAxSettings: "/hub/simax/serve-sim-ax-settings",
17 serveSimCli: "/hub/serve-sim.js",
18 },
19) => {
20 const calls: Call[] = [];
21 const unused = Effect.die("Device actions run host commands, never device operations");
22 const ready: DeviceHostReady = {
23 nodePath: process.execPath,
24 hub: { origin: "http://127.0.0.1:1" },
25 devices: {
26 list: unused,
27 boot: () => unused,
28 attach: () => unused,
29 shutdown: () => unused,
30 screenshot: () => unused,
31 },
32 helpers,
33 run: (command, args, options) => {
34 const call = { command, args, ...(options?.stdin ? { stdin: options.stdin } : {}) };
35 calls.push(call);
36 const result = respond(call);
37 return Effect.succeed({
38 stdout: result.stdout ?? "",
39 stderr: result.stderr ?? "",
40 code: result.code ?? 0,
41 });
42 },
43 };
44 return { ready, calls };
45};
46​
47const udid = "SIM-1";
48​
49describe("runDeviceAction", () => {
50 it.live.each([
51 "https://example.com/callback?code=1&state=2",
52 "https://example.com/it's?a=1&b=2",
53 "https://example.com/a b?key=value",
54 "https://example.com/plain",
55 ])("opens the complete Android URL %s", (url) =>
56 Effect.gen(function* () {
57 if ((yield* HostProcess.Platform) === "win32") return;
58 const { ready, calls } = makeReady();
59 yield* runDeviceAction(ready, "android", {
60 type: "openUrl",
61 deviceId: "emulator-5554",
62 url,
63 });
64 const args = calls[0]!.args;
65 expect(args.slice(0, 3)).toEqual(["-s", "emulator-5554", "shell"]);
66 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
67 // adb joins arguments verbatim before the Android shell parses them.
68 const stdout = yield* spawner.string(
69 ChildProcess.make("/bin/sh", [
70 "-c",
71 `am() { printf '%s\\0' "$@"; }\n${args.slice(3).join(" ")}`,
72 ]),
73 );
74 expect(stdout.split("\0").slice(0, -1)).toEqual([
75 "start",
76 "-a",
77 "android.intent.action.VIEW",
78 "-d",
79 url,
80 ]);
81 }).pipe(Effect.provide(NodeServices.layer)),
82 );
83​
84 it.effect.each([
85 ["emulator-5554", "Physical size: 2560x1600", "portrait", "-9.81:0:0"],
86 ["emulator-5554", "Physical size: 2560x1600", "landscape_left", "0:9.81:0"],
87 ["R5CT1234", "Physical size: 2560x1600", "portrait", "3"],
88 ["R5CT1234", "Physical size: 2560x1600", "landscape_left", "0"],
89 ["R5CT1234", "Physical size: 1080x1920\nOverride size: 1920x1080", "portrait", "3"],
90 ] as const)(
91 "rotates from the natural orientation reported by wm size: %s %j %s",
92 ([deviceId, wmSize, value, expected]) =>
93 Effect.gen(function* () {
94 const { ready, calls } = makeReady((call) =>
95 call.args.join(" ").endsWith("shell wm size") ? { stdout: `${wmSize}\n` } : {},
96 );
97 yield* runDeviceAction(ready, "android", { type: "setOrientation", deviceId, value });
98 expect(calls.at(-1)?.args.at(-1)).toBe(expected);
99 }),
100 );
101​
102 it.effect.each([
103 ["camera", "grant", "android.permission.CAMERA"],
104 ["photos", "revoke", "android.permission.READ_MEDIA_IMAGES"],
105 ] as const)(
106 "reports an Android %s %s that no pm command applied",
107 ([permission, decision, firstPermission]) =>
108 Effect.gen(function* () {
109 const { ready } = makeReady((call) => ({
110 code: 1,
111 stderr: `Unknown package: ${call.args.at(-1)}`,
112 }));
113 const error = yield* Effect.flip(
114 runDeviceAction(ready, "android", {
115 type: "setPermission",
116 deviceId: "emulator-5554",
117 appId: "com.example.missing",
118 permission,
119 decision,
120 }),
121 );
122 expect(error._tag).toBe("DeviceOperationError");
123 expect(error.operation).toBe("permission");
124 expect(error._tag === "DeviceOperationError" && error.cause).toMatchObject({
125 stderr: `Unknown package: ${firstPermission}`,
126 });
127 }),
128 );
129​
130 it.effect.each([
131 [
132 "photos",
133 ["android.permission.READ_MEDIA_IMAGES", "android.permission.READ_EXTERNAL_STORAGE"],
134 1,
135 ],
136 ["contacts", ["android.permission.READ_CONTACTS", "android.permission.WRITE_CONTACTS"], 0],
137 ] as const)(
138 "keeps a partially applied %s permission change",
139 ([permission, permissions, success]) =>
140 Effect.gen(function* () {
141 const { ready, calls } = makeReady((call) => ({
142 code: call.args.at(-1) === permissions[success] ? 0 : 1,
143 }));
144 const result = yield* runDeviceAction(ready, "android", {
145 type: "setPermission",
146 deviceId: "emulator-5554",
147 appId: "com.example.app",
148 permission,
149 decision: "grant",
150 });
151 expect(result).toBeUndefined();
152 expect(calls.map((call) => call.args.at(-1))).toEqual(permissions);
153 }),
154 );
155});
156​
157describe("readDeviceDetail", () => {
158 it.effect("reads the smallest iOS text size as small", () =>
159 Effect.gen(function* () {
160 const { ready } = makeReady((call) =>
161 call.args.join(" ").endsWith("ui SIM-1 content_size")
162 ? { stdout: "extra-small\n" }
163 : { code: 1 },
164 );
165 const detail = yield* readDeviceDetail(ready, "ios", udid);
166 expect(detail.settings.textSize).toBe("small");
167 }),
168 );
169​
170 it.effect("reads iOS settings from simctl and the accessibility helper", () =>
171 Effect.gen(function* () {
172 const { ready } = makeReady((call) => {
173 const key = call.args.join(" ");
174 if (key.endsWith("ui SIM-1 appearance")) return { stdout: "dark\n" };
175 if (key.endsWith("ui SIM-1 content_size")) return { stdout: "extra-extra-large\n" };
176 if (key.endsWith("ui SIM-1 increase_contrast")) return { stdout: "enabled\n" };
177 if (key.includes("serve-sim-ax-settings status")) {
178 return {
179 stdout:
180 '{"reduce-motion":"on","reduce-transparency":"off","show-borders":"off","voiceover":"off","liquid-glass":"tinted","color-filter":"grayscale"}',
181 };
182 }
183 return { code: 1 };
184 });
185 const detail = yield* readDeviceDetail(ready, "ios", udid);
186 expect(detail.settings).toEqual({
187 appearance: "dark",
188 textSize: "large",
189 increaseContrast: true,
190 reduceMotion: true,
191 reduceTransparency: false,
192 showBorders: false,
193 voiceOver: false,
194 liquidGlass: "tinted",
195 colorFilter: "grayscale",
196 });
197 }),
198 );
199​
200 it.effect("degrades unreadable values to unknown instead of failing", () =>
201 Effect.gen(function* () {
202 const { ready } = makeReady(() => ({ code: 1, stderr: "boom" }));
203 const detail = yield* readDeviceDetail(ready, "ios", udid);
204 expect(detail.settings).toEqual({});
205 expect(detail.foregroundApp).toBeNull();
206 }),
207 );
208​
209 it.effect("reads Android settings and the focused package", () =>
210 Effect.gen(function* () {
211 const { ready } = makeReady((call) => {
212 const key = call.args.join(" ");
213 if (key.endsWith("cmd uimode night")) return { stdout: "Night mode: yes\n" };
214 if (key.endsWith("font_scale")) return { stdout: "0.85\n" };
215 if (key.endsWith("animator_duration_scale")) return { stdout: "0\n" };
216 if (key.endsWith("wifi_on")) return { stdout: "1\n" };
217 if (key.endsWith("dumpsys window")) {
218 return {
219 stdout:
220 " mFocusedApp=ActivityRecord{155579877 u0 com.example.app/.MainActivity t15}\n mCurrentFocus=Window{1a2b u0 com.example.app/com.example.app.MainActivity}\n",
221 };
222 }
223 return { code: 1 };
224 });
225 const detail = yield* readDeviceDetail(ready, "android", "emulator-5554");
226 expect(detail.settings).toEqual({
227 appearance: "dark",
228 textSize: "small",
229 reduceMotion: true,
230 networkEnabled: true,
231 });
232 expect(detail.foregroundApp).toEqual({ id: "com.example.app" });
233 }),
234 );
235});