@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.22

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.
Files changed (39) hide show
  1. package/package.json +8 -5
  2. package/src/artifacts.ts +165 -26
  3. package/src/build.ts +236 -43
  4. package/src/bundle/linux/app.ts +275 -0
  5. package/src/bundle/linux/index.ts +1 -0
  6. package/src/{bundle.ts → bundle/macos/app.ts} +65 -23
  7. package/src/bundle/macos/index.ts +1 -0
  8. package/src/bundle/shared/cef-locales.ts +91 -0
  9. package/src/{bundle-win.ts → bundle/windows/app.ts} +10 -6
  10. package/src/bundle/windows/index.ts +1 -0
  11. package/src/check.ts +271 -0
  12. package/src/dev.ts +214 -35
  13. package/src/dmg.ts +22 -10
  14. package/src/doctor.ts +227 -0
  15. package/src/extras.ts +52 -13
  16. package/src/icons/macos/appearance.ts +96 -0
  17. package/src/icons/macos/index.ts +1 -0
  18. package/src/icons/sources.ts +25 -0
  19. package/src/icons/windows/icon.ts +255 -0
  20. package/src/icons/windows/index.ts +1 -0
  21. package/src/index.ts +109 -7
  22. package/src/installer-inno.ts +141 -0
  23. package/src/installer-win.ts +17 -11
  24. package/src/package/linux/index.ts +2 -0
  25. package/src/package/linux/lib/defaults.ts +53 -0
  26. package/src/package/linux/lib/tools.ts +45 -0
  27. package/src/package/linux/lib/validation.ts +71 -0
  28. package/src/package/linux/package.ts +293 -0
  29. package/src/package/linux/types.ts +30 -0
  30. package/src/release/installers.ts +148 -0
  31. package/src/release/previous.ts +105 -0
  32. package/src/release.ts +166 -116
  33. package/src/shared/driver.ts +240 -0
  34. package/src/shared/inspector.ts +64 -0
  35. package/src/shared/report.ts +69 -0
  36. package/src/shared/scenario.ts +58 -0
  37. package/src/shared/session.ts +153 -0
  38. package/src/skill.ts +51 -0
  39. package/src/icon-win.ts +0 -81
package/src/release.ts CHANGED
@@ -16,17 +16,62 @@
16
16
  * any static host. Channels coexist because the channel is part of every name.
17
17
  */
18
18
 
19
- import { $ } from "bun";
20
- import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
21
- import { join } from "node:path";
19
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync } from "node:fs";
22
20
  import { tmpdir } from "node:os";
21
+ import { join } from "node:path";
22
+ import { $ } from "bun";
23
23
  import { build } from "./build.ts";
24
- import { buildDmg, notarizeAndStaple, type DmgOptions } from "./dmg.ts";
25
- import { buildNsisInstaller, hasMakensis } from "./installer-win.ts";
26
- import { loadCodec } from "mirinjs/codec";
24
+ import { notarizeAndStaple } from "./dmg.ts";
25
+ import { buildReleaseInstaller } from "./release/installers.ts";
26
+ import {
27
+ assertTrustedReleaseUrl,
28
+ parsePreviousReleaseManifest,
29
+ releaseArtifactUrl,
30
+ trustedReleaseBaseUrl,
31
+ } from "./release/previous.ts";
32
+
33
+ // Level 10 keeps updater bundles compact without level 19's steep CPU cost.
34
+ // The native codec uses multiple workers, so this scales across CI runner cores.
35
+ const RELEASE_COMPRESSION_LEVEL = 10;
36
+
37
+ interface ReleaseCodec {
38
+ compress(src: string, dst: string, level: number): void;
39
+ decompress(src: string, dst: string): void;
40
+ diff(oldPath: string, newPath: string, patchPath: string): void;
41
+ }
42
+
43
+ export function createReleaseCodec(binary: string): ReleaseCodec {
44
+ const run = (operation: string, ...args: string[]) => {
45
+ const result = Bun.spawnSync({
46
+ cmd: [binary, operation, ...args],
47
+ stdout: "inherit",
48
+ stderr: "pipe",
49
+ });
50
+ if (result.exitCode !== 0) {
51
+ const detail = new TextDecoder().decode(result.stderr).trim();
52
+ throw new Error(`codec ${operation} failed${detail ? `: ${detail}` : ""}`);
53
+ }
54
+ };
55
+ return {
56
+ compress(src, dst, level) {
57
+ run("compress", src, dst, String(level));
58
+ },
59
+ decompress(src, dst) {
60
+ run("decompress", src, dst);
61
+ },
62
+ diff(oldPath, newPath, patchPath) {
63
+ run("diff", oldPath, newPath, patchPath);
64
+ },
65
+ };
66
+ }
27
67
 
