1// `test` for web scenarios: e2e's web test plus `t3`, which seeds state and opens the signed-in
2// app. `t3-mobile.ts` is the same for the iOS app.
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { test as base } from "@e2e-dev/web";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10
11import { APP_DIR, T3_HOME } from "../e2e.config.ts";
12import { run } from "./shell.ts";
13
14export interface T3State {
15 /** Creates a git repository holding `files` and adds it as a project. Returns its path. */
16 readonly project: (title: string, files?: Readonly<Record<string, string>>) => Promise<string>;
17 /**
18 * Starts a thread in a project and plays one replayed turn per message before returning.
19 * `model` is a dev replay recording: showcase, todo-list, approval, question, multi-turn.
20 */
21 readonly thread: (input: {
22 readonly project: string;
23 readonly title: string;
24 readonly messages: ReadonlyArray<string>;
25 readonly model?: string;
26 }) => Promise<void>;
27}
28
29export interface T3 extends T3State {
30 /** Opens the app, signed in, at `path`. */
31 readonly open: (path?: string) => Promise<void>;
32}
33
34/** e2e's fixtures are promise functions; this is the one place an Effect becomes a promise. */
35export const runPromise = <A, E>(effect: Effect.Effect<A, E, NodeServices.NodeServices>) =>
36 Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));
37
38const decodeLaunched = Schema.decodeUnknownEffect(
39 Schema.fromJsonString(Schema.Struct({ threadId: Schema.String })),
40);
41
42/** State for the server on `home`, made through the app's own CLI: real events, not inserted rows. */
43export function makeState(home: string, env: NodeJS.ProcessEnv = {}) {
44 const cli = Effect.fn("scenario.cli")(function* (
45 args: ReadonlyArray<string>,
46 allowExit: ReadonlyArray<number> = [0],
47 ) {
48 const hostEnvironment = yield* HostProcess.Environment;
49 return yield* run(
50 yield* HostProcess.ExecutablePath,
51 ["apps/server/src/bin.ts", ...args, "--base-dir", home],
52 {
53 cwd: APP_DIR,
54 env: { PATH: hostEnvironment.PATH, HOME: hostEnvironment.HOME, ...env },
55 allowExit,
56 },
57 );
58 });
59 const projects: Array<string> = [];
60
61 const project = Effect.fn("scenario.project")(function* (
62 title: string,
63 files: Readonly<Record<string, string>>,
64 ) {
65 const fs = yield* FileSystem.FileSystem;
66 const path = yield* Path.Path;
67 // Its own folder per test: the server and this home outlive one test.
68 yield* fs.makeDirectory(path.join(home, "workspaces"), { recursive: true });
69 const parent = yield* fs.makeTempDirectory({
70 directory: path.join(home, "workspaces"),
71 prefix: "w-",
72 });
73 const workspace = path.join(parent, title);
74 for (const [file, content] of Object.entries({ "README.md": "", ...files })) {
75 yield* fs.makeDirectory(path.dirname(path.join(workspace, file)), { recursive: true });
76 yield* fs.writeFileString(path.join(workspace, file), content);
77 }
78 const git = (args: ReadonlyArray<string>) => run("git", args, { cwd: workspace });
79 const author = ["-c", "user.name=Scenario", "-c", "user.email=scenario@example.invalid"];
80 yield* git(["init", "--quiet", "--initial-branch=main"]);
81 yield* git(["add", "--all"]);
82 yield* git([...author, "commit", "--quiet", "--message", "Initial commit"]);
83 yield* cli(["project", "add", workspace, "--title", title]);
84 projects.push(workspace);
85 return workspace;
86 });
87
88 const thread = Effect.fn("scenario.thread")(function* (input: {
89 readonly project: string;
90 readonly title: string;
91 readonly messages: ReadonlyArray<string>;
92 readonly model?: string;
93 }) {
94 // Exit code 2 means the turn waits for an answer, which a scenario may want to start from.
95 const turn = (args: ReadonlyArray<string>) =>
96 cli(["thread", ...args, "--wait", "--json"], [0, 2]);
97 const [first = "Start", ...rest] = input.messages;
98 const replay = ["--provider", "devReplay", "--model", input.model ?? "multi-turn"];
99 const started = yield* turn(["start", input.project, first, "--title", input.title, ...replay]);
100 const { threadId } = yield* decodeLaunched(started.stdout);
101 for (const message of rest) yield* turn(["send", threadId, message]);
102 });
103
104 return {
105 cli,
106 state: {
107 project: (title, files = {}) => runPromise(project(title, files)),
108 thread: (input) => runPromise(thread(input)),
109 } satisfies T3State,
110 // The server outlives the test, so the next one must not see this one's projects.
111 cleanup: Effect.forEach(
112 projects,
113 (workspace) => cli(["project", "remove", workspace, "--force"]),
114 { discard: true },
115 ),
116 };
117}
118
119const readPairingPath = Effect.gen(function* () {
120 const fs = yield* FileSystem.FileSystem;
121 const path = yield* Path.Path;
122 return yield* fs.readFileString(path.join(T3_HOME, "pairing-path"));
123});
124
125export const test = base.extend<{ t3: T3 }>({
126 t3: async ({ app }, use) => {
127 const { state, cleanup } = makeState(T3_HOME);
128 await use({
129 ...state,
130 open: async (path = "/") => {
131 await app.open(await runPromise(readPairingPath));
132 if (path !== "/") await app.open(path);
133 },
134 });
135 await runPromise(cleanup);
136 },
137});
138
139/** Test option for a web scenario: the iOS target skips it. */
140export const WEB = { platforms: ["web"] } as const;