Skip to content
Source
Sign inSign up

device.ts

260 lines8.7 KB

1// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - Host-side device automation uses Node subprocess and timing APIs directly.
2​
3import * as NodeChildProcess from "node:child_process";
4import * as NodePath from "node:path";
5import * as NodeProcess from "node:process";
6import * as Schema from "effect/Schema";
7​
8import type { Snapshot } from "./checks.ts";
9import type { Pose } from "./poses.ts";
10​
11export const BUNDLE_ID = "com.t3tools.t3code.dev";
12​
13export interface RunResult {
14 readonly code: number;
15 readonly stdout: string;
16 readonly stderr: string;
18​
19export function run(
20 command: string,
21 args: ReadonlyArray<string>,
22 options: {
23 readonly cwd?: string;
24 readonly env?: NodeJS.ProcessEnv;
25 readonly allowFailure?: boolean;
26 readonly timeoutMs?: number;
27 } = {},
28): Promise<RunResult> {
29 return new Promise((resolve, reject) => {
30 const child = NodeChildProcess.spawn(command, args, {
31 ...(options.cwd ? { cwd: options.cwd } : {}),
32 env: options.env ?? NodeProcess.env,
33 stdio: ["ignore", "pipe", "pipe"],
34 ...(options.timeoutMs ? { timeout: options.timeoutMs } : {}),
35 });
36 let stdout = "";
37 let stderr = "";
38 child.stdout.on("data", (chunk) => (stdout += chunk));
39 child.stderr.on("data", (chunk) => (stderr += chunk));
40 child.on("error", reject);
41 child.on("close", (code) => {
42 if (code !== 0 && !options.allowFailure)
43 reject(new Error(`${command} ${args.join(" ")} failed (${code}): ${stderr || stdout}`));
44 else resolve({ code: code ?? 1, stdout, stderr });
45 });
46 });
48​
49export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
50​
51/** How this run reaches the pool: one agent-device config, from one workspace. */
52export interface Pool {
53 readonly repoRoot: string;
54 readonly agentDevice: string;
55 /** The gateway's agent-device front, `<gateway>/v1/agent-device`. */
56 readonly agentDeviceUrl: string;
57 /**
58 * agent-device ties a lease to the workspace it was opened from and reads it from both the
59 * real cwd and $PWD, so every command runs from this real path with a matching PWD.
60 */
61 readonly workspace: string;
62 readonly env: NodeJS.ProcessEnv;
64​
65class AgentDeviceError extends Schema.TaggedError<AgentDeviceError>()("AgentDeviceError", {
66 message: Schema.String,
67}) {}
68​
69const isAgentDeviceError = Schema.is(AgentDeviceError);
70​
71// A shared pool's workers drop off the gateway and stall under load for seconds at a time.
72const TRANSIENT =
73 /Remote daemon is unavailable|not connected to the gateway|still finishing a previous command|Invalid daemon response|Daemon request timed out|no worker connected/i;
74​
75/** Failures that say the pool could not serve the pose, not that the app misbehaved. */
76export const poolError = (message: string) =>
77 TRANSIENT.test(message) ||
78 /Daemon connection closed|Failed to read daemon response|WORKER_UNREACHABLE|xcrun timed out|refused by simlock|is not available on this simlock/i.test(
79 message,
80 );
81​
82export class Device {
83 readonly pose: Pose;
84 readonly session: string;
85 readonly pool: Pool;
86​
87 private constructor(pose: Pose, session: string, pool: Pool) {
88 this.pose = pose;
89 this.session = session;
90 this.pool = pool;
91 }
92​
93 /** Leases a device for the pose; `open` waits in the pool's queue while every match is busy. */
94 static async lease(pose: Pose, pool: Pool, runId: string): Promise<Device> {
95 const device = new Device(pose, `t3-e2e-${runId}-${pose.id}`, pool);
96 await device.agent(["connect", "proxy", "--daemon-base-url", pool.agentDeviceUrl]);
97 await device.agent(
98 [
99 "open",
100 "--platform",
101 pose.platform,
102 "--device",
103 pose.device,
104 // A real keyboard, so the layout under it is what people see.
105 ...(pose.platform === "android" ? ["--no-test-ime"] : []),
106 ],
107 { timeoutMs: 420_000 },
108 );
109 return device;
110 }
111​
112 async agent(
113 args: ReadonlyArray<string>,
114 options: { readonly timeoutMs?: number } = {},
115 ): Promise<Record<string, unknown>> {
116 for (let attempt = 1; ; attempt++) {
117 try {
118 return await this.agentOnce(args, options);
119 } catch (error) {
120 if (attempt >= 5 || !isAgentDeviceError(error) || !TRANSIENT.test(error.message))
121 throw error;
122 await delay(5_000 * attempt);
123 }
124 }
125 }
126​
127 private async agentOnce(
128 args: ReadonlyArray<string>,
129 options: { readonly timeoutMs?: number },
130 ): Promise<Record<string, unknown>> {
131 const { stdout, stderr } = await run(
132 NodeProcess.execPath,
133 [this.pool.agentDevice, ...args, "--session", this.session, "--json"],
134 {
135 cwd: this.pool.workspace,
136 env: this.pool.env,
137 allowFailure: true,
138 timeoutMs: options.timeoutMs ?? 120_000,
139 },
140 );
141 let parsed: { success: boolean; data?: Record<string, unknown>; error?: { message: string } };
142 try {
143 parsed = JSON.parse(stdout);
144 } catch {
145 throw new AgentDeviceError({
146 message: `agent-device ${args[0]}: ${(stderr || stdout).slice(-400)}`,
147 });
148 }
149 if (!parsed.success)
150 throw new AgentDeviceError({
151 message: `agent-device ${args.join(" ")}: ${parsed.error?.message}`,
152 });
153 return parsed.data ?? {};
154 }
155​
156 /** The shared-cache dev client for this checkout; the worker downloads it itself. */
157 async install() {
158 await run(
159 NodeProcess.execPath,
160 [
161 NodePath.join(this.pool.repoRoot, "scripts/mobile-native-client.ts"),
162 "ensure",
163 this.pose.platform,
164 "--session",
165 this.session,
166 ],
167 { cwd: this.pool.workspace, env: this.pool.env, timeoutMs: 600_000 },
168 );
169 }
170​
171 /** Device settings that must be in place before the app starts. */
172 async prepare() {
173 await this.agent(["settings", "appearance", this.pose.appearance]);
174 if (this.pose.textSize) await this.agent(["settings", "text-size", this.pose.textSize]);
175 if (this.pose.fold) await this.agent(["fold", this.pose.fold]);
176 }
177​
178 async launch(metroUrl: string) {
179 const url = encodeURIComponent(metroUrl);
180 await this.agent(
181 [
182 "open",
183 BUNDLE_ID,
184 `t3code-dev://expo-development-client/?url=${url}&disableOnboarding=1&disableFab=1&disableAutoLaunch=1`,
185 // A real keyboard, so the layout under it is what people see.
186 ...(this.pose.platform === "android" ? ["--no-test-ime"] : []),
187 ],
188 { timeoutMs: 180_000 },
189 );
190 await this.acceptOpenPrompt();
191 }
192​
193 /**
194 * agent-device names rotations relative to the device's natural orientation, which is
195 * landscape on tablets and unfolded folds, so try each name until the screen has the shape.
196 */
197 async orient(shape: "landscape" | "portrait"): Promise<Snapshot> {
198 let snapshot = await this.snapshot();
199 for (const name of ["landscape-left", "portrait", "landscape-right"]) {
200 const { width, height } = snapshot.viewport;
201 if (width > height === (shape === "landscape")) return snapshot;
202 await this.agent(["orientation", name]);
203 await delay(2_000);
204 snapshot = await this.snapshot();
205 }
206 return snapshot;
207 }
208​
209 async openUrl(url: string) {
210 await this.agent(["open", BUNDLE_ID, url]);
211 await this.acceptOpenPrompt();
212 }
213​
214 /** iOS asks before another app opens a custom scheme, and agent-device does not always answer. */
215 private async acceptOpenPrompt() {
216 if (this.pose.platform !== "ios") return;
217 await delay(1_500);
218 await this.agent(["alert", "accept"]).catch(() => undefined);
219 }
220​
221 async snapshot(): Promise<Snapshot> {
222 return (await this.agent(["snapshot"])) as unknown as Snapshot;
223 }
224​
225 async waitFor(predicate: (snapshot: Snapshot) => boolean, label: string, timeoutMs = 60_000) {
226 const deadline = Date.now() + timeoutMs;
227 while (Date.now() < deadline) {
228 const snapshot = await this.snapshot().catch(() => null);
229 if (snapshot && predicate(snapshot)) return snapshot;
230 await delay(750);
231 }
232 throw new Error(`timed out waiting for ${label}`);
233 }
234​
235 async screenshot(path: string) {
236 // iOS defaults to 1x points, too coarse for anyone reviewing the screenshot.
237 await this.agent([
238 "screenshot",
239 path,
240 ...(this.pose.platform === "ios" ? ["--pixel-density", "2"] : []),
241 ]);
242 }
243​
244 /** Runs an action while recording, so transitions can be checked frame by frame. */
245 async recorded(directory: string, name: string, action: () => Promise<unknown>) {
246 const video = NodePath.join(directory, `${name}.mp4`);
247 await this.agent(["record", "start", video, "--hide-touches"]);
248 try {
249 await action();
250 await delay(2_000);
251 } finally {
252 await this.agent(["record", "stop"], { timeoutMs: 180_000 }).catch(() => undefined);
253 }
254 return video;
255 }
256​
257 async release() {
258 await this.agent(["close"]).catch(() => undefined);
259 }