diff --git a/docs/operations/scenarios.md b/docs/operations/scenarios.md index db4ea982cf0e0..798d066371684 100644 --- a/docs/operations/scenarios.md +++ b/docs/operations/scenarios.md @@ -46,8 +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 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 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). diff --git a/scripts/scenarios/t3.ts b/scripts/scenarios/t3.ts index 113916cf94081..7b9d916b7d8c6 100644 --- a/scripts/scenarios/t3.ts +++ b/scripts/scenarios/t3.ts @@ -1,17 +1,21 @@ // `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 { expect } from "e2e"; +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 * as HttpApiClient from "effect/http-api/HttpApiClient"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; 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. */ @@ -52,7 +56,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, @@ -69,7 +93,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, @@ -95,8 +151,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; }); @@ -126,7 +188,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 }, ), };