@mirinjs/cli 0.1.0-alpha.18 → 0.1.0-alpha.19
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.
- package/package.json +8 -8
- package/src/artifacts.ts +24 -9
- package/src/build.ts +2 -0
- package/src/check.ts +271 -0
- package/src/dev.ts +117 -14
- package/src/doctor.ts +227 -0
- package/src/extras.ts +7 -5
- package/src/index.ts +61 -3
- package/src/shared/driver.ts +240 -0
- package/src/shared/inspector.ts +64 -0
- package/src/shared/report.ts +69 -0
- package/src/shared/scenario.ts +58 -0
- package/src/shared/session.ts +153 -0
- package/src/skill.ts +51 -0
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mirin doctor` — check the project and environment before anything is built,
|
|
3
|
+
* including the previous session's outcome (docs/agent-devtools.md).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { createServer } from "node:net";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
readCurrentSession,
|
|
12
|
+
readEventsFile,
|
|
13
|
+
readSessionInfo,
|
|
14
|
+
sessionPaths,
|
|
15
|
+
} from "mirinjs/devtools/session";
|
|
16
|
+
import { isInRepo, vendoredCef } from "./artifacts.ts";
|
|
17
|
+
import { createReporter } from "./shared/report.ts";
|
|
18
|
+
|
|
19
|
+
export type CheckStatus = "ok" | "warn" | "fail";
|
|
20
|
+
|
|
21
|
+
export interface DoctorCheck {
|
|
22
|
+
name: string;
|
|
23
|
+
status: CheckStatus;
|
|
24
|
+
detail: string;
|
|
25
|
+
fix?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface DoctorReport {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
projectDir: string;
|
|
31
|
+
platform: string;
|
|
32
|
+
checks: DoctorCheck[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SUPPORTED = new Set(["darwin-arm64", "win32-x64", "win32-arm64", "linux-x64", "linux-arm64"]);
|
|
36
|
+
|
|
37
|
+
function ok(name: string, detail: string): DoctorCheck {
|
|
38
|
+
return { name, status: "ok", detail };
|
|
39
|
+
}
|
|
40
|
+
function warn(name: string, detail: string, fix?: string): DoctorCheck {
|
|
41
|
+
return { name, status: "warn", detail, ...(fix !== undefined ? { fix } : {}) };
|
|
42
|
+
}
|
|
43
|
+
function fail(name: string, detail: string, fix?: string): DoctorCheck {
|
|
44
|
+
return { name, status: "fail", detail, ...(fix !== undefined ? { fix } : {}) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function portFree(port: number): Promise<boolean> {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const server = createServer();
|
|
50
|
+
server.once("error", () => resolve(false));
|
|
51
|
+
server.once("listening", () => server.close(() => resolve(true)));
|
|
52
|
+
server.listen(port, "127.0.0.1");
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function checkPlatform(): DoctorCheck {
|
|
57
|
+
const target = `${process.platform}-${process.arch}`;
|
|
58
|
+
return SUPPORTED.has(target)
|
|
59
|
+
? ok("platform", `${target}, bun ${Bun.version}`)
|
|
60
|
+
: fail(
|
|
61
|
+
"platform",
|
|
62
|
+
`${target} is not a supported target`,
|
|
63
|
+
`supported: ${[...SUPPORTED].join(", ")}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Load `mirin.config.ts` and check the fields the CLI and host both require. */
|
|
68
|
+
async function checkConfig(projectDir: string): Promise<DoctorCheck[]> {
|
|
69
|
+
const path = join(projectDir, "mirin.config.ts");
|
|
70
|
+
if (!existsSync(path)) {
|
|
71
|
+
return [fail("config", "mirin.config.ts not found", "run `mirin init` or check the directory")];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let config: Record<string, unknown>;
|
|
75
|
+
try {
|
|
76
|
+
const loaded: unknown = (await import(path)).default;
|
|
77
|
+
if (typeof loaded !== "object" || loaded === null) {
|
|
78
|
+
return [fail("config", "mirin.config.ts has no default export object")];
|
|
79
|
+
}
|
|
80
|
+
config = loaded as Record<string, unknown>;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return [
|
|
83
|
+
fail("config", `mirin.config.ts failed to load: ${err instanceof Error ? err.message : err}`),
|
|
84
|
+
];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const checks: DoctorCheck[] = [];
|
|
88
|
+
const missing = ["id", "name", "main", "windows"].filter((key) => config[key] == null);
|
|
89
|
+
checks.push(
|
|
90
|
+
missing.length === 0
|
|
91
|
+
? ok("config", `${String(config.name)} (${String(config.id)})`)
|
|
92
|
+
: fail("config", `missing required field(s): ${missing.join(", ")}`),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const main = typeof config.main === "string" ? config.main : "main/main.ts";
|
|
96
|
+
checks.push(
|
|
97
|
+
existsSync(join(projectDir, main))
|
|
98
|
+
? ok("main entry", main)
|
|
99
|
+
: fail("main entry", `${main} does not exist`, "point `main` at your main-process entry"),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const windows =
|
|
103
|
+
typeof config.windows === "object" && config.windows !== null ? config.windows : {};
|
|
104
|
+
const names = Object.keys(windows);
|
|
105
|
+
checks.push(
|
|
106
|
+
names.length > 0
|
|
107
|
+
? ok("windows", names.join(", "))
|
|
108
|
+
: warn("windows", "no windows declared", "the app will start with nothing visible"),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const devtools = config.devtools;
|
|
112
|
+
if (typeof devtools === "object" && devtools !== null && "production" in devtools) {
|
|
113
|
+
const enabled = (devtools as { production?: unknown }).production === true;
|
|
114
|
+
checks.push(
|
|
115
|
+
enabled
|
|
116
|
+
? warn(
|
|
117
|
+
"devtools",
|
|
118
|
+
"devtools.production is true — packaged builds will expose the inspector",
|
|
119
|
+
"remove it before shipping: the inspector can evaluate code in your app",
|
|
120
|
+
)
|
|
121
|
+
: ok("devtools", "production access disabled"),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return checks;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function checkUi(projectDir: string): DoctorCheck[] {
|
|
128
|
+
const checks: DoctorCheck[] = [];
|
|
129
|
+
checks.push(
|
|
130
|
+
existsSync(join(projectDir, "index.html"))
|
|
131
|
+
? ok("ui entry", "index.html")
|
|
132
|
+
: warn("ui entry", "no index.html at the project root", "Vite needs an HTML entry"),
|
|
133
|
+
);
|
|
134
|
+
const viteConfig = ["vite.config.ts", "vite.config.js", "vite.config.mts"].find((name) =>
|
|
135
|
+
existsSync(join(projectDir, name)),
|
|
136
|
+
);
|
|
137
|
+
checks.push(
|
|
138
|
+
viteConfig !== undefined
|
|
139
|
+
? ok("vite config", viteConfig)
|
|
140
|
+
: warn("vite config", "none found", "Vite will run with defaults"),
|
|
141
|
+
);
|
|
142
|
+
return checks;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function checkCef(): DoctorCheck {
|
|
146
|
+
if (isInRepo()) {
|
|
147
|
+
// Resolved from the CLI's location, not the cwd, which is usually an example app.
|
|
148
|
+
const vendored = vendoredCef();
|
|
149
|
+
return vendored.present
|
|
150
|
+
? ok("cef", "vendor/cef is present")
|
|
151
|
+
: warn("cef", `${vendored.path} is missing or incomplete`, "run `bun scripts/fetch-cef.ts`");
|
|
152
|
+
}
|
|
153
|
+
const cache = join(homedir(), ".mirinjs", "cef");
|
|
154
|
+
return existsSync(cache)
|
|
155
|
+
? ok("cef", `cached at ${cache}`)
|
|
156
|
+
: warn("cef", "no cached CEF runtime", "the first `mirin dev` downloads it (a few hundred MB)");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function checkPorts(): Promise<DoctorCheck[]> {
|
|
160
|
+
const [vite, cdp] = await Promise.all([portFree(5173), portFree(9222)]);
|
|
161
|
+
return [
|
|
162
|
+
vite
|
|
163
|
+
? ok("vite port", "5173 is free")
|
|
164
|
+
: warn("vite port", "5173 is in use", "mirin dev probes upward, so this is not fatal"),
|
|
165
|
+
cdp
|
|
166
|
+
? ok("devtools port", "9222 is free")
|
|
167
|
+
: warn("devtools port", "9222 is in use", "mirin dev probes upward, so this is not fatal"),
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function checkLastSession(projectDir: string): DoctorCheck {
|
|
172
|
+
const dir = readCurrentSession(projectDir);
|
|
173
|
+
if (dir === undefined) return ok("last session", "none recorded yet");
|
|
174
|
+
|
|
175
|
+
const paths = sessionPaths(dir);
|
|
176
|
+
const info = readSessionInfo(paths);
|
|
177
|
+
const errors = readEventsFile(paths.events).filter((event) => event.level === "error");
|
|
178
|
+
const lastPhase = info?.phases.at(-1);
|
|
179
|
+
const phase =
|
|
180
|
+
lastPhase !== undefined ? `${lastPhase.name}=${lastPhase.status}` : "no phases recorded";
|
|
181
|
+
|
|
182
|
+
if (errors.length === 0) return ok("last session", `${phase}, no errors — ${dir}`);
|
|
183
|
+
const first = errors[0];
|
|
184
|
+
return warn(
|
|
185
|
+
"last session",
|
|
186
|
+
`${phase}, ${errors.length} error(s) — first: ${first?.msg ?? ""}`,
|
|
187
|
+
`full stream: ${paths.events}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function renderReport(report: DoctorReport): void {
|
|
192
|
+
const glyph: Record<CheckStatus, string> = { ok: "✓", warn: "!", fail: "✗" };
|
|
193
|
+
console.log(`\nmirin doctor — ${report.projectDir}\n`);
|
|
194
|
+
for (const check of report.checks) {
|
|
195
|
+
console.log(` ${glyph[check.status]} ${check.name.padEnd(14)} ${check.detail}`);
|
|
196
|
+
if (check.fix !== undefined) console.log(`${" ".repeat(19)}→ ${check.fix}`);
|
|
197
|
+
}
|
|
198
|
+
const failures = report.checks.filter((check) => check.status === "fail").length;
|
|
199
|
+
const warnings = report.checks.filter((check) => check.status === "warn").length;
|
|
200
|
+
console.log(
|
|
201
|
+
`\n${report.ok ? "✓ ready" : `✗ ${failures} blocking problem(s)`}${warnings > 0 ? `, ${warnings} warning(s)` : ""}\n`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Run the preflight. Exit code 0 unless a check failed outright. */
|
|
206
|
+
export async function doctor(
|
|
207
|
+
projectDir = process.cwd(),
|
|
208
|
+
options: { json?: boolean } = {},
|
|
209
|
+
): Promise<number> {
|
|
210
|
+
const reporter = createReporter(options.json === true);
|
|
211
|
+
const checks: DoctorCheck[] = [
|
|
212
|
+
checkPlatform(),
|
|
213
|
+
...(await checkConfig(projectDir)),
|
|
214
|
+
...checkUi(projectDir),
|
|
215
|
+
checkCef(),
|
|
216
|
+
...(await checkPorts()),
|
|
217
|
+
checkLastSession(projectDir),
|
|
218
|
+
];
|
|
219
|
+
const report: DoctorReport = {
|
|
220
|
+
ok: !checks.some((check) => check.status === "fail"),
|
|
221
|
+
projectDir,
|
|
222
|
+
platform: `${process.platform}-${process.arch}`,
|
|
223
|
+
checks,
|
|
224
|
+
};
|
|
225
|
+
reporter.finish({ ...report }, () => renderReport(report));
|
|
226
|
+
return report.ok ? 0 : 1;
|
|
227
|
+
}
|
package/src/extras.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { mkdirSync } from "node:fs";
|
|
9
9
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
10
|
import { $ } from "bun";
|
|
11
|
+
import { createReporter, type Reporter } from "./shared/report.ts";
|
|
11
12
|
|
|
12
13
|
/** Config shapes (mirrors packages/mirin/src/config/index.ts; CLI can't import the runtime). */
|
|
13
14
|
export type SidecarConfig = Record<string, string | { bin: string; entitlements?: string[] }>;
|
|
@@ -77,6 +78,8 @@ export async function compileWorkers(
|
|
|
77
78
|
workers: WorkersConfig | undefined,
|
|
78
79
|
outDir: string,
|
|
79
80
|
minify: boolean,
|
|
81
|
+
/** Where `bun build`'s output goes. Defaults to human mode, which prints it. */
|
|
82
|
+
reporter: Reporter = createReporter(false),
|
|
80
83
|
): Promise<Record<string, string>> {
|
|
81
84
|
mkdirSync(outDir, { recursive: true });
|
|
82
85
|
const out: Record<string, string> = {};
|
|
@@ -84,11 +87,10 @@ export async function compileWorkers(
|
|
|
84
87
|
const safeName = safeExtraAssetName(name, "worker name");
|
|
85
88
|
const js = join(outDir, `${safeName}.js`);
|
|
86
89
|
const src = projectRelativePath(projectDir, entry, `worker "${safeName}" entry`);
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
90
|
+
const minifyFlag = minify ? ["--minify"] : [];
|
|
91
|
+
await reporter.build(
|
|
92
|
+
$`bun build ${src} --target=bun ${minifyFlag} --outfile ${js}`.cwd(projectDir),
|
|
93
|
+
);
|
|
92
94
|
return { name: safeName, js };
|
|
93
95
|
});
|
|
94
96
|
for (const worker of await Promise.all(compiled)) {
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* mirin CLI — `dev`, `build`, `init`.
|
|
3
|
+
* mirin CLI — `dev`, `check`, `doctor`, `build`, `release`, `init`, `skill`.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
7
|
import { scaffold } from "create-mirinjs";
|
|
8
8
|
import { build } from "./build.ts";
|
|
9
|
+
import { check } from "./check.ts";
|
|
9
10
|
import { dev } from "./dev.ts";
|
|
11
|
+
import { doctor } from "./doctor.ts";
|
|
10
12
|
import { parseLinuxFormats } from "./package/linux/index.ts";
|
|
11
13
|
import { release } from "./release.ts";
|
|
14
|
+
import { skill } from "./skill.ts";
|
|
12
15
|
|
|
13
16
|
/** Minimal flag parser: `--k=v`, `--k v`, and boolean `--k`; the rest are positionals. */
|
|
14
17
|
function parseArgs(argv: string[]): {
|
|
@@ -49,20 +52,61 @@ const USAGE = `mirin — build desktop apps with Bun + Chromium
|
|
|
49
52
|
|
|
50
53
|
Usage:
|
|
51
54
|
mirin dev run the app against the Vite dev server (HMR + typed RPC)
|
|
55
|
+
mirin check boot once, capture a screenshot + UI snapshot, report, exit
|
|
56
|
+
mirin doctor check the project and environment without building
|
|
52
57
|
mirin build package a standalone app (output: ./build)
|
|
53
58
|
mirin release build + emit update artifacts (output: ./build/release)
|
|
54
59
|
mirin init [dir] scaffold a new app
|
|
60
|
+
mirin skill install the agent skill into this project (.claude/skills/mirin)
|
|
55
61
|
|
|
56
62
|
Options (build):
|
|
57
63
|
--version <v> override the app version (else package.json)
|
|
58
64
|
--linux also emit Linux packages (AppImage + .deb + .rpm)
|
|
59
65
|
--linux-target <a,b,…> restrict Linux packaging to these formats
|
|
60
66
|
(appimage,deb,rpm) — implies --linux
|
|
67
|
+
|
|
68
|
+
Options (check):
|
|
69
|
+
--timeout <ms> how long to wait for a window (default 45000)
|
|
70
|
+
--settle <ms> quiet time before capturing (default 1000)
|
|
71
|
+
--scenario <file> drive the app with a scenario before capturing;
|
|
72
|
+
the file default-exports defineCheck(async app => …)
|
|
73
|
+
|
|
74
|
+
Options (check, doctor, dev, build):
|
|
75
|
+
--json machine-readable output on stdout
|
|
61
76
|
`;
|
|
62
77
|
|
|
78
|
+
/** `--flag <n>` as a positive integer, or undefined when absent/unusable. */
|
|
79
|
+
function intOpt(key: string): number | undefined {
|
|
80
|
+
const raw = opts[key];
|
|
81
|
+
if (typeof raw !== "string") return undefined;
|
|
82
|
+
const value = Number(raw);
|
|
83
|
+
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const asJson = opts.json === true;
|
|
87
|
+
|
|
63
88
|
switch (command) {
|
|
64
89
|
case "dev": {
|
|
65
|
-
process.exit(await dev());
|
|
90
|
+
process.exit(await dev(process.cwd(), asJson ? { json: true } : undefined));
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "check": {
|
|
94
|
+
process.exit(
|
|
95
|
+
await check(process.cwd(), {
|
|
96
|
+
json: asJson,
|
|
97
|
+
...(intOpt("timeout") !== undefined ? { timeoutMs: intOpt("timeout") } : {}),
|
|
98
|
+
...(intOpt("settle") !== undefined ? { settleMs: intOpt("settle") } : {}),
|
|
99
|
+
...(typeof opts.scenario === "string" ? { scenario: opts.scenario } : {}),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "doctor": {
|
|
105
|
+
process.exit(await doctor(process.cwd(), { json: asJson }));
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
case "skill": {
|
|
109
|
+
process.exit(await skill(process.cwd(), { json: asJson }));
|
|
66
110
|
break;
|
|
67
111
|
}
|
|
68
112
|
case "build": {
|
|
@@ -70,7 +114,20 @@ switch (command) {
|
|
|
70
114
|
const target = typeof opts["linux-target"] === "string" ? opts["linux-target"] : undefined;
|
|
71
115
|
const linuxFormats = target ? parseLinuxFormats(target) : undefined;
|
|
72
116
|
const packageLinux = opts.linux === true || linuxFormats != null;
|
|
73
|
-
await build(process.cwd(), { version, packageLinux, linuxFormats });
|
|
117
|
+
const result = await build(process.cwd(), { version, packageLinux, linuxFormats });
|
|
118
|
+
if (asJson) {
|
|
119
|
+
// Only what a caller needs; the rest of BuildResult is plumbing for `mirin release`.
|
|
120
|
+
console.log(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
phase: "result",
|
|
123
|
+
app: result.app,
|
|
124
|
+
appName: result.appName,
|
|
125
|
+
bundleId: result.bundleId,
|
|
126
|
+
version: result.version,
|
|
127
|
+
channel: result.channel,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
74
131
|
process.exit(0);
|
|
75
132
|
break;
|
|
76
133
|
}
|
|
@@ -83,6 +140,7 @@ switch (command) {
|
|
|
83
140
|
try {
|
|
84
141
|
const name = scaffold(targetDir);
|
|
85
142
|
console.log(`\n✓ Created ${name} in ${arg ?? "my-mirin-app"}`);
|
|
143
|
+
console.log(" .claude/skills/mirin/ — agent skill for verifying changes");
|
|
86
144
|
console.log(" bun install && bun run dev");
|
|
87
145
|
process.exit(0);
|
|
88
146
|
} catch (err) {
|
|
@@ -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
|
+
}
|