Skip to content
Source
Sign inSign up

t3.ts

167 lines6.7 KB

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 { expect } from "e2e";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
12​
13import { APP_DIR, T3_HOME } from "../e2e.config.ts";
14import { run } from "./shell.ts";
15​
16export interface T3State {
17 /** Creates a git repository holding `files` and adds it as a project. Returns its path. */
18 readonly project: (title: string, files?: Readonly<Record<string, string>>) => Promise<string>;
19 /**
20 * Starts a thread in a project and plays one replayed turn per message before returning.
21 * `model` is a dev replay recording: showcase, todo-list, approval, question, multi-turn.
22 */
23 readonly thread: (input: {
24 readonly project: string;
25 readonly title: string;
26 readonly messages: ReadonlyArray<string>;
27 readonly model?: string;
28 }) => Promise<T3Thread>;
30​
31export interface T3Thread {
32 readonly threadId: string;
34​
35export interface T3 extends T3State {
36 /**
37 * Opens the app, signed in, at a path or on a thread `t3.thread` returned. Opening the thread
38 * by its link is the reliable way to reach it: a sidebar row covers its own title text.
39 */
40 readonly open: (target?: string | T3Thread) => Promise<void>;
42​
43/** e2e's fixtures are promise functions; this is the one place an Effect becomes a promise. */
44export const runPromise = <A, E>(effect: Effect.Effect<A, E, NodeServices.NodeServices>) =>
45 Effect.runPromise(effect.pipe(Effect.provide(NodeServices.layer)));
46​
47const decodeLaunched = Schema.decodeUnknownEffect(
48 Schema.fromJsonString(Schema.Struct({ threadId: Schema.String })),
49);
50​
51/** State for the server on `home`, made through the app's own CLI: real events, not inserted rows. */
52export function makeState(home: string, env: NodeJS.ProcessEnv = {}) {
53 const cli = Effect.fn("scenario.cli")(function* (
54 args: ReadonlyArray<string>,
55 allowExit: ReadonlyArray<number> = [0],
56 ) {
57 const hostEnvironment = yield* HostProcess.Environment;
58 return yield* run(
59 yield* HostProcess.ExecutablePath,
60 ["apps/server/src/bin.ts", ...args, "--base-dir", home],
61 {
62 cwd: APP_DIR,
63 env: { PATH: hostEnvironment.PATH, HOME: hostEnvironment.HOME, ...env },
64 allowExit,
65 },
66 );
67 });
68 const projects: Array<string> = [];
69​
70 const project = Effect.fn("scenario.project")(function* (
71 title: string,
72 files: Readonly<Record<string, string>>,
73 ) {
74 const fs = yield* FileSystem.FileSystem;
75 const path = yield* Path.Path;
76 // Its own folder per test: the server and this home outlive one test.
77 yield* fs.makeDirectory(path.join(home, "workspaces"), { recursive: true });
78 const parent = yield* fs.makeTempDirectory({
79 directory: path.join(home, "workspaces"),
80 prefix: "w-",
81 });
82 const workspace = path.join(parent, title);
83 for (const [file, content] of Object.entries({ "README.md": "", ...files })) {
84 yield* fs.makeDirectory(path.dirname(path.join(workspace, file)), { recursive: true });
85 yield* fs.writeFileString(path.join(workspace, file), content);
86 }
87 const git = (args: ReadonlyArray<string>) => run("git", args, { cwd: workspace });
88 const author = ["-c", "user.name=Scenario", "-c", "user.email=scenario@example.invalid"];
89 yield* git(["init", "--quiet", "--initial-branch=main"]);
90 yield* git(["add", "--all"]);
91 yield* git([...author, "commit", "--quiet", "--message", "Initial commit"]);
92 yield* cli(["project", "add", workspace, "--title", title]);
93 projects.push(workspace);
94 return workspace;
95 });
96​
97 const thread = Effect.fn("scenario.thread")(function* (input: {
98 readonly project: string;
99 readonly title: string;
100 readonly messages: ReadonlyArray<string>;
101 readonly model?: string;
102 }) {
103 // Exit code 2 means the turn waits for an answer, which a scenario may want to start from.
104 const turn = (args: ReadonlyArray<string>) =>
105 cli(["thread", ...args, "--wait", "--json"], [0, 2]);
106 const [first = "Start", ...rest] = input.messages;
107 const replay = ["--provider", "devReplay", "--model", input.model ?? "multi-turn"];
108 const started = yield* turn(["start", input.project, first, "--title", input.title, ...replay]);
109 const { threadId } = yield* decodeLaunched(started.stdout);
110 for (const message of rest) yield* turn(["send", threadId, message]);
111 return { threadId } satisfies T3Thread;
112 });
113​
114 return {
115 cli,
116 state: {
117 project: (title, files = {}) => runPromise(project(title, files)),
118 thread: (input) => runPromise(thread(input)),
119 } satisfies T3State,
120 // The server outlives the test, so the next one must not see this one's projects.
121 cleanup: Effect.forEach(
122 projects,
123 (workspace) => cli(["project", "remove", workspace, "--force"]),
124 { discard: true },
125 ),
126 };
128​
129const readPairingPath = Effect.gen(function* () {
130 const fs = yield* FileSystem.FileSystem;
131 const path = yield* Path.Path;
132 return yield* fs.readFileString(path.join(T3_HOME, "pairing-path"));
133});
134​
135/** The id every thread link starts with, asked of the server the app talks to. */
136const environmentId = (baseUrl: string) =>
137 HttpClient.get(`${baseUrl}/.well-known/t3/environment`).pipe(
138 Effect.flatMap(HttpClientResponse.filterStatusOk),
139 Effect.flatMap(
140 HttpClientResponse.schemaBodyJson(Schema.Struct({ environmentId: Schema.String })),
141 ),
142 Effect.map((environment) => environment.environmentId),
143 Effect.provide(FetchHttpClient.layer),
144 );
145​
146export const test = base.extend<{ t3: T3 }>({
147 t3: async ({ app, browser }, use) => {
148 const { state, cleanup } = makeState(T3_HOME);
149 await use({
150 ...state,
151 open: async (target = "/") => {
152 await app.open(await runPromise(readPairingPath));
153 // The pairing page signs in and then leaves itself. Going anywhere before it has done
154 // so cancels the sign-in, and the next page is the pairing page again.
155 await expect(browser).not.toHaveURL(/\/pair(?:[?#]|$)/, { timeout: 60_000 });
156 if (typeof target !== "string") {
157 const environment = await runPromise(environmentId(app.baseUrl ?? ""));
158 await app.open(`/${environment}/${target.threadId}`);
159 } else if (target !== "/") await app.open(target);
160 },
161 });
162 await runPromise(cleanup);
163 },
164});
165​
166/** Test option for a web scenario: the iOS target skips it. */
167export const WEB = { platforms: ["web"] } as const;