@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
@@ -11,9 +11,19 @@
11
11
  * "<exe> Helper (Renderer).app/Contents/MacOS/<exe> Helper (Renderer)".
12
12
  */
13
13
 
14
- import { $ } from "bun";
15
- import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, chmodSync } from "node:fs";
14
+ import {
15
+ chmodSync,
16
+ cpSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readdirSync,
20
+ rmSync,
21
+ writeFileSync,
22
+ } from "node:fs";
16
23
  import { join } from "node:path";
24
+ import { $ } from "bun";
25
+ import { isIconComposerDoc, writeAppearanceCatalog } from "../../icons/macos/index.ts";
26
+ import { pruneMacCefLocales } from "../shared/cef-locales.ts";
17
27
 
18
28
  const FRAMEWORK = "Chromium Embedded Framework.framework";
19
29
 
@@ -33,9 +43,14 @@ export interface BundleOptions {
33
43
  coreDylib: string; // libmirin_core.dylib
34
44
  helperBin: string; // compiled mirin-helper binary
35
45
  cefPath: string; // dir containing the CEF framework (vendor/cef)
46
+ /** Production CEF locale allowlist. Undefined keeps every locale. */
47
+ cefLocales?: string[];
36
48
  /** App version → Info.plist CFBundleShortVersionString/CFBundleVersion. */
37
49
  version?: string;
38
- /** App icon source: a .icns, a .iconset dir, or a square .png. Optional. */
50
+ /**
51
+ * App icon source: a .icns, a .iconset dir, a square .png, or an Icon Composer
52
+ * .icon document (the only form carrying appearance variants). Optional.
53
+ */
39
54
  icon?: string;
40
55
  /** Codesign identity; "-" (default) is ad-hoc. Set to a Developer ID to ship. */
41
56
  signIdentity?: string;
@@ -98,19 +113,8 @@ const ICONSET_RENDITIONS = [
98
113
  { name: "icon_512x512@2x.png", px: 1024 },
99
114
  ];
100
115
 
101
- /**
102
- * Render the app icon to `Resources/icon.icns` and return the CFBundleIconFile
103
- * stem ("icon"), or undefined if there's no usable source. Accepts a `.icns`
104
- * (copied), a `.iconset` directory (iconutil), or a square `.png` (rendered to
105
- * a full iconset via sips, then iconutil).
106
- */
107
- async function writeIcon(iconSrc: string, resources: string): Promise<string | undefined> {
108
- if (!existsSync(iconSrc)) {
109
- console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
110
- return undefined;
111
- }
112
- const icns = join(resources, "icon.icns");
113
-
116
+ /** Render one icon source to `Resources/icon.icns`. */
117
+ async function writeIcns(iconSrc: string, icns: string, resources: string): Promise<void> {
114
118
  if (iconSrc.endsWith(".icns")) {
115
119
  cpSync(iconSrc, icns);
116
120
  } else if (iconSrc.endsWith(".iconset")) {
@@ -125,7 +129,38 @@ async function writeIcon(iconSrc: string, resources: string): Promise<string | u
125
129
  await $`iconutil -c icns ${iconset} -o ${icns}`.quiet();
126
130
  rmSync(iconset, { recursive: true, force: true });
127
131
  }
128
- return "icon";
132
+ }
133
+
134
+ /** The Info.plist keys an icon contributes, empty when there's no usable source. */
135
+ interface IconKeys {
136
+ /** CFBundleIconFile — `Resources/icon.icns`, read by macOS 25 and earlier. */
137
+ iconFile?: string;
138
+ /** CFBundleIconName — `Resources/Assets.car`, preferred on macOS 26+. */
139
+ iconName?: string;
140
+ }
141
+
142
+ /**
143
+ * Render the app icon into Resources. An `.icon` document compiles to an
144
+ * `Assets.car` (macOS 26+ light/dark/tinted) and contributes the `.icns` actool
145
+ * derives from it; every other source takes the plain iconutil path.
146
+ */
147
+ async function writeIcon(iconSrc: string, resources: string, work: string): Promise<IconKeys> {
148
+ if (!existsSync(iconSrc)) {
149
+ console.warn(`[mirin] icon not found, skipping: ${iconSrc}`);
150
+ return {};
151
+ }
152
+
153
+ const icns = join(resources, "icon.icns");
154
+ if (isIconComposerDoc(iconSrc)) {
155
+ // An Icon Composer document can't go through iconutil, so the only .icns
156
+ // available for it is the one actool derives.
157
+ const catalog = await writeAppearanceCatalog(iconSrc, resources, work);
158
+ if (catalog?.icns) cpSync(catalog.icns, icns);
159
+ return { iconFile: existsSync(icns) ? "icon" : undefined, iconName: catalog?.name };
160
+ }
161
+
162
+ await writeIcns(iconSrc, icns, resources);
163
+ return { iconFile: "icon" };
129
164
  }
130
165
 
131
166
  type PlistValue = string | boolean | PlistValue[] | { [k: string]: PlistValue };
@@ -177,8 +212,12 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
177
212
  cpSync(opts.coreDylib, join(macos, "libmirin_core.dylib"));
178
213
 
179
214
  // Render the icon (if any) into Resources before writing the plist, so we
180
- // only set CFBundleIconFile when an icon was actually produced.
181
- const iconFile = opts.icon ? await writeIcon(opts.icon, join(contents, "Resources")) : undefined;
215
+ // only set the icon keys when an icon was actually produced.
216
+ const iconWork = join(opts.outDir, ".mirin-icon");
217
+ const { iconFile, iconName } = opts.icon
218
+ ? await writeIcon(opts.icon, join(contents, "Resources"), iconWork)
219
+ : ({} as IconKeys);
220
+ rmSync(iconWork, { recursive: true, force: true });
182
221
  const version = opts.version ?? "0.0.1";
183
222
 
184
223
  const info: Record<string, PlistValue> = {
@@ -198,20 +237,23 @@ export async function buildAppBundle(opts: BundleOptions): Promise<{ app: string
198
237
  LSEnvironment: { MallocNanoZone: "0" },
199
238
  };
200
239
  if (iconFile) info.CFBundleIconFile = iconFile;
240
+ // macOS 26+ resolves the appearance-aware icon out of Assets.car via this key
241
+ // and falls back to CFBundleIconFile when the catalog has no match.
242
+ if (iconName) info.CFBundleIconName = iconName;
201
243
  // Deep-link schemes: register this app as the macOS handler for app://-style
202
244
  // URLs (e.g. anko://…). Delivered at runtime via app.on("open-url").
203
245
  if (opts.urlSchemes?.length) {
204
- info.CFBundleURLTypes = [
205
- { CFBundleURLName: bundleId, CFBundleURLSchemes: opts.urlSchemes },
206
- ];
246
+ info.CFBundleURLTypes = [{ CFBundleURLName: bundleId, CFBundleURLSchemes: opts.urlSchemes }];
207
247
  }
208
248
 
209
249
  writeFileSync(join(contents, "Info.plist"), plist(info));
210
250
 
211
- cpSync(join(cefPath, FRAMEWORK), join(frameworks, FRAMEWORK), {
251
+ const bundledFramework = join(frameworks, FRAMEWORK);
252
+ cpSync(join(cefPath, FRAMEWORK), bundledFramework, {
212
253
  recursive: true,
213
254
  verbatimSymlinks: true,
214
255
  });
256
+ pruneMacCefLocales(bundledFramework, opts.cefLocales);
215
257
 
216
258
  // Production resources: the served UI, the Worker bundle, and the manifest.
217
259
  const resources = join(contents, "Resources");
@@ -0,0 +1 @@
1
+ export * from "./app.ts";
@@ -0,0 +1,91 @@
1
+ import { cpSync, mkdirSync, readdirSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const CEF_LOCALE = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/;
5
+ const MAC_VARIANTS = new Set(["FEMININE", "MASCULINE", "NEUTER"]);
6
+
7
+ /** Validate and normalize an optional CEF locale allowlist. */
8
+ export function normalizeCefLocales(locales: unknown): string[] | undefined {
9
+ if (locales == null) return undefined;
10
+ if (!Array.isArray(locales) || locales.length === 0) {
11
+ throw new Error("[mirin] cef.locales must contain at least one locale.");
12
+ }
13
+
14
+ const seen = new Set<string>();
15
+ const normalized: string[] = [];
16
+ for (const value of locales) {
17
+ if (typeof value !== "string" || !CEF_LOCALE.test(value)) {
18
+ throw new Error(`[mirin] invalid CEF locale ${JSON.stringify(value)}.`);
19
+ }
20
+ const locale = value.replaceAll("_", "-");
21
+ const key = locale.toLowerCase();
22
+ if (!seen.has(key)) {
23
+ seen.add(key);
24
+ normalized.push(locale);
25
+ }
26
+ }
27
+ return normalized;
28
+ }
29
+
30
+ /** Copy selected Windows/Linux `locales/<locale>.pak` files, or every locale when unset. */
31
+ export function copyFlatCefLocales(
32
+ source: string,
33
+ destination: string,
34
+ locales: string[] | undefined,
35
+ ): void {
36
+ if (!locales) {
37
+ cpSync(source, destination, { recursive: true });
38
+ return;
39
+ }
40
+
41
+ const entries = readdirSync(source, { withFileTypes: true }).filter(
42
+ (entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".pak"),
43
+ );
44
+ const byLocale = new Map(
45
+ entries.map((entry) => [localeKey(entry.name.slice(0, -".pak".length)), entry.name]),
46
+ );
47
+
48
+ mkdirSync(destination, { recursive: true });
49
+ for (const locale of locales) {
50
+ const file = byLocale.get(localeKey(locale));
51
+ if (!file) throw new Error(`[mirin] CEF locale "${locale}" is not available in ${source}.`);
52
+ cpSync(join(source, file), join(destination, file));
53
+ }
54
+ }
55
+
56
+ /** Remove unselected macOS `.lproj` locale directories from a copied CEF framework. */
57
+ export function pruneMacCefLocales(framework: string, locales: string[] | undefined): void {
58
+ if (!locales) return;
59
+
60
+ const resources = join(framework, "Resources");
61
+ const localeDirs = readdirSync(resources, { withFileTypes: true })
62
+ .filter((entry) => entry.isDirectory() && entry.name.endsWith(".lproj"))
63
+ .map((entry) => entry.name);
64
+ const available = new Set(localeDirs.map((name) => name.slice(0, -".lproj".length)));
65
+ const keep = new Set<string>();
66
+
67
+ for (const locale of locales) {
68
+ const requested = locale.replaceAll("-", "_");
69
+ const language = requested.split("_", 1)[0] ?? requested;
70
+ const root = available.has(requested)
71
+ ? requested
72
+ : available.has(language)
73
+ ? language
74
+ : undefined;
75
+ if (!root) throw new Error(`[mirin] CEF locale "${locale}" is not available in ${resources}.`);
76
+
77
+ keep.add(`${root}.lproj`);
78
+ for (const variant of MAC_VARIANTS) {
79
+ const name = `${root}_${variant}.lproj`;
80
+ if (localeDirs.includes(name)) keep.add(name);
81
+ }
82
+ }
83
+
84
+ for (const directory of localeDirs) {
85
+ if (!keep.has(directory)) rmSync(join(resources, directory), { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ function localeKey(locale: string): string {
90
+ return locale.replaceAll("_", "-").toLowerCase();
91
+ }
@@ -15,9 +15,10 @@
15
15
  * what mirin-core derives at runtime (engine::derive_subprocess_path, host.ts).
16
16
  */
17
17
 
18
- import { cpSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync } from "node:fs";
19
- import { join, extname } from "node:path";
20
- import { makeWindowsIcon } from "./icon-win.ts";
18
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
19
+ import { extname, join } from "node:path";
20
+ import { makeWindowsIcon } from "../../icons/windows/index.ts";
21
+ import { copyFlatCefLocales } from "../shared/cef-locales.ts";
21
22
 
22
23
  /** Source dir (vendor/cef on Windows) must contain libcef.dll. */
23
24
  const CEF_MARKER = "libcef.dll";
@@ -26,7 +27,7 @@ const CEF_MARKER = "libcef.dll";
26
27
  * distribution (libcef.lib, include/, cmake/, archive.json, bootstrap*.exe) is
27
28
  * build-time only and intentionally left out. */
28
29
  const CEF_RUNTIME_EXTS = new Set([".dll", ".bin", ".pak", ".dat"]);
29
- const CEF_RUNTIME_EXTRAS = ["vk_swiftshader_icd.json", "locales"];
30
+ const CEF_RUNTIME_EXTRAS = ["vk_swiftshader_icd.json"];
30
31
 
31
32
  export interface WinBundleOptions {
32
33
  appName: string; // also the host exe stem → <appName>.exe
@@ -35,6 +36,8 @@ export interface WinBundleOptions {
35
36
  coreDll: string; // mirin_core.dll
36
37
  helperExe: string; // mirin-helper.exe
37
38
  cefPath: string; // dir containing the Windows CEF runtime (vendor/cef)
39
+ /** Production CEF locale allowlist. Undefined keeps every locale. */
40
+ cefLocales?: string[];
38
41
  /** App icon source (.ico / .iconset / .png) → <App>/icon.ico, set as the window icon. */
39
42
  icon?: string;
40
43
  /** Production-only resources placed under <App>/resources. */
@@ -66,7 +69,7 @@ export async function buildWindowsBundle(
66
69
  cpSync(opts.coreDll, join(app, "mirin_core.dll"));
67
70
  cpSync(opts.helperExe, join(app, "mirin-helper.exe"));
68
71
 
69
- copyCefRuntime(cefPath, app);
72
+ copyCefRuntime(cefPath, app, opts.cefLocales);
70
73
 
71
74
  // App icon → <App>/icon.ico, loaded at runtime by the core and set as the window
72
75
  // icon (taskbar / Alt-Tab / title). Sits beside the exe so the core finds it.
@@ -105,7 +108,7 @@ export async function buildWindowsBundle(
105
108
  }
106
109
 
107
110
  /** Copy the CEF runtime (dlls, paks, snapshot, icu data, locales) into `dest`. */
108
- function copyCefRuntime(cefPath: string, dest: string): void {
111
+ function copyCefRuntime(cefPath: string, dest: string, locales: string[] | undefined): void {
109
112
  for (const entry of readdirSync(cefPath)) {
110
113
  if (CEF_RUNTIME_EXTS.has(extname(entry).toLowerCase())) {
111
114
  cpSync(join(cefPath, entry), join(dest, entry));
@@ -115,4 +118,5 @@ function copyCefRuntime(cefPath: string, dest: string): void {
115
118
  const src = join(cefPath, extra);
116
119
  if (existsSync(src)) cpSync(src, join(dest, extra), { recursive: true });
117
120
  }
121
+ copyFlatCefLocales(join(cefPath, "locales"), join(dest, "locales"), locales);
118
122
  }
@@ -0,0 +1 @@
1
+ export * from "./app.ts";
package/src/check.ts ADDED
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `mirin check` — boot the app once, capture a screenshot and accessibility
3
+ * snapshot, collect errors, exit non-zero on failure (docs/agent-devtools.md).
4
+ */
5
+
6
+ import type { CheckScenario } from "mirinjs/check";
7
+ import type { DevEvent } from "mirinjs/devtools/session";
8
+ import { readEventsFile, sessionPaths } from "mirinjs/devtools/session";
9
+ import { dev } from "./dev.ts";
10
+ import type { ScenarioOutcome } from "./shared/driver.ts";
11
+ import { InspectorClient } from "./shared/inspector.ts";
12
+ import { createReporter, type Reporter } from "./shared/report.ts";
13
+ import { loadScenario, runScenario } from "./shared/scenario.ts";
14
+
15
+ /** How long to wait for the app to show a window before giving up. */
16
+ const DEFAULT_TIMEOUT_MS = 45_000;
17
+
18
+ /** Quiet time after the first window appears, so the capture shows painted UI
19
+ * rather than an empty frame. */
20
+ const DEFAULT_SETTLE_MS = 1_000;
21
+
22
+ export interface CheckOptions {
23
+ timeoutMs?: number;
24
+ settleMs?: number;
25
+ json?: boolean;
26
+ /** Path to a scenario module (`mirin check --scenario ./check.ts`). */
27
+ scenario?: string;
28
+ }
29
+
30
+ export interface CheckReport {
31
+ ok: boolean;
32
+ reason?: string;
33
+ session: string | null;
34
+ inspector: string | null;
35
+ windows: { id: number; name?: string; url?: string; title?: string }[];
36
+ /** Path to the captured screenshot. */
37
+ screenshot: string | null;
38
+ /** Accessibility snapshot of the first window. */
39
+ snapshot: string | null;
40
+ /** Slices the app published with `devtools.expose`, read at the end of the run. */
41
+ exposed: Record<string, unknown>;
42
+ errors: DevEvent[];
43
+ eventCount: number;
44
+ durationMs: number;
45
+ /** Present when `--scenario` ran. */
46
+ scenario?: { file: string } & ScenarioOutcome;
47
+ }
48
+
49
+ function record(value: unknown): Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value)
51
+ ? (value as Record<string, unknown>)
52
+ : {};
53
+ }
54
+
55
+ function windowsOf(state: unknown): CheckReport["windows"] {
56
+ const list = record(state).windows;
57
+ if (!Array.isArray(list)) return [];
58
+ return list.flatMap((entry) => {
59
+ const win = record(entry);
60
+ const id = win.id;
61
+ if (typeof id !== "number") return [];
62
+ return [
63
+ {
64
+ id,
65
+ ...(typeof win.name === "string" ? { name: win.name } : {}),
66
+ ...(typeof win.url === "string" ? { url: win.url } : {}),
67
+ ...(typeof win.title === "string" ? { title: win.title } : {}),
68
+ },
69
+ ];
70
+ });
71
+ }
72
+
73
+ /** Poll `/state` until a window exists, or the deadline passes. */
74
+ async function waitForWindow(
75
+ client: InspectorClient,
76
+ deadline: number,
77
+ ): Promise<CheckReport["windows"]> {
78
+ let last: CheckReport["windows"] = [];
79
+ while (Date.now() < deadline) {
80
+ try {
81
+ last = windowsOf(await client.get("/state"));
82
+ if (last.length > 0) return last;
83
+ } catch {
84
+ // The inspector may still be binding; keep trying until the deadline.
85
+ }
86
+ await Bun.sleep(250);
87
+ }
88
+ return last;
89
+ }
90
+
91
+ async function capture(client: InspectorClient, report: CheckReport): Promise<void> {
92
+ try {
93
+ const shot = record(await client.get("/screenshot"));
94
+ if (typeof shot.path === "string") report.screenshot = shot.path;
95
+ } catch (err) {
96
+ report.errors.push(syntheticError(`screenshot failed: ${message(err)}`));
97
+ }
98
+ try {
99
+ const snap = record(await client.get("/snapshot"));
100
+ if (typeof snap.snapshot === "string") report.snapshot = snap.snapshot;
101
+ } catch (err) {
102
+ report.errors.push(syntheticError(`snapshot failed: ${message(err)}`));
103
+ }
104
+ try {
105
+ report.exposed = record(record(await client.get("/state")).exposed);
106
+ } catch {
107
+ // The app publishing nothing is the common case; not worth an error.
108
+ }
109
+ }
110
+
111
+ function message(err: unknown): string {
112
+ return err instanceof Error ? err.message : String(err);
113
+ }
114
+
115
+ /** A failure of the check itself, shaped like a stream event so one list suffices. */
116
+ function syntheticError(text: string): DevEvent {
117
+ return { seq: 0, ts: Date.now(), src: "main", level: "error", type: "check.error", msg: text };
118
+ }
119
+
120
+ function renderReport(report: CheckReport): void {
121
+ const mark = report.ok ? "✓" : "✗";
122
+ console.log(`\n${mark} mirin check ${report.ok ? "passed" : "failed"} in ${report.durationMs}ms`);
123
+ if (report.reason !== undefined) console.log(` reason: ${report.reason}`);
124
+
125
+ if (report.windows.length > 0) {
126
+ console.log(` windows: ${report.windows.length}`);
127
+ for (const win of report.windows) {
128
+ const label = [win.name, win.title].filter(Boolean).join(" / ");
129
+ console.log(
130
+ ` #${win.id}${label.length > 0 ? ` ${label}` : ""} ${win.url ?? ""}`.trimEnd(),
131
+ );
132
+ }
133
+ }
134
+ if (report.scenario !== undefined) {
135
+ const { file, steps, screenshots, failure } = report.scenario;
136
+ console.log(` scenario: ${file}`);
137
+ for (const step of steps) {
138
+ const mark = step.ok ? "✓" : "✗";
139
+ console.log(` ${mark} ${step.name}${step.ms >= 0 ? ` (${step.ms}ms)` : ""}`);
140
+ }
141
+ if (failure !== undefined) console.log(` → ${failure.message}`);
142
+ for (const shot of screenshots) console.log(` shot: ${shot}`);
143
+ }
144
+ const exposedKeys = Object.keys(report.exposed);
145
+ if (exposedKeys.length > 0) {
146
+ console.log(` app state: ${JSON.stringify(report.exposed).slice(0, 400)}`);
147
+ }
148
+ if (report.screenshot !== null) console.log(` screenshot: ${report.screenshot}`);
149
+ if (report.session !== null) console.log(` session: ${report.session}`);
150
+ console.log(` events: ${report.eventCount}, errors: ${report.errors.length}`);
151
+
152
+ for (const error of report.errors.slice(0, 10)) {
153
+ console.log(`\n [${error.src}] ${error.type}: ${error.msg}`);
154
+ const stack = record(error.data).stack;
155
+ if (Array.isArray(stack)) {
156
+ for (const frame of stack.slice(0, 4)) console.log(` at ${String(frame)}`);
157
+ }
158
+ }
159
+ if (report.errors.length > 10) {
160
+ console.log(`\n … ${report.errors.length - 10} more errors (see the session's events.jsonl)`);
161
+ }
162
+ if (report.snapshot !== null) {
163
+ console.log(`\n ui snapshot:\n${report.snapshot.replace(/^/gm, " ")}`);
164
+ }
165
+ }
166
+
167
+ /** Run the check. Returns the process exit code: 0 when the app came up clean. */
168
+ export async function check(
169
+ projectDir = process.cwd(),
170
+ options: CheckOptions = {},
171
+ ): Promise<number> {
172
+ const reporter: Reporter = createReporter(options.json === true);
173
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
174
+ const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
175
+ const startedAt = Date.now();
176
+
177
+ const report: CheckReport = {
178
+ ok: false,
179
+ session: null,
180
+ inspector: null,
181
+ windows: [],
182
+ screenshot: null,
183
+ snapshot: null,
184
+ exposed: {},
185
+ errors: [],
186
+ eventCount: 0,
187
+ durationMs: 0,
188
+ };
189
+
190
+ reporter.event("start", { projectDir, timeoutMs });
191
+
192
+ // Load the scenario before anything is built: a typo in the path should cost a
193
+ // second, not a cold compile.
194
+ let scenario: CheckScenario | undefined;
195
+ if (options.scenario !== undefined) {
196
+ try {
197
+ scenario = await loadScenario(projectDir, options.scenario);
198
+ } catch (err) {
199
+ report.reason = message(err);
200
+ report.durationMs = Date.now() - startedAt;
201
+ reporter.finish({ ...report }, () => renderReport(report));
202
+ return 1;
203
+ }
204
+ }
205
+
206
+ await dev(projectDir, {
207
+ // Inherit the output mode: in JSON mode `mirin dev`'s prose would otherwise
208
+ // interleave with the report and break parsing.
209
+ json: options.json === true,
210
+ async onLaunched(context) {
211
+ const deadline = Date.now() + timeoutMs;
212
+ const session = context.session;
213
+ if (session === undefined) {
214
+ report.reason = "no dev session was created — devtools artifacts are unavailable";
215
+ return;
216
+ }
217
+ report.session = session.paths.dir;
218
+
219
+ reporter.event("await-inspector");
220
+ const endpoint = await session.waitForInspector(Math.max(1_000, deadline - Date.now()));
221
+ if (endpoint === undefined) {
222
+ report.reason = "the app never published an inspector endpoint";
223
+ return;
224
+ }
225
+ report.inspector = `http://127.0.0.1:${endpoint.port}`;
226
+ const client = new InspectorClient(endpoint);
227
+
228
+ reporter.event("await-window");
229
+ report.windows = await waitForWindow(client, deadline);
230
+ if (report.windows.length === 0) {
231
+ report.reason = `no window appeared within ${timeoutMs}ms`;
232
+ return;
233
+ }
234
+
235
+ // Let the first paint land, or the capture shows an empty frame and tree.
236
+ await Bun.sleep(settleMs);
237
+
238
+ if (scenario !== undefined && options.scenario !== undefined) {
239
+ reporter.event("scenario", { file: options.scenario });
240
+ const outcome = await runScenario(client, scenario, (name) =>
241
+ reporter.event("step", { name }),
242
+ );
243
+ report.scenario = { file: options.scenario, ...outcome };
244
+ if (outcome.failure !== undefined) {
245
+ report.reason = `scenario failed at "${outcome.failure.step}": ${outcome.failure.message}`;
246
+ }
247
+ }
248
+
249
+ // Capture after the scenario, so artifacts show the state the run ended in.
250
+ reporter.event("capture", { windows: report.windows.length });
251
+ await capture(client, report);
252
+ },
253
+ });
254
+
255
+ // Read the stream from disk rather than the inspector: by now the app has exited,
256
+ // and the file is the complete record of the run.
257
+ if (report.session !== null) {
258
+ const events = readEventsFile(sessionPaths(report.session).events);
259
+ report.eventCount = events.length;
260
+ report.errors.push(...events.filter((event) => event.level === "error"));
261
+ }
262
+
263
+ report.durationMs = Date.now() - startedAt;
264
+ report.ok = report.reason === undefined && report.errors.length === 0;
265
+ if (!report.ok && report.reason === undefined) {
266
+ report.reason = `${report.errors.length} error-level event(s) during startup`;
267
+ }
268
+
269
+ reporter.finish({ ...report }, () => renderReport(report));
270
+ return report.ok ? 0 : 1;
271
+ }