From fba53b65a642ebde7d002cff9c46de5df2614d07 Mon Sep 17 00:00:00 2001 From: DongYun Kang Date: Thu, 12 Nov 2026 21:37:16 +0900 Subject: [PATCH 1/2] perf(scripts): each new scenario no longer pays 1.6 s to add and remove its project `t3.project` and its cleanup started `node apps/server/src/bin.ts` once each, and the process takes about 0.7 s before it does anything. Every scenario calls `t3.project`, so each new one paid about 1.6 s of fixture time before it tested anything. Projects now go through `/api/projects/mutate`. The run issues one session through the CLI, once, for all its tests. Co-Authored-By: Claude Opus 5.5 --- docs/operations/scenarios.md | 6 ++- scripts/scenarios/t3.ts | 83 +++++++++++++++++++++++++++++++++--- 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md index c49f23a4b2068..4505cff57dd00 100644 --- a/docs/operations/scenarios.md +++ b/docs/operations/scenarios.md @@ -44,8 +44,10 @@ adds one fixture for what e2e cannot know about T3 Code: message before returning, so the thread has real history. `model` picks the recording. - `t3.open(path)` opens the app, already signed in. -State is created through the app's own CLI, so it is real events and works on a base commit -whose schema differs. New threads default to the replay agent's `showcase` recording: type a +State is created through the server's own API and CLI, so it is real events and works on a base +commit whose database schema differs. Projects go through the HTTP API, since each CLI call +starts a process that takes about 0.7 s; the run issues one session for it through the CLI. +A base older than the projects API (`/api/projects/mutate`) cannot run a scenario. New threads default to the replay agent's `showcase` recording: type a message and send it. To play something else, add a recording to the [replay agent](development.md#replay-provider). diff --git a/scripts/scenarios/t3.ts b/scripts/scenarios/t3.ts index 4df481ace8cf2..915ed0e74f8e6 100644 --- a/scripts/scenarios/t3.ts +++ b/scripts/scenarios/t3.ts @@ -1,15 +1,20 @@ // `test` for web scenarios: e2e's web test plus `t3`, which seeds state and opens the signed-in // app. `t3-mobile.ts` is the same for the iOS app. +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { test as base } from "@e2e-dev/web"; +import { CommandId, EnvironmentHttpApi, type ProjectMutation, ProjectId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import { FetchHttpClient } from "effect/http"; +import * as HttpApiClient from "effect/http-api/HttpApiClient"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { APP_DIR, T3_HOME } from "../e2e.config.ts"; -import { run } from "./shell.ts"; +import { run, ScenarioError } from "./shell.ts"; export interface T3State { /** Creates a git repository holding `files` and adds it as a project. Returns its path. */ @@ -39,7 +44,27 @@ const decodeLaunched = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Struct({ threadId: Schema.String })), ); -/** State for the server on `home`, made through the app's own CLI: real events, not inserted rows. */ +const decodeRuntimeState = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ origin: Schema.String })), +); + +type ServerApi = { + readonly client: HttpApiClient.ForApi; + readonly headers: { readonly authorization: string }; +}; + +/** + * One API client per T3 home, signed in once per run. Each CLI call starts a process that takes + * about 0.7 s before it does anything, so per-test state goes through the server's HTTP API. + */ +const serverApis = new Map>(); + +const uuid = SecureRandom.SecureRandom.pipe( + Effect.flatMap((random) => random.uuidV4()), + Effect.provide(NodeCrypto.layer), +); + +/** State for the server on `home`, made through the app's own CLI and API: real events, not inserted rows. */ export function makeState(home: string, env: NodeJS.ProcessEnv = {}) { const cli = Effect.fn("scenario.cli")(function* ( args: ReadonlyArray, @@ -56,7 +81,39 @@ export function makeState(home: string, env: NodeJS.ProcessEnv = {}) { }, ); }); - const projects: Array = []; + + // The server writes its origin beside its database once it listens. + const connect = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { origin } = yield* decodeRuntimeState( + yield* fs.readFileString(path.join(home, "userdata", "server-runtime.json")), + ); + const issued = yield* cli(["auth", "session", "issue", "--token-only", "--label", "scenario"]); + const client = yield* HttpApiClient.make(EnvironmentHttpApi, { baseUrl: origin }); + return { client, headers: { authorization: `Bearer ${issued.stdout.trim()}` } }; + }).pipe(Effect.provide(FetchHttpClient.layer)); + const api = Effect.tryPromise({ + try: () => { + const known = serverApis.get(home); + if (known !== undefined) return known; + const connecting = runPromise(connect); + serverApis.set(home, connecting); + // A failed sign-in is not kept: the next test tries again. + connecting.catch(() => serverApis.delete(home)); + return connecting; + }, + catch: (cause) => new ScenarioError({ message: `Could not reach the server API: ${cause}` }), + }); + const mutate = Effect.fn("scenario.mutate")(function* (mutation: ProjectMutation) { + const { client, headers } = yield* api; + return yield* client.projects + .mutate({ headers, payload: mutation }) + .pipe( + Effect.mapError((cause) => new ScenarioError({ message: `${mutation.type}: ${cause}` })), + ); + }); + const projects: Array = []; const project = Effect.fn("scenario.project")(function* ( title: string, @@ -80,8 +137,14 @@ export function makeState(home: string, env: NodeJS.ProcessEnv = {}) { yield* git(["init", "--quiet", "--initial-branch=main"]); yield* git(["add", "--all"]); yield* git([...author, "commit", "--quiet", "--message", "Initial commit"]); - yield* cli(["project", "add", workspace, "--title", title]); - projects.push(workspace); + const created = yield* mutate({ + type: "project.create", + commandId: CommandId.make(yield* uuid), + projectId: ProjectId.make(yield* uuid), + title, + workspaceRoot: workspace, + }); + projects.push(created.id); return workspace; }); @@ -110,7 +173,15 @@ export function makeState(home: string, env: NodeJS.ProcessEnv = {}) { // The server outlives the test, so the next one must not see this one's projects. cleanup: Effect.forEach( projects, - (workspace) => cli(["project", "remove", workspace, "--force"]), + (projectId) => + Effect.flatMap(uuid, (commandId) => + mutate({ + type: "project.delete", + commandId: CommandId.make(commandId), + projectId, + force: true, + }), + ), { discard: true }, ), }; From 595342cd224ddd38138edfd1bda9683fff7ddcc4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 02:57:34 +0000 Subject: [PATCH 2/2] docs(scenarios): tighten the note on state made through the API Co-Authored-By: Claude Opus 5.5 --- docs/operations/scenarios.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md index 8cf12364c4ea1..798d066371684 100644 --- a/docs/operations/scenarios.md +++ b/docs/operations/scenarios.md @@ -46,10 +46,10 @@ adds one fixture for what e2e cannot know about T3 Code: - `t3.open(path)` opens the app, already signed in, at a path. `t3.open(thread)` opens it on a thread. -State is created through the server's own API and CLI, so it is real events and works on a base -commit whose database schema differs. Projects go through the HTTP API, since each CLI call -starts a process that takes about 0.7 s; the run issues one session for it through the CLI. -A base older than the projects API (`/api/projects/mutate`) cannot run a scenario. New threads default to the replay agent's `showcase` recording: type a +State is created through the server's own CLI and HTTP API, so it is real events and works on a +base commit whose database schema differs. Projects go through the API (`/api/projects/mutate`), +since each CLI call starts a process that takes about 0.7 s; a base older than that endpoint +cannot run a scenario. New threads default to the replay agent's `showcase` recording: type a message and send it. To play something else, add a recording to the [replay agent](development.md#replay-provider).