@mercury-fw/cli 0.29.4 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,16 @@
5
5
  * and validated before any of this runs (`program.ts`); what runs a command is
6
6
  * injected (`AppDeps`), which is how the tests see the exact calls.
7
7
  */
8
- import { readFileSync } from "node:fs";
8
+ import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
- import { join, resolve } from "node:path";
10
+ import { join, relative, resolve } from "node:path";
11
11
  import { CATALOG, type CliCredentials } from "../catalog.ts";
12
12
  import { packCredentials, readServiceAccountKey, setEnvVar } from "./credentials.ts";
13
13
  import type { App } from "./find-app.ts";
14
+ import { LOCAL_PACKS_DIR, packageNameOf, withLocalOverrides, withoutLocalOverrides } from "./local-packages.ts";
15
+ import { findTests, loadTest } from "../e2e/load.ts";
16
+ import { runE2e } from "../e2e/runner.ts";
17
+ import { openReplSession } from "../e2e/session.ts";
14
18
 
15
19
  export type AppDeps = {
16
20
  /** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
@@ -48,6 +52,16 @@ export const RESET_TARGETS = {
48
52
  } as const;
49
53
  export type ResetTarget = keyof typeof RESET_TARGETS;
50
54
 
55
+ /** How long one e2e turn may take: a local model on a long, tool-heavy turn is slow. */
56
+ const E2E_TURN_TIMEOUT_MS = 10 * 60_000;
57
+
58
+ /** Runs `argv` in `cwd`, returning its exit code and its output (stdout and stderr together). */
59
+ async function captureCode(argv: string[], cwd: string): Promise<{ code: number; output: string }> {
60
+ const proc = Bun.spawn(argv, { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
61
+ const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
62
+ return { code, output: stdout + stderr };
63
+ }
64
+
51
65
  /** The compose calls that build and start the app; `recreate` restarts containers even when nothing changed. */
52
66
  function startCalls(noCache: boolean, recreate: boolean): string[][] {
53
67
  const up = [...COMPOSE, "up", "-d", ...(noCache ? [] : ["--build"]), ...(recreate ? ["--force-recreate"] : [])];
@@ -174,8 +188,77 @@ export function appCommands(app: App, deps: AppDeps) {
174
188
  deps.print(`Delete ${source} now, the env file holds the key. mfw start applies it to a running app.`);
175
189
  return 0;
176
190
  },
191
+ /** Makes the app install the packages in `from`'s tarballs (`bun pm
192
+ * pack`) instead of the registry's: copied into `.packs/` (which the
193
+ * image copies too), overridden in the manifest, then installed.
194
+ * Everything is checked before anything is written. */
195
+ localPackages: async (from: string) => {
196
+ const source = resolve(from);
197
+ const target = join(app.dir, LOCAL_PACKS_DIR);
198
+ if (source === target) throw new Error(`${source} is the app's own .packs/: give the folder the tarballs were packed into.`);
199
+ if (!existsSync(source)) throw new Error(`${source} doesn't exist.`);
200
+ const files = readdirSync(source).filter((f) => f.endsWith(".tgz")).sort();
201
+ if (files.length === 0) throw new Error(`No .tgz in ${source}: pack the packages there first (bun pm pack).`);
202
+ const packs = await Promise.all(files.map(async (file) => ({ name: await packageNameOf(join(source, file)), file })));
203
+ const manifest = withLocalOverrides(readManifest(), packs);
204
+ rmSync(target, { recursive: true, force: true });
205
+ mkdirSync(target);
206
+ for (const { file } of packs) cpSync(join(source, file), join(target, file));
207
+ writeManifest(manifest);
208
+ deps.print(`${packs.length} local packages in ${target}: ${packs.map((p) => p.name).join(", ")}.`);
209
+ return deps.run(["bun", "install"], { cwd: app.dir });
210
+ },
211
+ /** Runs e2e tests (`tests`, or the app's `e2e/*.e2e.ts`) against the app's
212
+ * REPL in its container, keeping the turns and checks in
213
+ * `e2e/results/<time>/`; returns 1 when a case didn't pass. */
214
+ e2e: async (tests: string[], { repeat }: { repeat?: number }) => {
215
+ const files = findTests(tests, { appDir: app.dir, cwd: process.cwd() });
216
+ const loaded = await Promise.all(files.map(async (file) => ({ file: relative(process.cwd(), file) || file, test: await loadTest(file) })));
217
+ const results = join(app.dir, "e2e", "results", new Date().toISOString().replace(/[:.]/g, "-"));
218
+ mkdirSync(results, { recursive: true });
219
+ // The container's user writes the dumps here; on a Linux host it isn't
220
+ // the folder's owner.
221
+ chmodSync(results, 0o777);
222
+ let sessions = 0;
223
+ return runE2e(loaded, repeat === undefined ? {} : { repeat }, {
224
+ openSession: async () =>
225
+ openReplSession({
226
+ argv: [...COMPOSE, "run", "--rm", "-T", "-v", `${results}:/e2e`, SERVICE, "bun", "run", "repl"],
227
+ cwd: app.dir,
228
+ hostDir: results,
229
+ replDir: "/e2e",
230
+ name: `run-${++sessions}`,
231
+ timeoutMs: E2E_TURN_TIMEOUT_MS,
232
+ }),
233
+ cli: (command) => captureCode([...COMPOSE, "run", "--rm", "-T", "--no-deps", SERVICE, "sh", "-c", command], app.dir),
234
+ appPackages: (readManifest() as { dependencies?: Record<string, string> }).dependencies ?? {},
235
+ print: deps.print,
236
+ now: Date.now,
237
+ writeReport: async (report) => {
238
+ writeFileSync(join(results, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
239
+ deps.print(`Turns and checks in ${results}`);
240
+ },
241
+ });
242
+ },
243
+ /** Undoes `localPackages`: the app installs from the registry again. */
244
+ localPackagesOff: async () => {
245
+ writeManifest(withoutLocalOverrides(readManifest()));
246
+ rmSync(join(app.dir, LOCAL_PACKS_DIR), { recursive: true, force: true });
247
+ deps.print("Local packages removed: installing from the registry.");
248
+ return deps.run(["bun", "install"], { cwd: app.dir });
249
+ },
177
250
  };
178
251
 
252
+ /** The app's manifest, as an object. */
253
+ function readManifest(): Record<string, unknown> {
254
+ return JSON.parse(readFileSync(join(app.dir, "package.json"), "utf-8")) as Record<string, unknown>;
255
+ }
256
+
257
+ /** Writes the app's manifest, two-space indented like the one `mfw create` writes. */
258
+ function writeManifest(manifest: Record<string, unknown>): void {
259
+ writeFileSync(join(app.dir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
260
+ }
261
+
179
262
  /** Stops `service`, runs `steps`, starts it again; stops at the first
180
263
  * failure and, once the service is stopped, says it's down and how to bring
181
264
  * it back. Returns the exit code. */
@@ -0,0 +1,51 @@
1
+ /**
2
+ * What `mfw local-packages` writes: the app's `package.json` with `overrides`
3
+ * pointing packages at local tarballs (`bun pm pack`) copied into `.packs/`,
4
+ * which the image copies before `bun install`. Overrides, and not the
5
+ * dependencies themselves, because a packed package names its siblings by
6
+ * version, which the registry has too: only an override sends those
7
+ * transitive dependencies to the tarballs as well.
8
+ */
9
+
10
+ /** The tarballs' folder inside the app, as the Dockerfile copies it. */
11
+ export const LOCAL_PACKS_DIR = ".packs";
12
+
13
+ /** The prefix of every override this command writes, how it tells them from the user's own. */
14
+ const LOCAL_OVERRIDE = `file:./${LOCAL_PACKS_DIR}/`;
15
+
16
+ type Manifest = Record<string, unknown> & { overrides?: Record<string, string> };
17
+
18
+ /** The user's own overrides in `pkg`: every one this command didn't write. */
19
+ function ownOverrides(pkg: Manifest): Record<string, string> {
20
+ return Object.fromEntries(Object.entries(pkg.overrides ?? {}).filter(([, spec]) => !spec.startsWith(LOCAL_OVERRIDE)));
21
+ }
22
+
23
+ /** `pkg` with each of `packs` (a package name and its tarball's file name in
24
+ * `.packs/`) as an override, in place of any left by an earlier run. */
25
+ export function withLocalOverrides(pkg: Manifest, packs: Array<{ name: string; file: string }>): Manifest {
26
+ const local = Object.fromEntries(packs.map((p) => [p.name, `${LOCAL_OVERRIDE}${p.file}`]));
27
+ return { ...pkg, overrides: { ...ownOverrides(pkg), ...local } };
28
+ }
29
+
30
+ /** `pkg` without the overrides this command writes; without `overrides` at
31
+ * all when none of the user's own is left. */
32
+ export function withoutLocalOverrides(pkg: Manifest): Manifest {
33
+ const { overrides: _, ...rest } = pkg;
34
+ const own = ownOverrides(pkg);
35
+ return Object.keys(own).length > 0 ? { ...rest, overrides: own } : rest;
36
+ }
37
+
38
+ /** The package name in a `bun pm pack` tarball, read from its
39
+ * `package/package.json`. */
40
+ export async function packageNameOf(tarball: string): Promise<string> {
41
+ const proc = Bun.spawn(["tar", "-xOzf", tarball, "package/package.json"], { stdout: "pipe", stderr: "pipe" });
42
+ const [manifest, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
43
+ try {
44
+ if (code !== 0) throw new Error(`tar exited with ${code}`);
45
+ const name = (JSON.parse(manifest) as { name?: unknown }).name;
46
+ if (typeof name !== "string") throw new Error("no name in its package.json");
47
+ return name;
48
+ } catch (err) {
49
+ throw new Error(`${tarball} isn't a package tarball: ${err instanceof Error ? err.message : String(err)}`);
50
+ }
51
+ }
package/src/args.ts CHANGED
@@ -15,7 +15,13 @@ export type CreateArgs = {
15
15
  role?: string;
16
16
  channels?: string[];
17
17
  plugins?: string[];
18
+ /** The repository's origin, taken as typed. */
19
+ gitRemote?: string;
18
20
  yes: boolean;
21
+ /** Run `bun install` after writing (`--no-install` turns it off). */
22
+ install: boolean;
23
+ /** Create the repository with a first commit (`--no-git` turns it off). */
24
+ git: boolean;
19
25
  };
20
26
 
21
27
  /** `create`'s options as commander hands them over. */
@@ -25,7 +31,10 @@ export type CreateOptions = {
25
31
  role?: string;
26
32
  channels?: string;
27
33
  plugins?: string;
34
+ gitRemote?: string;
28
35
  yes?: boolean;
36
+ install?: boolean;
37
+ git?: boolean;
29
38
  };
30
39
 
31
40
  /** A comma-separated list, trimmed, empty items dropped. */
@@ -37,11 +46,13 @@ const list = (value: string): string[] =>
37
46
 
38
47
  /** The answers in `folder` and `opts`, with only what was actually given. */
39
48
  export function toCreateArgs(folder: string, opts: CreateOptions): CreateArgs {
40
- const args: CreateArgs = { dir: folder, yes: opts.yes ?? false };
49
+ const args: CreateArgs = { dir: folder, yes: opts.yes ?? false, install: opts.install ?? true, git: opts.git ?? true };
41
50
  if (opts.name !== undefined) args.name = opts.name;
42
51
  if (opts.assistantName !== undefined) args.assistantName = opts.assistantName;
43
52
  if (opts.role !== undefined) args.role = opts.role;
44
53
  if (opts.channels !== undefined) args.channels = list(opts.channels);
45
54
  if (opts.plugins !== undefined) args.plugins = list(opts.plugins);
55
+ // Blank is none, as in the wizard.
56
+ if (opts.gitRemote !== undefined && opts.gitRemote.trim() !== "") args.gitRemote = opts.gitRemote.trim();
46
57
  return args;
47
58
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The shape of an e2e test, as a test file imports it
3
+ * (`import { e2e } from "@mercury-fw/cli/e2e"`): the plugins and channels the
4
+ * app must have, and cases of turns sent to the app's real model through its
5
+ * REPL, each with checks on the calls it made and the answer it gave.
6
+ * `mfw e2e` runs them (`runner.ts`).
7
+ */
8
+ import type { Call, TurnData } from "./dump.ts";
9
+
10
+ export type { Call } from "./dump.ts";
11
+
12
+ /** One turn as a check sees it: its calls, its answer, how long it took. */
13
+ export type Turn = TurnData & { seconds: number };
14
+
15
+ /** A case's run: every turn in order, and the last one. */
16
+ export type Run = { turns: Turn[]; last: Turn };
17
+
18
+ /** Runs `command` in the app's container (`sh -c`), outside the model:
19
+ * preparing data, reading what a turn changed, cleaning up. */
20
+ export type Cli = (command: string) => Promise<{ code: number; output: string }>;
21
+
22
+ /** What `before`, `after` and `check` can reach besides the run. */
23
+ export type Context = { cli: Cli };
24
+
25
+ /** The checks a case makes. Call helpers look at every turn's calls, answer
26
+ * helpers at the last answer; each records a named check and never throws,
27
+ * so a case reports every failure at once. */
28
+ export type Expect = {
29
+ /** At least one call to `tool`, matching `match` when given. */
30
+ call(tool: string, match?: (call: Call) => boolean, label?: string): void;
31
+ /** Every call to `tool` matches `match` (and there is at least one). */
32
+ everyCall(tool: string, match: (call: Call) => boolean, label?: string): void;
33
+ /** No call failed or went without a result. */
34
+ noFailedCalls(label?: string): void;
35
+ /** The number of calls, to every tool or to `tool`, within the bounds. */
36
+ callCount(bounds: { min?: number; max?: number }, tool?: string, label?: string): void;
37
+ /** The answer contains `pattern`, or matches it. */
38
+ answer(pattern: string | RegExp, label?: string): void;
39
+ /** The answer doesn't contain `pattern`, or doesn't match it. */
40
+ answerNot(pattern: string | RegExp, label?: string): void;
41
+ /** Anything else. */
42
+ that(label: string, condition: boolean): void;
43
+ };
44
+
45
+ /** One case: the turns sent, in one REPL session, and the checks on them. */
46
+ export type E2eCase = {
47
+ name: string;
48
+ /** A message, or a function of the turn before (a follow-up, a confirmation token). */
49
+ turns: Array<string | ((previous: Turn) => string)>;
50
+ /** How many times to run it (the model isn't deterministic); default 1. */
51
+ repeat?: number;
52
+ /** How many runs must pass; default every one. */
53
+ minPasses?: number;
54
+ before?: (ctx: Context) => unknown;
55
+ after?: (ctx: Context) => unknown;
56
+ check: (run: Run, expect: Expect, ctx: Context) => unknown;
57
+ };
58
+
59
+ /** An e2e test: what the app must have, and its cases. */
60
+ export type E2eTest = {
61
+ /** Catalog ids of the tool plugins the app must have (`jira`, …). */
62
+ plugins?: string[];
63
+ /** Catalog ids of the channels the app must have (`http`, …). */
64
+ channels?: string[];
65
+ cases: E2eCase[];
66
+ };
67
+
68
+ /** Declares an e2e test: the identity, there for the types. */
69
+ export function e2e(test: E2eTest): E2eTest {
70
+ return test;
71
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A turn read out of the file the REPL's `/dump` writes: the AI SDK's step
3
+ * results for the last turn, whose `content` parts are the tool calls, their
4
+ * results (or errors) and the model's text. Read as data, not through the
5
+ * SDK's types: only the parts listed here matter.
6
+ */
7
+
8
+ /** One tool call as a check sees it. `ok` is false when the call failed or
9
+ * never got a result; a result without an `ok` of its own counts as worked.
10
+ * `pending` is an irreversible command staged for confirmation, which worked
11
+ * (its output has the token) though it reports `ok: false`. */
12
+ export type Call = { tool: string; input: unknown; output: unknown; ok: boolean; pending: boolean };
13
+
14
+ /** What a turn did: its tool calls in order, and its final text. */
15
+ export type TurnData = { calls: Call[]; answer: string };
16
+
17
+ type Part = { type?: unknown; toolCallId?: unknown; toolName?: unknown; input?: unknown; output?: unknown; error?: unknown; text?: unknown };
18
+
19
+ /** The calls and the answer in `dump`, the parsed content of a `/dump` file. */
20
+ export function turnFromDump(dump: unknown): TurnData {
21
+ if (!Array.isArray(dump)) throw new Error("the file isn't a /dump of steps");
22
+ const parts: Part[][] = dump.map((step) =>
23
+ Array.isArray((step as { content?: unknown } | null)?.content) ? ((step as { content: Part[] }).content) : [],
24
+ );
25
+
26
+ const calls: Array<Call & { id: unknown }> = [];
27
+ for (const part of parts.flat()) {
28
+ if (part.type === "tool-call") {
29
+ calls.push({ id: part.toolCallId, tool: String(part.toolName), input: part.input, output: undefined, ok: false, pending: false });
30
+ continue;
31
+ }
32
+ const call = calls.find((c) => c.id === part.toolCallId);
33
+ if (call === undefined) continue;
34
+ if (part.type === "tool-result") {
35
+ call.output = part.output;
36
+ const result = part.output as { ok?: unknown; pendingConfirmation?: unknown } | null;
37
+ call.pending = result?.pendingConfirmation === true;
38
+ call.ok = call.pending || (typeof result?.ok === "boolean" ? result.ok : true);
39
+ } else if (part.type === "tool-error") {
40
+ call.output = { error: part.error };
41
+ call.ok = false;
42
+ }
43
+ }
44
+
45
+ const last = parts.at(-1) ?? [];
46
+ const answer = last.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text as string).join("");
47
+ return { calls: calls.map(({ id: _, ...call }) => call), answer };
48
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The `expect` a case's `check` receives: helpers that record named checks
3
+ * against a run, passed or failed with a detail, and never throw.
4
+ */
5
+ import type { Call } from "./dump.ts";
6
+ import type { Expect, Run } from "./define.ts";
7
+
8
+ /** One check's outcome; `detail` says what was found when it failed. */
9
+ export type Check = { label: string; ok: boolean; detail?: string };
10
+
11
+ /** A call, one line, for a failure's detail. */
12
+ const describe = (c: Call) => `${c.tool} ${JSON.stringify(c.input)}`;
13
+
14
+ /** `pattern`, as a label says it. */
15
+ const said = (pattern: string | RegExp) => (typeof pattern === "string" ? `contains "${pattern}"` : `matches ${pattern}`);
16
+ const saidNot = (pattern: string | RegExp) => (typeof pattern === "string" ? `doesn't contain "${pattern}"` : `doesn't match ${pattern}`);
17
+ const found = (text: string, pattern: string | RegExp) => (typeof pattern === "string" ? text.includes(pattern) : pattern.test(text));
18
+
19
+ /** The checks' bounds, as a label says them. */
20
+ function boundsLabel({ min, max }: { min?: number; max?: number }, what: string): string {
21
+ if (min !== undefined && max !== undefined) return `${min} to ${max} ${what}`;
22
+ if (max !== undefined) return `at most ${max} ${what}`;
23
+ return `at least ${min ?? 0} ${what}`;
24
+ }
25
+
26
+ /** An `expect` bound to `run`, and the checks it recorded so far. */
27
+ export function createExpect(run: Run): { expect: Expect; checks: () => Check[] } {
28
+ const checks: Check[] = [];
29
+ const record = (label: string, ok: boolean, detail?: string) =>
30
+ void checks.push(ok || detail === undefined ? { label, ok } : { label, ok, detail });
31
+ const calls = run.turns.flatMap((t) => t.calls);
32
+ const of = (tool: string) => calls.filter((c) => c.tool === tool);
33
+
34
+ const expect: Expect = {
35
+ call: (tool, match, label) => {
36
+ const ok = of(tool).some((c) => match?.(c) ?? true);
37
+ record(label ?? `a ${tool} call`, ok, `calls: ${calls.map((c) => c.tool).join(", ") || "none"}`);
38
+ },
39
+ everyCall: (tool, match, label) => {
40
+ const mine = of(tool);
41
+ const bad = mine.find((c) => !match(c));
42
+ record(label ?? `every ${tool} call matches`, mine.length > 0 && bad === undefined, bad ? describe(bad) : `no ${tool} call`);
43
+ },
44
+ noFailedCalls: (label) => {
45
+ const failed = calls.filter((c) => !c.ok);
46
+ record(label ?? "no failed calls", failed.length === 0, failed.map(describe).join("; "));
47
+ },
48
+ callCount: (bounds, tool, label) => {
49
+ const n = (tool === undefined ? calls : of(tool)).length;
50
+ const ok = n >= (bounds.min ?? 0) && n <= (bounds.max ?? Infinity);
51
+ const what = tool === undefined ? "calls" : `${tool} call${bounds.max === 1 || bounds.min === 1 ? "" : "s"}`;
52
+ record(label ?? boundsLabel(bounds, what), ok, String(n));
53
+ },
54
+ answer: (pattern, label) => record(label ?? `answer ${said(pattern)}`, found(run.last.answer, pattern), run.last.answer),
55
+ answerNot: (pattern, label) => record(label ?? `answer ${saidNot(pattern)}`, !found(run.last.answer, pattern), run.last.answer),
56
+ that: (label, condition) => record(label, condition),
57
+ };
58
+ return { expect, checks: () => [...checks] };
59
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Which e2e test files `mfw e2e` runs, and loading one: Bun imports the
3
+ * TypeScript file as it is, and its default export is checked to look like
4
+ * a test before anything starts.
5
+ */
6
+ import { existsSync, readdirSync } from "node:fs";
7
+ import { join, resolve } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import type { E2eTest } from "./define.ts";
10
+
11
+ /** The files to run: `named` resolved from `cwd`, or, when none is named,
12
+ * the app's `e2e/*.e2e.ts` in name order. */
13
+ export function findTests(named: string[], { appDir, cwd }: { appDir: string; cwd: string }): string[] {
14
+ if (named.length > 0) return named.map((f) => resolve(cwd, f));
15
+ const folder = join(appDir, "e2e");
16
+ const found = existsSync(folder) ? readdirSync(folder).filter((f) => f.endsWith(".e2e.ts")).sort() : [];
17
+ if (found.length === 0) throw new Error(`No tests: none named, and no *.e2e.ts in ${folder}`);
18
+ return found.map((f) => join(folder, f));
19
+ }
20
+
21
+ /** The test `file` exports as default; throws naming the file when it isn't one. */
22
+ export async function loadTest(file: string): Promise<E2eTest> {
23
+ const loaded = ((await import(pathToFileURL(file).href)) as { default?: unknown }).default as Partial<E2eTest> | undefined;
24
+ if (loaded === undefined || loaded === null || !Array.isArray(loaded.cases)) {
25
+ throw new Error(`${file} doesn't export a test as default (export default e2e({ … }))`);
26
+ }
27
+ for (const [i, c] of loaded.cases.entries()) {
28
+ const which = `${file}: case ${i + 1} ("${c?.name ?? "?"}")`;
29
+ if (typeof c?.name !== "string") throw new Error(`${which} has no name`);
30
+ if (!Array.isArray(c.turns) || c.turns.length === 0) throw new Error(`${which} has no turns`);
31
+ if (typeof c.check !== "function") throw new Error(`${which} has no check`);
32
+ const whole = (n: unknown) => typeof n === "number" && Number.isInteger(n) && n >= 1;
33
+ if (c.repeat !== undefined && !whole(c.repeat)) throw new Error(`${which} repeat takes a positive whole number`);
34
+ if (c.minPasses !== undefined && !whole(c.minPasses)) throw new Error(`${which} minPasses takes a positive whole number`);
35
+ if (c.minPasses !== undefined && c.minPasses > (c.repeat ?? 1)) {
36
+ throw new Error(`${which} minPasses (${c.minPasses}) is more than repeat (${c.repeat ?? 1})`);
37
+ }
38
+ }
39
+ return loaded as E2eTest;
40
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Runs e2e tests against an app: for each case (as many times as it
3
+ * repeats), a fresh REPL session gets the case's turns, the checks run on
4
+ * what each turn did, and every check is printed; the exit code says whether
5
+ * every case passed enough runs. The session, the container commands and the
6
+ * report's destination are injected (`session.ts` and `commands.ts` hold the
7
+ * real ones), so the tests drive the runner with scripted turns.
8
+ */
9
+ import { CATALOG } from "../catalog.ts";
10
+ import type { Context, E2eCase, E2eTest, Run, Turn } from "./define.ts";
11
+ import { turnFromDump } from "./dump.ts";
12
+ import { createExpect, type Check } from "./expect.ts";
13
+
14
+ /** A REPL session in the app: `turn` sends one line and resolves with what
15
+ * `/dump` wrote for it and what the REPL printed meanwhile. */
16
+ export type Session = {
17
+ turn: (line: string) => Promise<{ dump: unknown; output: string }>;
18
+ close: () => Promise<void>;
19
+ };
20
+
21
+ export type RunnerDeps = {
22
+ openSession: () => Promise<Session>;
23
+ cli: Context["cli"];
24
+ /** The app's dependencies, to check the test's plugins and channels against. */
25
+ appPackages: Record<string, string>;
26
+ print: (line: string) => void;
27
+ /** Milliseconds, for each turn's duration. */
28
+ now: () => number;
29
+ writeReport: (report: CaseReport[]) => Promise<void>;
30
+ };
31
+
32
+ /** One run of a case, as the report keeps it. */
33
+ export type RunReport = { ok: boolean; turns: Turn[]; checks: Check[] };
34
+ export type CaseReport = { file: string; case: string; passed: boolean; runs: RunReport[] };
35
+
36
+ /** The prompt the REPL prints after a reply, with its context-usage suffix. */
37
+ const PROMPT = /(\[[^\]\n]*\] )?> $/;
38
+
39
+ /** A turn's printed output as an answer: no styling, no trailing prompt. */
40
+ function answerFromOutput(output: string): string {
41
+ return output.replace(/\u001b\[[0-9;]*m/g, "").replace(PROMPT, "").trim();
42
+ }
43
+
44
+ /** The test's plugins and channels the app doesn't depend on, as a message, or undefined. */
45
+ function missing(test: E2eTest, packages: Record<string, string>): string | undefined {
46
+ const wanted = [
47
+ ...(test.plugins ?? []).map((id) => ({ id, kind: "tool" as const, word: "plugin" })),
48
+ ...(test.channels ?? []).map((id) => ({ id, kind: "channel" as const, word: "channel" })),
49
+ ];
50
+ const lacking = wanted.flatMap(({ id, kind, word }) => {
51
+ const pkg = CATALOG.find((e) => e.kind === kind && e.id === id)?.package;
52
+ if (pkg === undefined) return [`${word} ${id} (not in the catalog)`];
53
+ return packages[pkg] === undefined ? [`${word} ${id} (${pkg})`] : [];
54
+ });
55
+ return lacking.length > 0 ? `the app lacks what the test needs: ${lacking.join(", ")}` : undefined;
56
+ }
57
+
58
+ /** Runs the tests in `tests` (each with the file it came from); returns the
59
+ * exit code: 0 when every case passed enough runs. `repeat` overrides each
60
+ * case's own. */
61
+ export async function runE2e(
62
+ tests: Array<{ file: string; test: E2eTest }>,
63
+ opts: { repeat?: number },
64
+ deps: RunnerDeps,
65
+ ): Promise<number> {
66
+ const report: CaseReport[] = [];
67
+ let code = 0;
68
+ for (const { file, test } of tests) {
69
+ deps.print(file);
70
+ const lacking = missing(test, deps.appPackages);
71
+ if (lacking !== undefined) {
72
+ deps.print(` ${lacking}`);
73
+ code = 1;
74
+ continue;
75
+ }
76
+ for (const c of test.cases) {
77
+ const result = await runCase(c, opts.repeat, deps);
78
+ report.push({ file, case: c.name, ...result });
79
+ if (!result.passed) code = 1;
80
+ }
81
+ }
82
+ await deps.writeReport(report);
83
+ return code;
84
+ }
85
+
86
+ /** Runs one case as many times as it repeats, printing each run's checks and the verdict. */
87
+ async function runCase(c: E2eCase, repeatOverride: number | undefined, deps: RunnerDeps): Promise<{ passed: boolean; runs: RunReport[] }> {
88
+ const repeat = repeatOverride ?? c.repeat ?? 1;
89
+ const minPasses = Math.min(c.minPasses ?? repeat, repeat);
90
+ deps.print(` ${c.name}`);
91
+ const runs: RunReport[] = [];
92
+ for (let i = 1; i <= repeat; i++) {
93
+ if (repeat > 1) deps.print(` run ${i}/${repeat}`);
94
+ const run = await runOnce(c, deps);
95
+ const indent = repeat > 1 ? " " : " ";
96
+ for (const check of run.checks) {
97
+ deps.print(`${indent}${check.ok ? "✓" : "✗"} ${check.label}${check.ok || check.detail === undefined ? "" : `: ${check.detail}`}`);
98
+ }
99
+ runs.push(run);
100
+ }
101
+ const passes = runs.filter((r) => r.ok).length;
102
+ const passed = passes >= minPasses;
103
+ deps.print(
104
+ repeat > 1
105
+ ? ` ${c.name}: ${passes}/${repeat} runs passed (needs ${minPasses})${passed ? "" : " FAILED"}`
106
+ : ` ${c.name}: ${passed ? "passed" : "FAILED"}`,
107
+ );
108
+ return { passed, runs };
109
+ }
110
+
111
+ /** One run of a case in a fresh session: `before`, the turns, the checks, `after`. */
112
+ async function runOnce(c: E2eCase, deps: RunnerDeps): Promise<RunReport> {
113
+ const ctx: Context = { cli: deps.cli };
114
+ const turns: Turn[] = [];
115
+ let checks: Check[] = [];
116
+ try {
117
+ await c.before?.(ctx);
118
+ const session = await deps.openSession();
119
+ try {
120
+ for (const next of c.turns) {
121
+ const line = typeof next === "string" ? next : next(turns.at(-1)!);
122
+ if (line.includes("\n")) throw new Error("a turn must be one line (the REPL reads one line per turn)");
123
+ const start = deps.now();
124
+ const { dump, output } = await session.turn(line);
125
+ const seconds = (deps.now() - start) / 1000;
126
+ const data = turnFromDump(dump);
127
+ const answer = data.calls.length === 0 && data.answer === "" ? answerFromOutput(output) : data.answer;
128
+ turns.push({ calls: data.calls, answer, seconds });
129
+ }
130
+ } finally {
131
+ await session.close();
132
+ }
133
+ const run: Run = { turns, last: turns.at(-1)! };
134
+ const recorder = createExpect(run);
135
+ try {
136
+ await c.check(run, recorder.expect, ctx);
137
+ checks = recorder.checks();
138
+ // A case that checks nothing proves nothing.
139
+ if (checks.length === 0) checks = [{ label: "the case made no checks", ok: false }];
140
+ } catch (err) {
141
+ checks = [...recorder.checks(), { label: `check threw: ${err instanceof Error ? err.message : String(err)}`, ok: false }];
142
+ }
143
+ } catch (err) {
144
+ checks = [{ label: `run failed: ${err instanceof Error ? err.message : String(err)}`, ok: false }];
145
+ } finally {
146
+ try {
147
+ await c.after?.(ctx);
148
+ } catch (err) {
149
+ checks = [...checks, { label: `after threw: ${err instanceof Error ? err.message : String(err)}`, ok: false }];
150
+ }
151
+ }
152
+ return { ok: checks.every((ch) => ch.ok), turns, checks };
153
+ }