/** Shared setup for the editor e2e scripts: API calls, seeded repos and local-D1 hooks. */ import { execFileSync } from "node:child_process"; import { RoomClient } from "../src/editor-extension/room-client"; import type { RoomControl } from "../src/lib/editor-protocol"; export const BASE = (process.env.EDITOR_E2E_URL ?? "http://localhost:3000").replace(/\/$/, ""); export const PAT = process.env.GITFOR_TOKEN ?? ""; export const WRITER_PAT = process.env.GITFOR_WRITER_TOKEN ?? ""; /** Local D1 sqlite file; lets the script flip settings no public API exposes (revocation, PAT scope). */ export const LOCAL_D1 = process.env.GITFOR_E2E_D1 ?? ""; const SQLITE = process.env.SQLITE ?? "sqlite3"; export const HTTP = { ok: 200, created: 201, noContent: 204, badRequest: 400, unauthorized: 401, payment: 402, forbidden: 403, notFound: 404, conflict: 409, tooLarge: 413, } as const; let failures = 0; /** Prints a progress line; e2e scripts report on stdout. */ export function log(line: string): void { process.stdout.write(`${line}\n`); } export function check(condition: unknown, message: string): void { if (!condition) failures++; log(`${condition ? "ok" : "not ok"} - ${message}`); } export function finish(): never { log(failures === 0 ? "all checks passed" : `${failures} check(s) failed`); process.exit(failures === 0 ? 0 : 1); } export async function call(path: string, token: string, init: { method?: string; body?: unknown } = {}) { const response = await fetch(`${BASE}${path}`, { method: init.method ?? "GET", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: init.body === undefined ? undefined : JSON.stringify(init.body), signal: AbortSignal.timeout(120_000), }); const text = await response.text(); // oxlint-disable-next-line typescript/no-explicit-any -- e2e assertions read arbitrary JSON shapes let body: any = null; try { body = text ? JSON.parse(text) : null; } catch { body = text; } return { status: response.status, headers: response.headers, body }; } export function sql(statement: string): string { if (!LOCAL_D1) throw new Error("GITFOR_E2E_D1 is required for this step"); return execFileSync(SQLITE, [LOCAL_D1, statement], { encoding: "utf8" }).trim(); } export const quote = (value: string) => `'${value.replace(/'/g, "''")}'`; export async function seedRepo(prefix: string, files: Record) { const me = await call("/api/v1/user", PAT); const name = `${prefix}-${Date.now()}`; const created = await call("/api/v1/repos", PAT, { method: "POST", body: { name, private: true } }); if (created.status !== HTTP.created) throw new Error(`create repo: ${created.status} ${JSON.stringify(created.body)}`); for (const [path, content] of Object.entries(files)) { const put = await call(`/api/v1/repos/${me.body.login}/${name}/contents/${path}`, PAT, { method: "PUT", body: { branch: "main", message: `add ${path}`, content: Buffer.from(content).toString("base64") }, }); if (put.status >= HTTP.badRequest) throw new Error(`seed ${path}: ${put.status} ${JSON.stringify(put.body)}`); } return { owner: me.body.login as string, name, id: created.body.id as string, api: `/api/v1/repos/${me.body.login}/${name}`, }; } export async function mint(repoApi: string, token: string, ref = "main"): Promise { const minted = await call(`${repoApi}/editor-sessions`, token, { method: "POST", body: { ref } }); if (minted.status !== HTTP.created) throw new Error(`mint: ${minted.status} ${JSON.stringify(minted.body)}`); return minted.body.token; } export type Joined = { client: RoomClient; states: string[]; controls: RoomControl[] }; /** A Yjs room client exactly like the VS Code extension's, for agents and the API e2e. */ export function join(token: string, refresh: () => Promise, agent?: string): Joined { const states: string[] = []; const controls: RoomControl[] = []; const url = new URL(`${BASE.replace(/^http/, "ws")}/api/editor/room`); if (agent) url.searchParams.set("agent", agent); const client = new RoomClient(url.toString(), token, { onControl: (message) => controls.push(message), onState: (state) => states.push(state), refreshToken: refresh, }); return { client, states, controls }; } export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export async function until(label: string, probe: () => T | Promise, timeoutMs = 20_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const value = await probe(); if (value) return value; await sleep(200); } log(` (timed out waiting for ${label})`); return null; }