Skip to content
Source
Sign inSign up

start-app.ts

266 lines9.8 KB

1#!/usr/bin/env node
2// The app command of e2e.config.ts: starts the dev stack of the checkout it runs in on a fresh
3// T3 home. e2e stops it with a signal, which ends the scope and the processes it started.
4import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
5import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
6import * as NodeServices from "@effect/platform-node/NodeServices";
7import * as HostProcess from "@t3tools/shared/HostProcess";
8import * as NetService from "@t3tools/shared/Net";
9import * as Console from "effect/Console";
10import * as Effect from "effect/Effect";
11import * as FileSystem from "effect/FileSystem";
12import * as Path from "effect/Path";
13import * as SecureRandom from "effect/crypto/SecureRandom";
14import * as Schedule from "effect/Schedule";
15import * as Schema from "effect/Schema";
16import * as Stream from "effect/Stream";
17import { Argument, Command } from "effect/cli";
18import { ChildProcess, ChildProcessSpawner } from "effect/process";
19​
20import { ScenarioError } from "./shell.ts";
21​
22// The dev runner's web port is 5733 plus an offset. Mirror of BASE_WEB_PORT in dev-runner.ts.
23const BASE_WEB_PORT = 5733;
24// The replay agent `vp run dev` registers: free, recorded turns through the real adapter.
25const REPLAY = { instanceId: "devReplay", model: "showcase" };
26// Generated text (titles) cannot go to the replay agent: the server swaps an ACP agent for the
27// first enabled real provider, else its default, Codex. So every provider it could fall back to
28// is off and Codex is a command that only fails: no run reaches a paid model.
29// Mirror of TEXT_GENERATION_FALLBACK_DRIVERS in shared/serverSettings.
30const REAL_PROVIDERS = [
31 "codex",
32 "claudeAgent",
33 "cursor",
34 "grok",
35 "muse",
36 "pi",
37 "opencode",
38 "antigravity",
39];
40​
41// The server labels its environment with the host's name, probed through these commands.
42// Captures must not depend on, or show, the machine that made them.
43const HOST_LABEL_SHIM = `#!/bin/sh
44case "$*" in
45 "--pretty" | "--get ComputerName") echo "Scenario" ;;
46 *) for real in /usr/bin /usr/sbin /bin; do
47 [ -x "$real/$(basename "$0")" ] && exec "$real/$(basename "$0")" "$@"
48 done
49 exit 127 ;;
50esac
51`;
52​
53const encodeSettings = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
54​
55const prepareHome = Effect.fn("startApp.prepareHome")(function* (home: string) {
56 const fs = yield* FileSystem.FileSystem;
57 const path = yield* Path.Path;
58 yield* fs.remove(home, { recursive: true, force: true });
59 yield* fs.makeDirectory(path.join(home, "userdata"), { recursive: true });
60 yield* fs.makeDirectory(path.join(home, "bin"));
61 for (const command of ["hostnamectl", "scutil"]) {
62 const shim = path.join(home, "bin", command);
63 yield* fs.writeFileString(shim, HOST_LABEL_SHIM);
64 yield* fs.chmod(shim, 0o755);
65 }
66 const noModel = path.join(home, "bin/no-model");
67 yield* fs.writeFileString(noModel, "#!/bin/sh\necho 'scenarios run no model' >&2\nexit 1\n");
68 yield* fs.chmod(noModel, 0o755);
69 yield* fs.writeFileString(
70 path.join(home, "userdata/settings.json"),
71 yield* encodeSettings({
72 defaultModelSelection: REPLAY,
73 providerInstances: Object.fromEntries(
74 REAL_PROVIDERS.map((driver) => [
75 driver,
76 {
77 driver,
78 enabled: false,
79 ...(driver === "codex" ? { config: { binaryPath: noModel } } : {}),
80 },
81 ]),
82 ),
83 }),
84 );
85});
86​
87/** Starts a process in the caller's scope and hands each line it prints to `onLine`. */
88const start = Effect.fn("startApp.start")(function* (input: {
89 readonly cwd: string;
90 readonly command: string;
91 readonly args: ReadonlyArray<string>;
92 readonly env: NodeJS.ProcessEnv;
93 readonly onLine: (line: string) => Effect.Effect<void, never, FileSystem.FileSystem>;
94}) {
95 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
96 const child = yield* spawner.spawn(
97 ChildProcess.make(input.command, [...input.args], {
98 cwd: input.cwd,
99 env: input.env,
100 extendEnv: false,
101 stdin: "ignore",
102 stdout: "pipe",
103 stderr: "inherit",
104 forceKillAfter: "5 seconds",
105 }),
106 );
107 yield* child.stdout.pipe(
108 Stream.decodeText(),
109 Stream.splitLines,
110 Stream.runForEach(input.onLine),
111 Effect.ignore,
112 Effect.forkScoped,
113 );
114 return child;
115});
116​
117const startApp = Effect.fn("startApp")(function* (input: {
118 readonly surface: "web" | "mobile";
119 readonly home: string;
120 readonly ports: ReadonlyArray<string>;
121}) {
122 const fs = yield* FileSystem.FileSystem;
123 const path = yield* Path.Path;
124 const net = yield* NetService.NetService;
125 const node = yield* HostProcess.ExecutablePath;
126 const checkout = yield* HostProcess.WorkingDirectory;
127 const hostEnvironment = yield* HostProcess.Environment;
128 yield* prepareHome(input.home);
129 const env = {
130 ...hostEnvironment,
131 PATH: `${path.join(input.home, "bin")}:${hostEnvironment.PATH ?? ""}`,
132 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "0",
133 };
134 const devRunner = (mode: string, args: ReadonlyArray<string>) => [
135 "scripts/dev-runner.ts",
136 mode,
137 "--home-dir",
138 input.home,
139 "--no-seed",
140 ...args,
141 ];
142​
143 if (input.surface === "web") {
144 // e2e opens the app at this host, so the web server is told to bind exactly it. Left to its
145 // default, `localhost`, it binds ::1 alone on machines that resolve the name to IPv6 first.
146 const [host = "", webPort = ""] = input.ports;
147 const origin = `http://${host}:${webPort}`;
148 const serverPort = String(yield* net.reserveLoopbackPort());
149 const token = yield* (yield* SecureRandom.SecureRandom).uuidV4();
150 const ports = ["--port", serverPort, "--dev-url", origin];
151 const webEnv = { ...env, T3CODE_PORT_OFFSET: String(Number(webPort) - BASE_WEB_PORT) };
152 const server = yield* start({
153 cwd: checkout,
154 command: node,
155 args: devRunner("dev:server", ports),
156 env: {
157 ...webEnv,
158 T3CODE_DEV_ALLOWED_ORIGINS: origin,
159 // Makes the startup pairing link reusable, so every test can sign in with it.
160 T3CODE_DEV_AUTH_TOKEN: `${token}${token}`.replaceAll("-", ""),
161 },
162 onLine: (line) => {
163 const url = /pairingUrl:\s*(\S+)/.exec(line)?.[1];
164 if (url === undefined) return Console.log(line);
165 // The link is a credential: it goes to a file the tests read, never to the log.
166 const { pathname, search, hash } = new URL(url);
167 return fs
168 .writeFileString(path.join(input.home, "pairing-path"), `${pathname}${search}${hash}`)
169 .pipe(Effect.ignore);
170 },
171 });
172 const web = yield* start({
173 cwd: checkout,
174 command: node,
175 args: devRunner("dev:web", [...ports, "--", "--host", host]),
176 env: webEnv,
177 onLine: Console.log,
178 });
179 // A web server that came up on another loopback address would leave e2e waiting out its
180 // whole startup timeout; say so at once instead.
181 const boundElsewhere = Effect.gen(function* () {
182 const elsewhere = host === "::1" ? "127.0.0.1" : "::1";
183 if (yield* net.hasListenerOnHost(Number(webPort), host)) return yield* Effect.never;
184 if (!(yield* net.hasListenerOnHost(Number(webPort), elsewhere))) {
185 return yield* new ScenarioError({ message: "The web server is not listening yet." });
186 }
187 yield* Console.error(
188 `The web server listens on ${elsewhere}:${webPort}, not on ${host}: it ignored --host.`,
189 );
190 return 1;
191 }).pipe(
192 Effect.retry({
193 schedule: Schedule.spaced("1 second"),
194 while: (error) => error._tag === "ScenarioError",
195 }),
196 );
197 return yield* Effect.raceAll([server.exitCode, web.exitCode, boundElsewhere]);
198 }
199​
200 // The device is another machine: the server listens on every interface, and Metro serves the
201 // dev client its JavaScript. Tests mint their own pairing links, so the startup one is dropped.
202 const [serverPort = "", metroPort = "", host = ""] = input.ports;
203 const server = yield* start({
204 cwd: checkout,
205 command: node,
206 args: devRunner("dev:server", ["--host", "0.0.0.0", "--port", serverPort]),
207 env,
208 onLine: (line) => (line.includes("pairingUrl:") ? Effect.void : Console.log(line)),
209 });
210 yield* net.hasListenerOnHost(Number(serverPort), "127.0.0.1").pipe(
211 Effect.filterOrFail(
212 (listening) => listening,
213 () => new ScenarioError({ message: "The server is not listening yet." }),
214 ),
215 Effect.retry({ schedule: Schedule.spaced("500 millis"), times: 600 }),
216 );
217 const metro = yield* start({
218 cwd: path.join(checkout, "apps/mobile"),
219 command: "npx",
220 args: ["expo", "start", "--dev-client", "--scheme", "t3code-dev", "--lan", "--port", metroPort],
221 env: {
222 ...env,
223 APP_VARIANT: "development",
224 CI: "1",
225 // Lazily fetched chunks sometimes arrive broken on pool devices.
226 EXPO_NO_METRO_LAZY: "1",
227 REACT_NATIVE_PACKAGER_HOSTNAME: host,
228 },
229 onLine: Console.log,
230 });
231 return yield* Effect.raceFirst(server.exitCode, metro.exitCode);
232});
233​
234const command = Command.make(
235 "start-app",
236 {
237 surface: Argument.Literals("surface", ["web", "mobile"]).pipe(
238 Argument.withDescription(
239 "web: server and Vite. mobile: server on every interface and Metro.",
240 ),
241 ),
242 home: Argument.String("t3-home").pipe(Argument.withDescription("T3 home to recreate.")),
243 ports: Argument.String("port").pipe(
244 Argument.withDescription(
245 "web: <host> <web-port>. mobile: <server-port> <metro-port> <host>.",
246 ),
247 Argument.variadic(),
248 ),
249 },
250 (input) =>
251 startApp(input).pipe(
252 Effect.flatMap((exitCode) =>
253 Effect.sync(() => {
254 process.exitCode = Number(exitCode) || 1;
255 }),
256 ),
257 ),
258);
259​
260if (import.meta.main) {
261 Command.run(command, { version: "0.0.0" }).pipe(
262 Effect.scoped,
263 Effect.provide([NodeServices.layer, NodeCrypto.layer, NetService.layer]),
264 NodeRuntime.runMain,
265 );