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 NodeUtil from "node:util";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts";
11import {
12 agentDeviceConfigPath,
13 agentDeviceSession,
14 writeAgentDeviceConfig,
15} from "./AgentDeviceTarget.ts";
16
17const exec = NodeUtil.promisify(NodeChildProcess.execFile);
18
19describe("host-bound agent commands", () => {
20 it.effect("runs two hosts concurrently and only updates the reconnected host", () =>
21 Effect.gen(function* () {
22 const fs = yield* FileSystem.FileSystem;
23 const path = yield* Path.Path;
24 const temp = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-target-" });
25 const platform = yield* HostProcess.Platform;
26 const dir = path.join(
27 temp,
28 platform === "win32" ? "paths with spaces" : "quotes '\" $HOME `literal`",
29 );
30 yield* fs.makeDirectory(dir);
31 const entryPath = path.join(dir, "cli.mjs");
32 yield* fs.writeFileString(
33 entryPath,
34 `import { readFileSync } from 'node:fs';
35const args = process.argv.slice(2);
36console.log(readFileSync(args[args.indexOf('--config') + 1], 'utf8'));
37if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`,
38 );
39 const shim = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir });
40 const files = yield* Effect.forEach(["mini", "android"], (host) =>
41 agentDeviceConfigPath(dir, host, path),
42 );
43 for (const [index, file] of files.entries())
44 yield* writeAgentDeviceConfig(file, {
45 baseUrl: `http://127.0.0.1:${1000 + index}`,
46 token: `token-${index}`,
47 entryPath,
48 });
49 const invoke = (file: string) =>
50 exec(
51 platform === "win32" ? process.execPath : path.join(shim, "agent-device"),
52 [
53 ...(platform === "win32" ? [path.join(shim, "agent-device-launcher.mjs")] : []),
54 "snapshot",
55 "--config",
56 file,
57 "--session",
58 "test-session",
59 ],
60 { env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } },
61 ).then((result) => JSON.parse(result.stdout));
62 expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([
63 { daemonBaseUrl: "http://127.0.0.1:1000", daemonAuthToken: "token-0" },
64 { daemonBaseUrl: "http://127.0.0.1:1001", daemonAuthToken: "token-1" },
65 ]);
66 const second = yield* fs.readFileString(files[1]!);
67 yield* writeAgentDeviceConfig(files[0]!, {
68 baseUrl: "http://127.0.0.1:2000",
69 token: "new",
70 entryPath,
71 });
72 expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new");
73 expect(yield* fs.readFileString(files[1]!)).toBe(second);
74 expect(yield* agentDeviceSession("thread", "mini", "same-id")).not.toBe(
75 yield* agentDeviceSession("thread", "android", "same-id"),
76 );
77 for (const args of [
78 ["snapshot"],
79 ["snapshot", "--config", files[0]!],
80 ["snapshot", "--config", "help"],
81 ["snapshot", "--config", files[0]!, "--session"],
82 ]) {
83 yield* Effect.promise(() =>
84 expect(
85 exec(process.execPath, [path.join(shim, "agent-device-launcher.mjs"), ...args]),
86 ).rejects.toThrow("Call device_open first"),
87 );
88 }
89 }).pipe(
90 Effect.scoped,
91 Effect.provideService(HostProcess.IsExecutable, true),
92 Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"),
93 Effect.provide(NodeServices.layer),
94 ),
95 );
96});