1// Scenarios: e2e tests that run against the real dev stack. See docs/operations/scenarios.md.
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { mobile, type DeviceProvider } from "@e2e-dev/mobile";
5import { web } from "@e2e-dev/web";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import { createAgentDeviceClient } from "agent-device";
8import type { E2EConfig } from "e2e";
9import * as Effect from "effect/Effect";
10import * as Path from "effect/Path";
11import * as SecureRandom from "effect/crypto/SecureRandom";
12import * as Schedule from "effect/Schedule";
13
14import { run, ScenarioError } from "./scenarios/shell.ts";
15
16// e2e loads this module for its values, so the paths and the host are resolved once, here.
17const resolved = await Effect.gen(function* () {
18 const path = yield* Path.Path;
19 const env = yield* HostProcess.Environment;
20 const scripts = yield* path.fromFileUrl(new URL(".", import.meta.url));
21 const home = path.resolve(scripts, "../.t3/scenarios");
22 // A pooled device reaches this machine over the tailnet, not localhost.
23 const host = yield* run("tailscale", ["ip", "-4"]).pipe(
24 Effect.map((result) => result.stdout.trim().split("\n")[0] ?? ""),
25 Effect.orElseSucceed(() => ""),
26 );
27 return {
28 node: yield* HostProcess.ExecutablePath,
29 scripts,
30 /** The checkout whose app runs. `prove-scenario.ts` points it at a base or head worktree. */
31 appDir: path.resolve(scripts, env.T3_SCENARIO_APP_DIR ?? ".."),
32 home,
33 startApp: path.join(scripts, "scenarios/start-app.ts"),
34 mobile: {
35 host: host || "127.0.0.1",
36 // A mobile target has no `app.url`, so e2e cannot pick these ports.
37 serverPort: Number(env.T3_SCENARIO_SERVER_PORT ?? 13_990),
38 metroPort: Number(env.T3_SCENARIO_METRO_PORT ?? 8_190),
39 },
40 };
41}).pipe(Effect.provide(NodeServices.layer), Effect.runPromise);
42
43export const APP_DIR = resolved.appDir;
44/** T3 homes only scenarios use, recreated on every run. `.t3` is gitignored. */
45export const T3_HOME = resolved.home;
46export const MOBILE_T3_HOME = `${T3_HOME}-mobile`;
47export const MOBILE = resolved.mobile;
48const startApp = resolved.startApp;
49/**
50 * The one address e2e opens the web app at and the app is told to bind. Never `localhost`: the
51 * runner and the app can resolve that name to different addresses.
52 */
53const LOOPBACK = "127.0.0.1";
54
55/**
56 * Leases a device from the Simlock pool, whose gateway speaks agent-device's lease protocol.
57 * `retainOnClose` keeps the device when the engine closes its session between attempts.
58 */
59const simlock = (device: string): DeviceProvider => {
60 const connect = Effect.fn("simlock.connect")(function* (
61 env: Readonly<Record<string, string | undefined>>,
62 ) {
63 const daemonBaseUrl = env.SIMLOCK_AGENT_DEVICE_URL;
64 const daemonAuthToken = env.AGENT_DEVICE_DAEMON_AUTH_TOKEN;
65 if (daemonBaseUrl === undefined || daemonAuthToken === undefined) {
66 return yield* new ScenarioError({
67 message: "SIMLOCK_AGENT_DEVICE_URL and AGENT_DEVICE_DAEMON_AUTH_TOKEN are not set.",
68 });
69 }
70 return { daemonBaseUrl, daemonAuthToken };
71 });
72 const call = <A>(description: string, request: () => Promise<A>) =>
73 Effect.tryPromise({
74 try: request,
75 catch: (cause) => new ScenarioError({ message: `${description}: ${String(cause)}` }),
76 });
77 const acquire = Effect.fn("simlock.acquire")(function* (
78 request: Parameters<DeviceProvider["acquire"]>[0],
79 ) {
80 const daemon = yield* connect(request.env);
81 const client = createAgentDeviceClient(daemon);
82 const platform = request.platform === "android" ? "android" : "ios";
83 const scope = { tenant: "t3-scenarios", runId: `${request.runId}-${request.slot}` };
84 // Simlock takes the lease id from the caller: 32 hex characters.
85 const leaseId = (yield* (yield* SecureRandom.SecureRandom).uuidV4()).replaceAll("-", "");
86 const lease = yield* call(`The pool leased no ${device}`, () =>
87 client.leases.allocate({ ...scope, platform, device, retainOnClose: true, leaseId }),
88 ).pipe(
89 // A busy gateway answers its health probe late, which the client reports as unavailable.
90 Effect.tapError(() => Effect.sync(() => request.log("the pool is busy; asking again"))),
91 Effect.retry({ schedule: Schedule.spaced("10 seconds"), times: 12 }),
92 );
93 const leaseScope = { ...scope, leaseId: lease.leaseId, leaseBackend: lease.backend };
94 // The pool answers `boot` with the leased device; a worker hosts several, so name ours.
95 const leased = yield* call("The pool did not name the leased device", () =>
96 createAgentDeviceClient({ ...daemon, ...leaseScope }).devices.boot({ platform }),
97 ).pipe(
98 // e2e releases only the leases `acquire` returned.
99 Effect.tapError(() =>
100 call("release", () => client.leases.release({ leaseId: lease.leaseId })).pipe(
101 Effect.ignore,
102 ),
103 ),
104 );
105 return {
106 id: lease.leaseId,
107 daemon: { baseUrl: daemon.daemonBaseUrl, authToken: daemon.daemonAuthToken },
108 deviceId: leased.id,
109 client: {
110 ...leaseScope,
111 ...(lease.leaseProvider === undefined ? {} : { leaseProvider: lease.leaseProvider }),
112 },
113 };
114 });
115 return {
116 name: "simlock",
117 acquire: (request) =>
118 acquire(request).pipe(Effect.provide(NodeCrypto.layer), Effect.runPromise),
119 release: (lease, context) =>
120 connect(context.env).pipe(
121 Effect.flatMap((daemon) =>
122 call("release", () =>
123 createAgentDeviceClient(daemon).leases.release({ leaseId: lease.id }),
124 ),
125 ),
126 Effect.asVoid,
127 Effect.runPromise,
128 ),
129 };
130};
131
132export default {
133 tests: "scenarios/**/*.e2e.ts",
134 targets: [
135 {
136 name: "web",
137 engine: web({ viewport: { width: 1280, height: 800 } }),
138 app: {
139 url: `http://${LOOPBACK}:0`,
140 // Vite answers before the server does; this path is proxied to the server.
141 readyUrl: `http://${LOOPBACK}:{port}/.well-known/t3/environment`,
142 command: {
143 executable: resolved.node,
144 args: [startApp, "web", T3_HOME, LOOPBACK, "{port}"],
145 cwd: APP_DIR,
146 startupTimeout: 300_000,
147 log: ".e2e/app.log",
148 },
149 },
150 },
151 {
152 name: "ios",
153 engine: mobile({
154 platform: "ios",
155 device: simlock("iPhone 18 Pro"),
156 session: "t3-scenarios",
157 }),
158 app: {
159 bundleId: "com.t3tools.t3code.dev",
160 // Metro starts after the server listens, so it answering means both are up.
161 readyUrl: `http://127.0.0.1:${MOBILE.metroPort}/status`,
162 command: {
163 executable: resolved.node,
164 args: [
165 startApp,
166 "mobile",
167 MOBILE_T3_HOME,
168 String(MOBILE.serverPort),
169 String(MOBILE.metroPort),
170 MOBILE.host,
171 ],
172 cwd: APP_DIR,
173 startupTimeout: 300_000,
174 log: ".e2e/mobile-app.log",
175 },
176 },
177 },
178 ],
179 // One server holds every test's state, and a dev stack is heavy.
180 workers: 1,
181 timeout: 300_000,
182 assertionTimeout: 15_000,
183 video: "on",
184 trace: "on",
185 reporters: ["list", "markdown"],
186} satisfies E2EConfig;