1#!/usr/bin/env node
2// @effect-diagnostics nodeBuiltinImport:off globalDate:off - Host-side device automation uses Node subprocess and filesystem APIs directly.
3
4import * as NodeChildProcess from "node:child_process";
5import * as NodeFS from "node:fs";
6import * as NodeFSP from "node:fs/promises";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import * as NodeProcess from "node:process";
10import * as NodeURL from "node:url";
11
12import { Device, type Pool, poolError, run } from "./mobile-e2e/device.ts";
13import { runJourney, type StepResult } from "./mobile-e2e/journey.ts";
14import { POSES, type Pose } from "./mobile-e2e/poses.ts";
15import {
16 failedChecks,
17 poolBlocked,
18 poseFailed,
19 type PoseResult,
20 renderReport,
21} from "./mobile-e2e/report.ts";
22import { reviewPose } from "./mobile-e2e/review.ts";
23import {
24 reserveAvailablePort,
25 startShowcaseEnvironments,
26 stopProcess,
27 waitForPort,
28} from "./mobile-showcase.ts";
29import { SHOWCASE_ENVIRONMENTS } from "./mobile-showcase-environment.ts";
30
31const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), "..");
32
33const log = (scope: string, message: string) => NodeProcess.stderr.write(`[${scope}] ${message}\n`);
34
35/** This machine's Tailscale address, which pool devices reach it on. */
36async function tailnetAddress(): Promise<string> {
37 // Other VPNs share 100.64.0.0/10, so ask Tailscale instead of matching interface ranges.
38 // The macOS app bundles its CLI without putting a launcher on PATH.
39 for (const cli of ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]) {
40 const stdout = await run(cli, ["ip", "-4"]).then(
41 (result) => result.stdout,
42 () => "",
43 );
44 const address = stdout.trim().split("\n")[0];
45 if (address) return address;
46 }
47 throw new Error("No Tailscale IPv4 address: pool devices reach Metro and the fixture over it.");
48}
49
50async function mapConcurrent<T, R>(
51 items: ReadonlyArray<T>,
52 limit: number,
53 task: (item: T) => Promise<R>,
54): Promise<R[]> {
55 const results: R[] = Array.from({ length: items.length });
56 let next = 0;
57 await Promise.all(
58 Array.from({ length: Math.min(limit, items.length) }, async () => {
59 while (next < items.length) {
60 const index = next++;
61 results[index] = await task(items[index]!);
62 }
63 }),
64 );
65 return results;
66}
67
68async function issuePairUrl(
69 environment: { readonly baseDir: string; readonly port: number },
70 host: string,
71) {
72 const { stdout } = await run(
73 NodeProcess.execPath,
74 [
75 "apps/server/src/bin.ts",
76 "auth",
77 "pairing",
78 "create",
79 "--base-dir",
80 environment.baseDir,
81 "--base-url",
82 `http://${host}:${environment.port}`,
83 "--ttl",
84 "15m",
85 "--json",
86 ],
87 { cwd: REPO_ROOT },
88 );
89 const { pairUrl } = JSON.parse(
90 stdout.slice(stdout.indexOf("{"), stdout.lastIndexOf("}") + 1),
91 ) as {
92 pairUrl?: string;
93 };
94 if (!pairUrl) throw new Error("The pairing command returned no pairUrl.");
95 return pairUrl;
96}
97
98async function runPose(
99 pose: Pose,
100 pool: Pool,
101 runId: string,
102 outputDirectory: string,
103 metroUrl: string,
104 pair: () => Promise<string>,
105): Promise<PoseResult> {
106 const directory = NodePath.join(outputDirectory, pose.id);
107 await NodeFSP.mkdir(directory, { recursive: true });
108 const steps: StepResult[] = [];
109 let device: Device | null = null;
110 try {
111 log(pose.id, `leasing ${pose.device}`);
112 device = await Device.lease(pose, pool, runId);
113 log(pose.id, "installing the dev client");
114 await device.install();
115 await device.prepare();
116 await device.launch(metroUrl);
117 log(pose.id, "pairing and walking the app");
118 await runJourney(device, directory, pair, steps);
119 return { pose, steps };
120 } catch (error) {
121 const message = error instanceof Error ? error.message : String(error);
122 if (!device) return { pose, steps, error: message };
123 // What the device showed when the journey stopped is usually the whole diagnosis.
124 const stopped = NodePath.join(directory, "stopped.png");
125 const shot = await device.screenshot(stopped).then(
126 () => stopped,
127 () => undefined,
128 );
129 const labels = await device.snapshot().then(
130 (snapshot) =>
131 snapshot.nodes
132 .map((node) => node.label)
133 .filter(Boolean)
134 .slice(0, 12)
135 .join(" | "),
136 () => "",
137 );
138 return {
139 pose,
140 steps,
141 error: labels ? `${message} (screen: ${labels})` : message,
142 ...(shot ? { stopped: shot } : {}),
143 };
144 } finally {
145 await device?.release();
146 }
147}
148
149async function main() {
150 const args = NodeProcess.argv.slice(2);
151 if (args.includes("--list")) {
152 for (const pose of POSES)
153 NodeProcess.stdout.write(
154 `${pose.id}\t${pose.device}${pose.pending ? `\t(pending: ${pose.pending})` : ""}\n`,
155 );
156 return;
157 }
158 const values = (flag: string) =>
159 args.flatMap((arg, index) => (args[index - 1] === flag ? [arg] : []));
160 const selected = values("--pose");
161 const unknown = selected.filter((id) => !POSES.some((pose) => pose.id === id));
162 if (unknown.length) throw new Error(`Unknown pose ${unknown.join(", ")}. Run with --list.`);
163 const platforms = values("--platform");
164 const poses = POSES.filter(
165 (pose) =>
166 (selected.length === 0 ? !pose.pending : selected.includes(pose.id)) &&
167 (platforms.length === 0 || platforms.includes(pose.platform)),
168 );
169 const concurrencyFlag = values("--concurrency")[0];
170 const concurrency = concurrencyFlag ? Number(concurrencyFlag) : undefined;
171 const review = !args.includes("--no-review");
172
173 const agentDeviceUrl = NodeProcess.env.SIMLOCK_AGENT_DEVICE_URL;
174 const token = NodeProcess.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
175 if (!agentDeviceUrl || !token)
176 throw new Error(
177 "Set SIMLOCK_AGENT_DEVICE_URL (<gateway>/v1/agent-device) and AGENT_DEVICE_DAEMON_AUTH_TOKEN (a Simlock agent token) to run on the device pool.",
178 );
179 const host = await tailnetAddress();
180
181 const runId = new Date().toISOString().replaceAll(/[:.]/g, "-");
182 const outputDirectory = NodePath.join(REPO_ROOT, "artifacts/mobile-e2e", runId);
183 await NodeFSP.mkdir(outputDirectory, { recursive: true });
184 const rootDir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-mobile-e2e-"));
185 const workspace = await NodeFSP.realpath(outputDirectory);
186 const config = NodePath.join(rootDir, "agent-device.json");
187 await NodeFSP.writeFile(
188 config,
189 JSON.stringify({
190 daemonBaseUrl: agentDeviceUrl,
191 daemonAuthToken: token,
192 stateDir: NodePath.join(rootDir, "agent-device"),
193 }),
194 { mode: 0o600 },
195 );
196 const pool: Pool = {
197 repoRoot: REPO_ROOT,
198 agentDevice: NodePath.join(REPO_ROOT, "scripts/node_modules/agent-device/bin/agent-device.mjs"),
199 agentDeviceUrl,
200 workspace,
201 env: { ...NodeProcess.env, AGENT_DEVICE_CONFIG: config, PWD: workspace },
202 };
203
204 const servers: NodeChildProcess.ChildProcess[] = [];
205 let metro: NodeChildProcess.ChildProcess | null = null;
206 try {
207 log("e2e", "starting the fixture server");
208 const [environment] = await startShowcaseEnvironments(
209 rootDir,
210 (server) => servers.push(server),
211 SHOWCASE_ENVIRONMENTS.slice(0, 1),
212 );
213 if (!environment) throw new Error("The fixture server did not start.");
214 const metroPort = await reserveAvailablePort();
215 metro = NodeChildProcess.spawn(
216 "vp",
217 [
218 "exec",
219 "expo",
220 "start",
221 "--dev-client",
222 "--scheme",
223 "t3code-dev",
224 "--lan",
225 "--port",
226 String(metroPort),
227 ],
228 {
229 cwd: NodePath.join(REPO_ROOT, "apps/mobile"),
230 // Lazily fetched chunks (dynamic import()) sometimes arrive broken on the pool's Android
231 // emulators and throw SyntaxErrors; release builds have no lazy chunks either.
232 env: { ...NodeProcess.env, APP_VARIANT: "development", CI: "1", EXPO_NO_METRO_LAZY: "1" },
233 // The app's console output arrives through Metro; keep it with the run.
234 stdio: [
235 "ignore",
236 NodeFS.openSync(NodePath.join(outputDirectory, "metro.log"), "w"),
237 "inherit",
238 ],
239 // Its own group, so stopping it also stops the expo child that owns the port.
240 detached: true,
241 },
242 );
243 await waitForPort(metroPort, "Metro", 120_000);
244 // Warm each platform's bundle once, instead of on every device at once, and have its dev
245 // client cached before any lease: a cache miss builds, which can outlast a lease.
246 for (const platform of new Set(poses.map((pose) => pose.platform))) {
247 log("e2e", `preparing the ${platform} dev client`);
248 await run(
249 NodeProcess.execPath,
250 ["scripts/mobile-native-client.ts", "ensure", platform, "--prepare"],
251 { cwd: REPO_ROOT, timeoutMs: 1_800_000 },
252 );
253 log("e2e", `bundling for ${platform}`);
254 await run("curl", [
255 "-sf",
256 "-o",
257 "/dev/null",
258 `http://127.0.0.1:${metroPort}/apps/mobile/index.bundle?platform=${platform}&dev=true&minify=false`,
259 ]);
260 }
261
262 const attempt = (pose: Pose, session: string) =>
263 runPose(pose, pool, session, outputDirectory, `http://${host}:${metroPort}`, () =>
264 issuePairUrl(environment, host),
265 );
266 const runOne = async (pose: Pose) => {
267 let result = await attempt(pose, runId);
268 // A pose that failed before its first screen usually met the pool, not the app.
269 for (
270 let retry = 1;
271 retry <= 2 && result.error && (result.steps.length === 0 || poolError(result.error));
272 retry++
273 ) {
274 log(pose.id, `retrying after: ${result.error.split("\n")[0]}`);
275 result = await attempt(pose, `${runId}-retry${retry}`);
276 }
277 return result;
278 };
279 // Each platform has its own workers, so each gets its own limit; one Mac runs every
280 // iOS simulator, and recording several at once on a shared pool times it out.
281 const byPlatform = await Promise.all(
282 (["ios", "android"] as const).map((platform) =>
283 mapConcurrent(
284 poses.filter((pose) => pose.platform === platform),
285 concurrency ?? (platform === "ios" ? 2 : 3),
286 runOne,
287 ),
288 ),
289 );
290 const results = poses.map((pose) => byPlatform.flat().find((result) => result.pose === pose)!);
291
292 const reviewed = review
293 ? await mapConcurrent(results, 4, async (result) => {
294 log(result.pose.id, "agent review");
295 const findings = await reviewPose(result, outputDirectory).catch((error) => {
296 log(result.pose.id, `review failed: ${error instanceof Error ? error.message : error}`);
297 return [];
298 });
299 return { ...result, findings };
300 })
301 : results;
302
303 const report = NodePath.join(outputDirectory, "index.html");
304 await NodeFSP.writeFile(report, renderReport(reviewed, outputDirectory));
305 await NodeFSP.writeFile(
306 NodePath.join(outputDirectory, "report.json"),
307 JSON.stringify(reviewed, null, 2),
308 );
309 for (const result of reviewed) {
310 const failures = failedChecks(result).map(
311 (item) => `${item.step}: ${item.name} (${item.detail})`,
312 );
313 log(
314 result.pose.id,
315 [poolBlocked(result) ? `blocked by the pool: ${result.error}` : result.error, ...failures]
316 .filter(Boolean)
317 .join("; ") ||
318 `passed${result.findings?.length ? `, ${result.findings.length} review findings` : ""}`,
319 );
320 }
321 const failed = reviewed.filter((result) => poseFailed(result) && !poolBlocked(result)).length;
322 const blocked = reviewed.filter(poolBlocked).length;
323 NodeProcess.stdout.write(
324 `${JSON.stringify({
325 report,
326 poses: reviewed.length,
327 failed,
328 blocked,
329 findings: reviewed.reduce((count, result) => count + (result.findings?.length ?? 0), 0),
330 })}\n`,
331 );
332 // A pose the pool blocked was not tested, so it fails the run too.
333 process.exitCode = failed || blocked ? 1 : 0;
334 } finally {
335 if (metro?.pid) {
336 try {
337 NodeProcess.kill(-metro.pid, "SIGTERM");
338 } catch {}
339 await stopProcess(metro);
340 }
341 await Promise.all(servers.map((server) => stopProcess(server)));
342 await NodeFSP.rm(rootDir, { recursive: true, force: true });
343 }
344}
345
346if (import.meta.main) {
347 await main();
348}