Skip to content
Source
Sign inSign up

AgentDeviceTarget.test.ts

483 lines19.5 KB

1// @effect-diagnostics nodeBuiltinImport:off - exercises concurrent real CLI subprocesses.
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import { describe, expect, it } from "@effect/vitest";
4import * as NodeChildProcess from "node:child_process";
5import * as NodeNet from "node:net";
6import * as NodeUtil from "node:util";
7import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import * as Layer from "effect/Layer";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import {
14 agentDeviceCloseOutcome,
15 bindAgentDeviceLauncher,
16 ensureAgentDeviceShim,
17 pruneAgentDeviceLaunchers,
18} from "./AgentDeviceShim.ts";
19import {
20 agentDeviceConfigPath,
21 agentDeviceSession,
22 writeAgentDeviceConfig,
23} from "./AgentDeviceTarget.ts";
24​
25const exec = NodeUtil.promisify(NodeChildProcess.execFile);
26​
27/** A loopback port that accepts connections until the scope closes, like T3's device connection. */
28const listeningPort = Effect.acquireRelease(
29 Effect.promise(
30 () =>
31 new Promise<NodeNet.Server>((resolve) => {
32 const server = NodeNet.createServer((socket) => socket.destroy());
33 server.listen(0, "127.0.0.1", () => resolve(server));
34 }),
35 ),
36 (server) => Effect.promise(() => new Promise<void>((resolve) => server.close(() => resolve()))),
37).pipe(Effect.map((server) => (server.address() as NodeNet.AddressInfo).port));
38​
39describe("host-bound agent commands", () => {
40 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
41 "runs both desktop hops as Node with a stripped environment, preserving arguments and exit code",
42 () =>
43 Effect.gen(function* () {
44 const fs = yield* FileSystem.FileSystem;
45 const path = yield* Path.Path;
46 const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" });
47 const dir = path.join(temp, "quotes '\" $HOME `literal`");
48 yield* fs.makeDirectory(dir);
49 const desktopApp = path.join(dir, "T3 Code");
50 // Dropping Node mode after checking it forces each hop to set it independently.
51 yield* fs.writeFileString(
52 desktopApp,
53 `#!/bin/sh
54if [ "\${ELECTRON_RUN_AS_NODE:-}" != "1" ]; then
55 echo "started the desktop app" >&2
56 exit 134
57fi
58unset ELECTRON_RUN_AS_NODE
59exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@"
60`,
61 );
62 yield* fs.chmod(desktopApp, 0o755);
63 const entryPath = path.join(dir, "cli.mjs");
64 yield* fs.writeFileString(
65 entryPath,
66 "console.log(JSON.stringify(process.argv.slice(2))); process.exitCode = 23;",
67 );
68 const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir }).pipe(
69 Effect.provideService(HostProcess.ExecutablePath, desktopApp),
70 Effect.provideService(HostProcess.IsExecutable, false),
71 );
72 const args = [
73 "snapshot",
74 "--config",
75 path.join(dir, "config.json"),
76 "--session",
77 "a!b!c",
78 "",
79 ];
80 const hostPath = (yield* HostProcess.Environment).PATH ?? "";
81 yield* Effect.promise(() =>
82 expect(
83 exec(path.join(shim, "agent-device"), args, {
84 env: { PATH: hostPath, HOME: temp },
85 }),
86 ).rejects.toMatchObject({ code: 23, stdout: `${JSON.stringify(args)}\n`, stderr: "" }),
87 );
88 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
89 );
90​
91 it.effect("binds one device's flags into its launcher and leaves help alone", () =>
92 Effect.gen(function* () {
93 const fs = yield* FileSystem.FileSystem;
94 const path = yield* Path.Path;
95 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-bound-" });
96 const entryPath = path.join(dir, "cli.mjs");
97 yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));");
98 const target = [
99 "--platform",
100 "ios",
101 "--udid",
102 "UDID-1",
103 "--config",
104 "/h.json",
105 "--session",
106 "t3-a",
107 ];
108 const command = yield* bindAgentDeviceLauncher({
109 entryPath,
110 stateDir: dir,
111 session: "t3-a",
112 targetArgs: target,
113 });
114 const shared = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });
115 const launch = (launcher: string, args: ReadonlyArray<string>) =>
116 exec(process.execPath, [path.join(launcher, "agent-device-launcher.mjs"), ...args]).then(
117 (result) => JSON.parse(result.stdout),
118 );
119 const bound = path.dirname(command);
120 expect(yield* Effect.promise(() => launch(bound, ["snapshot", "-i"]))).toEqual([
121 "snapshot",
122 "-i",
123 ...target,
124 ]);
125 // A flag the agent passes itself wins over the bound one.
126 expect(yield* Effect.promise(() => launch(bound, ["open", "--session", "mine"]))).toEqual([
127 "open",
128 "--session",
129 "mine",
130 "--platform",
131 "ios",
132 "--udid",
133 "UDID-1",
134 "--config",
135 "/h.json",
136 ]);
137 expect(yield* Effect.promise(() => launch(bound, ["snapshot", "--session=mine"]))).toEqual([
138 "snapshot",
139 "--session=mine",
140 "--platform",
141 "ios",
142 "--udid",
143 "UDID-1",
144 "--config",
145 "/h.json",
146 ]);
147 // After `--` everything is text: no help, no overrides, and bound flags go before it.
148 for (const text of [["--help"], ["literal text", "--session", "mine"]]) {
149 expect(yield* Effect.promise(() => launch(bound, ["fill", "@e5", "--", ...text]))).toEqual([
150 "fill",
151 "@e5",
152 ...target,
153 "--",
154 ...text,
155 ]);
156 }
157 expect(
158 yield* Effect.promise(() =>
159 launch(shared, ["snapshot", "--config=/h.json", "--session=t3-a"]),
160 ),
161 ).toEqual(["snapshot", "--config=/h.json", "--session=t3-a"]);
162 yield* Effect.promise(() =>
163 expect(
164 launch(shared, ["fill", "@e5", "--", "--config", "/h.json", "--session", "t3-a"]),
165 ).rejects.toThrow("Call device_open first"),
166 );
167 for (const args of [["help", "gesture"], ["open", "--help"], ["record", "-h"], []]) {
168 expect(yield* Effect.promise(() => launch(bound, args))).toEqual(args);
169 expect(yield* Effect.promise(() => launch(shared, args))).toEqual(args);
170 }
171 yield* fs.remove(entryPath);
172 yield* Effect.promise(() =>
173 expect(launch(bound, ["snapshot"])).rejects.toThrow("Call device_open again"),
174 );
175 }).pipe(
176 Effect.scoped,
177 Effect.provideService(HostProcess.IsExecutable, false),
178 Effect.provideService(HostProcess.ExecutablePath, process.execPath),
179 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
180 ),
181 );
182​
183 it.effect("passes each command only the bound flags agent-device accepts for it", () =>
184 Effect.gen(function* () {
185 const fs = yield* FileSystem.FileSystem;
186 const path = yield* Path.Path;
187 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-commands-" });
188 const entryPath = path.join(dir, "cli.mjs");
189 yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));");
190 const command = yield* bindAgentDeviceLauncher({
191 entryPath,
192 stateDir: dir,
193 session: "t3-a",
194 targetArgs: [
195 "--platform",
196 "ios",
197 "--udid",
198 "UDID-1",
199 "--config",
200 "/h.json",
201 "--session",
202 "t3-a",
203 ],
204 });
205 const launch = (args: ReadonlyArray<string>) =>
206 exec(process.execPath, [
207 path.join(path.dirname(command), "agent-device-launcher.mjs"),
208 ...args,
209 ]).then((result) => JSON.parse(result.stdout));
210 // agent-device 0.21.31 rejects these flags with "unknown option" or "not supported for command".
211 const cdp = ["cdp", "target", "list", "--url", "http://127.0.0.1:8081"];
212 expect(yield* Effect.promise(() => launch(cdp))).toEqual(cdp);
213 expect(yield* Effect.promise(() => launch(["connection"]))).toEqual([
214 "connection",
215 "--config",
216 "/h.json",
217 "--session",
218 "t3-a",
219 ]);
220 // The command is found past leading flags and their values, in either form.
221 for (const config of [["--config", "/o.json"], ["--config=/o.json"]]) {
222 expect(yield* Effect.promise(() => launch([...config, "connection"]))).toEqual([
223 ...config,
224 "connection",
225 "--session",
226 "t3-a",
227 ]);
228 // device status reads claims on the machine it runs on, which is not the device's host.
229 yield* Effect.promise(() =>
230 expect(launch([...config, "device", "status"])).rejects.toThrow(
231 "`device status` reads this machine's device claims, not those on the device's host",
232 ),
233 );
234 }
235 expect(yield* Effect.promise(() => launch(["--json", ...cdp]))).toEqual(["--json", ...cdp]);
236 for (const managed of [
237 ["connect"],
238 ["disconnect"],
239 ["auth", "status"],
240 ["daemon", "stop"],
241 ["--config", "/o.json", "auth", "status"],
242 ["--", "connect"],
243 ]) {
244 yield* Effect.promise(() =>
245 expect(launch(managed)).rejects.toThrow(
246 `T3 Code manages this command's connection to the device, so \`${managed.find((arg) => ["connect", "disconnect", "auth", "daemon"].includes(arg))}\` is not available through it.`,
247 ),
248 );
249 }
250 }).pipe(
251 Effect.scoped,
252 Effect.provideService(HostProcess.IsExecutable, false),
253 Effect.provideService(HostProcess.ExecutablePath, process.execPath),
254 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
255 ),
256 );
257​
258 it("tells a released session from a close that failed or timed out", () => {
259 const result = (code: number | null, stderr = "", timedOut = false) => ({
260 stdout: "",
261 stderr,
262 code,
263 timedOut,
264 });
265 expect(agentDeviceCloseOutcome(result(0))).toBe("closed");
266 // A session that never ran `open` has nothing to release.
267 expect(
268 agentDeviceCloseOutcome(
269 result(1, "Error (SESSION_NOT_FOUND): No active session. Run open first."),
270 ),
271 ).toBe("no-session");
272 expect(
273 agentDeviceCloseOutcome(result(1, "Error (COMMAND_FAILED): Remote daemon is unavailable")),
274 ).toBe("failed");
275 expect(agentDeviceCloseOutcome(result(null, "", true))).toBe("timed-out");
276 });
277​
278 it.live("stops before agent-device when T3's device connection is down", () =>
279 Effect.gen(function* () {
280 const fs = yield* FileSystem.FileSystem;
281 const path = yield* Path.Path;
282 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-endpoint-" });
283 const entryPath = path.join(dir, "cli.mjs");
284 yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));");
285 const configPath = path.join(dir, "host.json");
286 // A port that was just free: nothing listens on it, so a connect is refused.
287 const port = yield* Effect.promise(
288 () =>
289 new Promise<number>((resolve) => {
290 const server = NodeNet.createServer();
291 server.listen(0, "127.0.0.1", () => {
292 const address = server.address() as NodeNet.AddressInfo;
293 server.close(() => resolve(address.port));
294 });
295 }),
296 );
297 yield* writeAgentDeviceConfig(configPath, {
298 baseUrl: `http://127.0.0.1:${port}`,
299 token: "secret",
300 });
301 const command = yield* bindAgentDeviceLauncher({
302 entryPath,
303 stateDir: dir,
304 session: "t3-a",
305 targetArgs: ["--config", configPath, "--session", "t3-a"],
306 });
307 const launch = (args: ReadonlyArray<string>) =>
308 exec(process.execPath, [
309 path.join(path.dirname(command), "agent-device-launcher.mjs"),
310 ...args,
311 ]);
312 yield* Effect.promise(() =>
313 expect(launch(["screenshot", "/tmp/shot.png"])).rejects.toMatchObject({
314 code: 1,
315 stdout: "",
316 stderr: `T3 Code's device connection at 127.0.0.1:${port} is not reachable from this shell (connect ECONNREFUSED). Call device_open again to reconnect; if that succeeds and this repeats, this shell runs in a network sandbox, so run the command with network access, outside the sandbox.\n`,
317 }),
318 );
319 // An explicit --daemon-base-url wins over the config, as it does in agent-device.
320 const override = `http://127.0.0.1:${yield* listeningPort}`;
321 for (const flag of [["--daemon-base-url", override], [`--daemon-base-url=${override}`]]) {
322 expect(
323 JSON.parse(
324 (yield* Effect.promise(() => launch(["screenshot", "/tmp/shot.png", ...flag]))).stdout,
325 ),
326 ).toEqual([
327 "screenshot",
328 "/tmp/shot.png",
329 ...flag,
330 "--config",
331 configPath,
332 "--session",
333 "t3-a",
334 ]);
335 }
336 // cdp talks to Metro, not to T3's connection, so it still runs.
337 expect(
338 JSON.parse((yield* Effect.promise(() => launch(["cdp", "target", "list"]))).stdout),
339 ).toEqual(["cdp", "target", "list"]);
340 }).pipe(
341 Effect.scoped,
342 Effect.provideService(HostProcess.IsExecutable, false),
343 Effect.provideService(HostProcess.ExecutablePath, process.execPath),
344 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
345 ),
346 );
347​
348 it.effect("prunes the launchers of ended sessions and keeps the shared one", () =>
349 Effect.gen(function* () {
350 const fs = yield* FileSystem.FileSystem;
351 const path = yield* Path.Path;
352 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-prune-" });
353 const entryPath = path.join(stateDir, "cli.mjs");
354 const shared = yield* ensureAgentDeviceShim({ entryPath, stateDir });
355 const bind = (session: string) =>
356 bindAgentDeviceLauncher({ entryPath, stateDir, session, targetArgs: [] });
357 const kept = yield* bind("t3-open");
358 const ended = yield* bind("t3-closed");
359 yield* pruneAgentDeviceLaunchers({ stateDir, live: new Set(["t3-open"]) });
360 expect(yield* fs.exists(kept)).toBe(true);
361 expect(yield* fs.exists(path.dirname(ended))).toBe(false);
362 expect(yield* fs.exists(shared)).toBe(true);
363 yield* pruneAgentDeviceLaunchers({ stateDir, live: new Set() });
364 expect(yield* fs.readDirectory(path.join(stateDir, "device", "targets"))).toEqual([]);
365 }).pipe(
366 Effect.scoped,
367 Effect.provideService(HostProcess.IsExecutable, false),
368 Effect.provideService(HostProcess.ExecutablePath, process.execPath),
369 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
370 ),
371 );
372​
373 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
374 "tells the agent to reopen the device when the runtime it was bound to is gone",
375 () =>
376 Effect.gen(function* () {
377 const fs = yield* FileSystem.FileSystem;
378 const path = yield* Path.Path;
379 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-stale-" });
380 const runtime = path.join(dir, "old-node");
381 yield* fs.writeFileString(runtime, "#!/bin/sh\nexit 0\n");
382 yield* fs.chmod(runtime, 0o755);
383 const command = yield* bindAgentDeviceLauncher({
384 entryPath: path.join(dir, "cli.mjs"),
385 stateDir: dir,
386 session: "t3-a",
387 targetArgs: ["--config", "/h.json", "--session", "t3-a"],
388 }).pipe(Effect.provideService(HostProcess.ExecutablePath, runtime));
389 yield* fs.remove(runtime);
390 yield* Effect.promise(() =>
391 expect(exec(command, ["snapshot"])).rejects.toMatchObject({
392 code: 1,
393 stderr: expect.stringContaining("Call device_open again"),
394 }),
395 );
396 }).pipe(
397 Effect.scoped,
398 Effect.provideService(HostProcess.IsExecutable, false),
399 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
400 ),
401 );
402​
403 it.effect("runs two hosts concurrently and only updates the reconnected host", () =>
404 Effect.gen(function* () {
405 const fs = yield* FileSystem.FileSystem;
406 const path = yield* Path.Path;
407 const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" });
408 const platform = yield* HostProcess.Platform;
409 const dir = path.join(
410 temp,
411 platform === "win32" ? "paths with spaces" : "quotes '\" $HOME `literal`",
412 );
413 yield* fs.makeDirectory(dir);
414 const entryPath = path.join(dir, "cli.mjs");
415 yield* fs.writeFileString(
416 entryPath,
417 `import { readFileSync } from 'node:fs';
418const args = process.argv.slice(2);
419console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8'));
420if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
421 );
422 const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });
423 const files = yield* Effect.forEach(["mini", "android"], (host) =>
424 agentDeviceConfigPath(dir, host, path),
425 );
426 const ports = [yield* listeningPort, yield* listeningPort, yield* listeningPort];
427 for (const [index, file] of files.entries())
428 yield* writeAgentDeviceConfig(file, {
429 baseUrl: `http://127.0.0.1:${ports[index]}`,
430 token: `token-${index}`,
431 });
432 const invoke = (file: string) =>
433 exec(
434 platform === "win32" ? process.execPath : path.join(shim, "agent-device"),
435 [
436 ...(platform === "win32" ? [path.join(shim, "agent-device-launcher.mjs")] : []),
437 "snapshot",
438 "--config",
439 file,
440 "--session",
441 "test-session",
442 ],
443 { env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } },
444 ).then((result) => JSON.parse(result.stdout));
445 expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([
446 { daemonBaseUrl: `http://127.0.0.1:${ports[0]}`, daemonAuthToken: "token-0" },
447 { daemonBaseUrl: `http://127.0.0.1:${ports[1]}`, daemonAuthToken: "token-1" },
448 ]);
449 const second = yield* fs.readFileString(files[1]!);
450 yield* writeAgentDeviceConfig(files[0]!, {
451 baseUrl: `http://127.0.0.1:${ports[2]}`,
452 token: "new",
453 });
454 expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");
455 expect(yield* fs.readFileString(files[1]!)).toBe(second);
456 // Hosts with a daemon per device get a file per device, apart from the host's own.
457 const perDevice = yield* Effect.forEach(["udid-1", "udid-2"], (device) =>
458 agentDeviceConfigPath(dir, "eas", path, device),
459 );
460 expect(new Set([...perDevice, yield* agentDeviceConfigPath(dir, "eas", path)]).size).toBe(3);
461 expect(yield* agentDeviceSession("thread", "mini", "same-id")).not.toBe(
462 yield* agentDeviceSession("thread", "android", "same-id"),
463 );
464 for (const args of [
465 ["snapshot"],
466 ["snapshot", "--config", files[0]!],
467 ["snapshot", "--config", "help"],
468 ["snapshot", "--config", files[0]!, "--session"],
469 ]) {
470 yield* Effect.promise(() =>
471 expect(
472 exec(process.execPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]),
473 ).rejects.toThrow("Call device_open first"),
474 );
475 }
476 }).pipe(
477 Effect.scoped,
478 Effect.provideService(HostProcess.IsExecutable, true),
479 Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"),
480 Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
481 ),
482 );
483});