28
- const sha256File = (path: string) =>
29
- new Bun.CryptoHasher("sha256").update(readFileSync(path)).digest("hex");
68
+ async function sha256File(path: string): Promise<string> {
69
+ const hasher = new Bun.CryptoHasher("sha256");
70
+ for await (const chunk of Bun.file(path).stream()) {
71
+ hasher.update(chunk);
72
+ }
73
+ return hasher.digest("hex");
74
+ }
30
75
 
31
76
  export async function release(projectDir = process.cwd()): Promise<number> {
32
77
  const result = await build(projectDir);
@@ -36,13 +81,14 @@ export async function release(projectDir = process.cwd()): Promise<number> {
36
81
  }
37
82
 
38
83
  const isWindows = process.platform === "win32";
39
- const platform = isWindows ? "win32" : "darwin";
84
+ const isLinux = process.platform === "linux";
85
+ const platform = isWindows ? "win32" : isLinux ? "linux" : "darwin";
40
86
  const arch = process.arch; // "arm64" | "x64"
41
87
  const prefix = `${result.channel}-${platform}-${arch}`;
42
88
  const safeName = result.appName.replace(/[^A-Za-z0-9._-]/g, "");
43
- const base = result.baseUrl.replace(/\/$/, "");
44
- // The packaged unit: a flat app folder on Windows, an `.app` bundle on macOS.
45
- const appArtifact = isWindows ? result.appName : `${result.appName}.app`;
89
+ const base = trustedReleaseBaseUrl(result.baseUrl);
90
+ // The packaged unit: a flat app folder on Windows/Linux, an `.app` bundle on macOS.
91
+ const appArtifact = isWindows || isLinux ? result.appName : `${result.appName}.app`;
46
92
 
47
93
  const buildDir = join(projectDir, "build");
48
94
  const outDir = join(buildDir, "release");
@@ -52,58 +98,81 @@ export async function release(projectDir = process.cwd()): Promise<number> {
52
98
  // Notarize + staple the .app (Developer ID, macOS) before packing, when
53
99
  // credentials are present. The .tar.zst / patch updater bundles are made from
54
100
  // this signed, stapled .app, so the updater swaps in an already-notarized app.
55
- if (!isWindows) await notarizeAndStaple(result.app);
101
+ if (!isWindows && !isLinux) await notarizeAndStaple(result.app);
56
102
 
57
- // Load the codec (zstd/bsdiff) from the *bundled* core, not the raw build output:
58
- // on Windows mirin_core.dll imports libcef.dll. The DLL loader resolves imports
59
- // from PATH (and the loader exe's dir), not the dll's own dir, so prepend the
60
- // bundle dir to PATH so libcef.dll is found. (On macOS the framework loads at
61
- // runtime, so result.coreDylib works directly.)
62
- const codecCore = isWindows ? join(result.app, "mirin_core.dll") : result.coreDylib;
63
- if (isWindows) process.env.PATH = `${result.app};${process.env.PATH ?? ""}`;
64
- const codec = loadCodec(codecCore);
103
+ // Installer/package tooling runs in child processes and reads the assembled app.
104
+ // Start it now so it overlaps updater compression and delta generation below.
105
+ const installerBuild = buildReleaseInstaller({
106
+ result,
107
+ buildDir,
108
+ outDir,
109
+ appArtifact,
110
+ prefix,
111
+ safeName,
112
+ isWindows,
113
+ isLinux,
114
+ });
115
+
116
+ // Release tooling runs in plain Bun, including Windows arm64 builds where
117
+ // bun:ffi is unavailable. The standalone helper also avoids loading CEF merely
118
+ // to compress updater artifacts.
119
+ const codec = createReleaseCodec(result.codecBin);
65
120
 
66
121
  // Uncompressed tar — the identity + diff/patch basis (BSD tar keeps symlinks).
67
122
  const newTar = join(outDir, "_new.tar");
68
123
  await $`tar -cf ${newTar} -C ${buildDir} ${appArtifact}`;
69
- const tarHash = sha256File(newTar);
124
+ const tarHash = await sha256File(newTar);
70
125
 
71
126
  // Full bundle: zstd(newTar).
72
127
  const bundleName = `${prefix}-${safeName}${isWindows ? "" : ".app"}.tar.zst`;
73
128
  const bundlePath = join(outDir, bundleName);
74
129
  console.log(`[mirin release] compressing → ${bundleName}`);
75
- codec.compress(newTar, bundlePath, 19);
76
- const bundleSha = sha256File(bundlePath);
77
- const bundleSize = readFileSync(bundlePath).byteLength;
130
+ codec.compress(newTar, bundlePath, RELEASE_COMPRESSION_LEVEL);
131
+ const bundleSha = await sha256File(bundlePath);
132
+ const bundleSize = statSync(bundlePath).size;
78
133
 
79
134
  // Delta patch vs the previous release (if reachable). Best-effort.
80
135
  const patches: Array<{ fromVersion: string; url: string; sha256: string; size: number }> = [];
81
136
  try {
82
- const prevRes = await fetch(`${base}/${prefix}-update.json?t=${Date.now()}`, { redirect: "follow" });
137
+ const manifestUrl = new URL(releaseArtifactUrl(base, `${prefix}-update.json`));
138
+ manifestUrl.searchParams.set("t", String(Date.now()));
139
+ const prevRes = await fetch(manifestUrl, {
140
+ redirect: "follow",
141
+ });
142
+ assertTrustedReleaseUrl(prevRes.url);
83
143
  if (prevRes.ok) {
84
- const prev = (await prevRes.json()) as { version: string; bundle?: { url: string } };
85
- if (prev.version && prev.version !== result.version && prev.bundle?.url) {
144
+ const prev = parsePreviousReleaseManifest(await prevRes.json(), {
145
+ channel: result.channel,
146
+ platform,
147
+ arch,
148
+ });
149
+ if (prev.version !== result.version) {
86
150
  console.log(`[mirin release] generating delta ${prev.version} → ${result.version}…`);
87
- const tmp = join(tmpdir(), `mirin-release-${Date.now()}`);
88
- mkdirSync(tmp, { recursive: true });
89
- const prevZst = join(tmp, "prev.tar.zst");
90
- const dl = await fetch(`${base}/${prev.bundle.url}`, { redirect: "follow" });
91
- if (!dl.ok || !dl.body) throw new Error(`prev bundle ${dl.status}`);
92
- await Bun.write(prevZst, await dl.arrayBuffer());
93
- const prevTar = join(tmp, "prev.tar");
94
- codec.decompress(prevZst, prevTar);
95
- const rawPatch = join(tmp, "patch.bin");
96
- codec.diff(prevTar, newTar, rawPatch); // bsdiff
97
- const patchName = `${prefix}-${prev.version}.patch`;
98
- const patchPath = join(outDir, patchName);
99
- codec.compress(rawPatch, patchPath, 19);
100
- rmSync(tmp, { recursive: true, force: true });
101
- patches.push({
102
- fromVersion: prev.version,
103
- url: patchName,
104
- sha256: sha256File(patchPath),
105
- size: readFileSync(patchPath).byteLength,
106
- });
151
+ const tmp = mkdtempSync(join(tmpdir(), "mirin-release-"));
152
+ try {
153
+ const prevZst = join(tmp, "prev.tar.zst");
154
+ await downloadVerified(
155
+ releaseArtifactUrl(base, prev.bundle.url),
156
+ prevZst,
157
+ prev.bundle.sha256,
158
+ prev.bundle.size,
159
+ );
160
+ const prevTar = join(tmp, "prev.tar");
161
+ codec.decompress(prevZst, prevTar);
162
+ const rawPatch = join(tmp, "patch.bin");
163
+ codec.diff(prevTar, newTar, rawPatch); // bsdiff
164
+ const patchName = `${prefix}-${prev.version}.patch`;
165
+ const patchPath = join(outDir, patchName);
166
+ codec.compress(rawPatch, patchPath, RELEASE_COMPRESSION_LEVEL);
167
+ patches.push({
168
+ fromVersion: prev.version,
169
+ url: patchName,
170
+ sha256: await sha256File(patchPath),
171
+ size: statSync(patchPath).size,
172
+ });
173
+ } finally {
174
+ rmSync(tmp, { recursive: true, force: true });
175
+ }
107
176
  }
108
177
  }
109
178
  } catch (e) {
@@ -117,6 +186,7 @@ export async function release(projectDir = process.cwd()): Promise<number> {
117
186
  channel: result.channel,
118
187
  platform,
119
188
  arch,
189
+ body: result.releaseNotes,
120
190
  tarHash,
121
191
  bundle: { url: bundleName, sha256: bundleSha, size: bundleSize },
122
192
  patches,
@@ -124,76 +194,10 @@ export async function release(projectDir = process.cwd()): Promise<number> {
124
194
  const manifestName = `${prefix}-update.json`;
125
195
  await Bun.write(join(outDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
126
196
 
127
- // Distributable installer for first-time installs (the updater uses the
128
- // .tar.zst/patch): a drag-to-Applications .dmg on macOS, a .zip of the app
129
- // folder on Windows.
130
- let installerName: string | undefined;
131
- let installerSize = 0;
132
- if (isWindows) {
133
- // Preferred: a real NSIS installer (Program Files / per-user install, Start
134
- // Menu + Desktop shortcuts, uninstaller, Add/Remove Programs). Falls back to a
135
- // portable .zip when nsis is disabled or makensis isn't installed.
136
- const wantsInstaller = result.nsis !== false;
137
- if (wantsInstaller && (await hasMakensis())) {
138
- installerName = `${prefix}-${safeName}-setup.exe`;
139
- const exePath = await buildNsisInstaller({
140
- appDir: result.app,
141
- appName: result.appName,
142
- exeName: `${result.appName}.exe`,
143
- version: result.version,
144
- bundleId: result.bundleId,
145
- outDir,
146
- fileName: installerName,
147
- options: typeof result.nsis === "object" ? result.nsis : {},
148
- projectDir: result.projectDir,
149
- });
150
- installerSize = readFileSync(exePath).byteLength;
151
- } else {
152
- if (wantsInstaller) {
153
- console.warn(
154
- "[mirin release] makensis (NSIS) not found — shipping the portable .zip. " +
155
- "Install NSIS (e.g. `scoop install nsis`) for an installer.",
156
- );
157
- }
158
- installerName = `${prefix}-${safeName}.zip`;
159
- console.log(`[mirin release] zipping → ${installerName}`);
160
- const zipPath = join(outDir, installerName);
161
- rmSync(zipPath, { force: true });
162
- // bsdtar (Windows `tar`) creates a .zip from the extension with `-a`.
163
- await $`tar -a -cf ${zipPath} -C ${buildDir} ${appArtifact}`;
164
- installerSize = readFileSync(zipPath).byteLength;
165
- }
166
- } else if (result.dmg !== false) {
167
- // The DMG is the first-install convenience; the updater bundle + manifest (the
168
- // critical artifacts) are already built. Never fail the whole release over a
169
- // flaky installer step — warn loudly and ship the rest.
170
- const dmgName = `${prefix}-${safeName}.dmg`;
171
- console.log(`[mirin release] building installer → ${dmgName}`);
172
- try {
173
- const options: DmgOptions = typeof result.dmg === "object" ? result.dmg : {};
174
- const dmgPath = await buildDmg({
175
- app: result.app,
176
- appName: result.appName,
177
- outDir,
178
- fileName: dmgName,
179
- options,
180
- projectDir: result.projectDir,
181
- signIdentity: result.signIdentity,
182
- });
183
- await notarizeAndStaple(dmgPath); // no-op without notary credentials
184
- installerName = dmgName;
185
- installerSize = readFileSync(dmgPath).byteLength;
186
- } catch (e) {
187
- console.warn(
188
- `\n[mirin release] ⚠️ DMG build failed: ${e instanceof Error ? e.message : e}\n` +
189
- `[mirin release] shipping the updater bundle + manifest WITHOUT a .dmg installer.\n`,
190
- );
191
- rmSync(join(outDir, dmgName), { force: true });
192
- }
193
- }
197
+ const { installerName, installerSize } = await installerBuild;
194
198
 
195
199
  const mb = (n: number) => (n / 1e6).toFixed(1);
196
- console.log(`\n[mirin release] done → build/release/`);
200
+ console.log("\n[mirin release] done → build/release/");
197
201
  console.log(` ${manifestName}`);
198
202
  console.log(` ${bundleName} (${mb(bundleSize)} MB)`);
199
203
  for (const p of patches) console.log(` ${p.url} (${mb(p.size)} MB delta from ${p.fromVersion})`);
@@ -202,3 +206,49 @@ export async function release(projectDir = process.cwd()): Promise<number> {
202
206
  if (existsSync(join(outDir, "_new.tar"))) rmSync(join(outDir, "_new.tar"), { force: true });
203
207
  return 0;
204
208
  }
209
+
210
+ async function downloadVerified(
211
+ url: string,
212
+ destination: string,
213
+ expectedSha256: string,
214
+ expectedSize: number,
215
+ ): Promise<void> {
216
+ const response = await fetch(url, { redirect: "follow" });
217
+ assertTrustedReleaseUrl(response.url);
218
+ if (!response.ok || !response.body) throw new Error(`previous bundle ${response.status}`);
219
+
220
+ const contentLength = Number(response.headers.get("content-length") ?? 0);
221
+ if (contentLength > 0 && contentLength !== expectedSize) {
222
+ throw new Error(
223
+ `previous bundle size mismatch (expected ${expectedSize}, got ${contentLength})`,
224
+ );
225
+ }
226
+
227
+ const reader = response.body.getReader();
228
+ const writer = Bun.file(destination).writer();
229
+ const hasher = new Bun.CryptoHasher("sha256");
230
+ let received = 0;
231
+ try {
232
+ try {
233
+ for (;;) {
234
+ const { done, value } = await reader.read();
235
+ if (done) break;
236
+ received += value.byteLength;
237
+ if (received > expectedSize) throw new Error("previous bundle exceeds declared size");
238
+ hasher.update(value);
239
+ writer.write(value);
240
+ }
241
+ } finally {
242
+ await writer.end();
243
+ }
244
+ if (received !== expectedSize) {
245
+ throw new Error(`previous bundle size mismatch (expected ${expectedSize}, got ${received})`);
246
+ }
247
+ if (hasher.digest("hex") !== expectedSha256) {
248
+ throw new Error("previous bundle checksum mismatch");
249
+ }
250
+ } catch (error) {
251
+ rmSync(destination, { force: true });
252
+ throw error;
253
+ }
254
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * The `CheckDriver` a scenario is handed, implemented over a running app's
3
+ * inspector (docs/agent-devtools.md). Failures throw; `runScenario` turns the
4
+ * throw into a failed check.
5
+ */
6
+
7
+ import type { CheckDriver, CheckWindow, WindowTarget } from "mirinjs/check";
8
+ import type { DevEvent, DevEventQuery } from "mirinjs/devtools/session";
9
+ import type { InspectorClient } from "./inspector.ts";
10
+
11
+ /** How long `expectText` waits for the UI to catch up with an RPC round trip. */
12
+ const EXPECT_TIMEOUT_MS = 2_000;
13
+ const EXPECT_POLL_MS = 100;
14
+
15
+ /** Thrown by `assert` / `expectText`, and by a step that fails. */
16
+ export class CheckAssertionError extends Error {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = "CheckAssertionError";
20
+ }
21
+ }
22
+
23
+ export interface ScenarioStep {
24
+ name: string;
25
+ ok: boolean;
26
+ ms: number;
27
+ }
28
+
29
+ export interface ScenarioOutcome {
30
+ steps: ScenarioStep[];
31
+ screenshots: string[];
32
+ /** Set when the scenario threw; the step it was in is in `steps`. */
33
+ failure?: { step: string; message: string };
34
+ }
35
+
36
+ function record(value: unknown): Record<string, unknown> {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value)
38
+ ? (value as Record<string, unknown>)
39
+ : {};
40
+ }
41
+
42
+ function windowQuery(options: WindowTarget | undefined): string {
43
+ return options?.window !== undefined ? `?window=${options.window}` : "";
44
+ }
45
+
46
+ function windowsOf(state: unknown): CheckWindow[] {
47
+ const list = record(state).windows;
48
+ if (!Array.isArray(list)) return [];
49
+ return list.flatMap((entry) => {
50
+ const win = record(entry);
51
+ const id = win.id;
52
+ const name = win.name;
53
+ if (typeof id !== "number") return [];
54
+ return [
55
+ {
56
+ id,
57
+ name: typeof name === "string" ? name : `window-${id}`,
58
+ ...(typeof win.url === "string" ? { url: win.url } : {}),
59
+ ...(typeof win.title === "string" ? { title: win.title } : {}),
60
+ },
61
+ ];
62
+ });
63
+ }
64
+
65
+ /** `onStep` mirrors steps into the event stream, so the app's log and the
66
+ * scenario's progress interleave in one timeline. */
67
+ export function createDriver(
68
+ client: InspectorClient,
69
+ onStep?: (name: string) => void,
70
+ ): { driver: CheckDriver; outcome: ScenarioOutcome; finishStep(ok: boolean): void } {
71
+ const steps: ScenarioStep[] = [];
72
+ const screenshots: string[] = [];
73
+ const outcome: ScenarioOutcome = { steps, screenshots };
74
+ let startedAt = Date.now();
75
+
76
+ const finishStep = (ok: boolean): void => {
77
+ const current = steps.at(-1);
78
+ if (current !== undefined && current.ms === -1) {
79
+ current.ok = ok;
80
+ current.ms = Date.now() - startedAt;
81
+ }
82
+ };
83
+
84
+ const act = async (body: Record<string, unknown>, options?: WindowTarget): Promise<void> => {
85
+ await client.post(`/act${windowQuery(options)}`, body);
86
+ };
87
+
88
+ const driver: CheckDriver = {
89
+ step(name) {
90
+ finishStep(true);
91
+ steps.push({ name, ok: false, ms: -1 });
92
+ startedAt = Date.now();
93
+ onStep?.(name);
94
+ },
95
+
96
+ async click(selector, options) {
97
+ await act(
98
+ {
99
+ action: "click",
100
+ selector,
101
+ ...(options?.clickCount !== undefined ? { clickCount: options.clickCount } : {}),
102
+ },
103
+ options,
104
+ );
105
+ },
106
+
107
+ async type(text, options) {
108
+ await act(
109
+ {
110
+ action: "type",
111
+ text,
112
+ ...(options?.selector !== undefined ? { selector: options.selector } : {}),
113
+ },
114
+ options,
115
+ );
116
+ },
117
+
118
+ async key(name, options) {
119
+ await act({ action: "key", key: name }, options);
120
+ },
121
+
122
+ async scroll(options) {
123
+ await act(
124
+ { action: "scroll", deltaY: options.deltaY ?? 0, deltaX: options.deltaX ?? 0 },
125
+ options,
126
+ );
127
+ },
128
+
129
+ async waitFor(selector, options) {
130
+ await act(
131
+ {
132
+ action: "wait",
133
+ selector,
134
+ ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
135
+ },
136
+ options,
137
+ );
138
+ },
139
+
140
+ async sleep(ms) {
141
+ await Bun.sleep(ms);
142
+ },
143
+
144
+ async snapshot(options) {
145
+ const query = new URLSearchParams();
146
+ if (options?.window !== undefined) query.set("window", String(options.window));
147
+ if (options?.format !== undefined) query.set("format", options.format);
148
+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
149
+ const result = record(await client.get(`/snapshot${suffix}`));
150
+ const snapshot = result.snapshot;
151
+ if (typeof snapshot !== "string") throw new Error("the app returned no snapshot");
152
+ return snapshot;
153
+ },
154
+
155
+ async evaluate(expression, options) {
156
+ const result = record(await client.post(`/eval${windowQuery(options)}`, { expression }));
157
+ // The route reports a page-side throw as ok:false rather than as HTTP 500.
158
+ if (result.ok === false) {
159
+ throw new Error(`eval failed: ${String(result.error ?? "unknown error")}`);
160
+ }
161
+ return result.value as never;
162
+ },
163
+
164
+ async screenshot(label, options) {
165
+ const query = new URLSearchParams();
166
+ if (options?.window !== undefined) query.set("window", String(options.window));
167
+ // Default the label to the current step, so screenshots stay identifiable.
168
+ query.set("label", label ?? steps.at(-1)?.name ?? `shot-${screenshots.length + 1}`);
169
+ const result = record(await client.get(`/screenshot?${query.toString()}`));
170
+ const path = result.path;
171
+ if (typeof path !== "string") throw new Error("the app returned no screenshot path");
172
+ screenshots.push(path);
173
+ return path;
174
+ },
175
+
176
+ async windows() {
177
+ return windowsOf(await client.get("/state"));
178
+ },
179
+
180
+ async exposed() {
181
+ return record(record(await client.get("/state")).exposed);
182
+ },
183
+
184
+ async logs(query: DevEventQuery = {}) {
185
+ const params = new URLSearchParams();
186
+ for (const [key, value] of Object.entries(query)) {
187
+ if (value === undefined) continue;
188
+ for (const entry of Array.isArray(value) ? value : [value]) {
189
+ params.append(key, String(entry));
190
+ }
191
+ }
192
+ const suffix = params.size > 0 ? `?${params.toString()}` : "";
193
+ const events = record(await client.get(`/logs${suffix}`)).events;
194
+ return Array.isArray(events) ? (events as DevEvent[]) : [];
195
+ },
196
+
197
+ async cdp(method, params, options) {
198
+ const result = record(
199
+ await client.post(`/cdp${windowQuery(options)}`, { method, params: params ?? {} }),
200
+ );
201
+ if (result.ok === false) {
202
+ throw new Error(`${method} failed: ${String(result.error ?? "unknown error")}`);
203
+ }
204
+ return result.result as never;
205
+ },
206
+
207
+ assert(condition, message) {
208
+ if (!condition) throw new CheckAssertionError(message);
209
+ },
210
+
211
+ async waitUntil(condition, message, options) {
212
+ const timeoutMs = options?.timeoutMs ?? EXPECT_TIMEOUT_MS;
213
+ const deadline = Date.now() + timeoutMs;
214
+ for (;;) {
215
+ // A condition that throws counts as "not yet"; the timeout makes it a failure.
216
+ let held: unknown = false;
217
+ try {
218
+ held = await condition();
219
+ } catch {
220
+ held = false;
221
+ }
222
+ if (held) return;
223
+ if (Date.now() >= deadline) {
224
+ throw new CheckAssertionError(`${message} (waited ${timeoutMs}ms)`);
225
+ }
226
+ await Bun.sleep(EXPECT_POLL_MS);
227
+ }
228
+ },
229
+
230
+ async expectText(text, options) {
231
+ await driver.waitUntil(
232
+ async () => (await driver.snapshot(options)).includes(text),
233
+ `expected the UI to contain ${JSON.stringify(text)}`,
234
+ ...(options?.timeoutMs !== undefined ? [{ timeoutMs: options.timeoutMs }] : []),
235
+ );
236
+ },
237
+ };
238
+
239
+ return { driver, outcome, finishStep };
240
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Client for a running app's inspector (docs/agent-devtools.md). Responses come
3
+ * back as `unknown`; callers narrow what they need.
4
+ */
5
+
6
+ import type { InspectorEndpoint } from "mirinjs/devtools/session";
7
+
8
+ /** Per-request deadline. Screenshots of a big window are the slow case. */
9
+ const REQUEST_TIMEOUT_MS = 20_000;
10
+
11
+ export class InspectorError extends Error {
12
+ readonly status: number;
13
+ constructor(status: number, message: string) {
14
+ super(message);
15
+ this.name = "InspectorError";
16
+ this.status = status;
17
+ }
18
+ }
19
+
20
+ export class InspectorClient {
21
+ readonly base: string;
22
+ #token: string;
23
+
24
+ constructor(endpoint: InspectorEndpoint) {
25
+ this.base = `http://127.0.0.1:${endpoint.port}`;
26
+ this.#token = endpoint.token;
27
+ }
28
+
29
+ /** GET a JSON route. `path` starts with `/` and may carry a query string. */
30
+ async get(path: string): Promise<unknown> {
31
+ return this.#request(path, { method: "GET" });
32
+ }
33
+
34
+ async post(path: string, body: unknown): Promise<unknown> {
35
+ return this.#request(path, {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json" },
38
+ body: JSON.stringify(body),
39
+ });
40
+ }
41
+
42
+ async #request(path: string, init: RequestInit): Promise<unknown> {
43
+ const res = await fetch(`${this.base}${path}`, {
44
+ ...init,
45
+ headers: { ...init.headers, authorization: `Bearer ${this.#token}` },
46
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
47
+ });
48
+ const text = await res.text();
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = JSON.parse(text);
52
+ } catch {
53
+ parsed = undefined;
54
+ }
55
+ if (!res.ok) {
56
+ const message =
57
+ typeof parsed === "object" && parsed !== null && "error" in parsed
58
+ ? String((parsed as { error: unknown }).error)
59
+ : text.slice(0, 200);
60
+ throw new InspectorError(res.status, `${path}: ${message}`);
61
+ }
62
+ return parsed;
63
+ }
64
+ }