@mirinjs/cli 0.1.0-alpha.2 → 0.1.0-alpha.20
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 -5
- package/src/artifacts.ts +165 -26
- package/src/build.ts +234 -43
- package/src/bundle/linux/app.ts +275 -0
- package/src/bundle/linux/index.ts +1 -0
- package/src/{bundle.ts → bundle/macos/app.ts} +65 -23
- package/src/bundle/macos/index.ts +1 -0
- package/src/bundle/shared/cef-locales.ts +91 -0
- package/src/{bundle-win.ts → bundle/windows/app.ts} +10 -6
- package/src/bundle/windows/index.ts +1 -0
- package/src/check.ts +271 -0
- package/src/dev.ts +213 -35
- package/src/dmg.ts +22 -10
- package/src/doctor.ts +227 -0
- package/src/extras.ts +52 -13
- package/src/icons/macos/appearance.ts +96 -0
- package/src/icons/macos/index.ts +1 -0
- package/src/icons/windows/icon.ts +255 -0
- package/src/icons/windows/index.ts +1 -0
- package/src/index.ts +109 -7
- package/src/installer-inno.ts +141 -0
- package/src/installer-win.ts +17 -11
- package/src/package/linux/index.ts +2 -0
- package/src/package/linux/lib/defaults.ts +53 -0
- package/src/package/linux/lib/tools.ts +45 -0
- package/src/package/linux/lib/validation.ts +71 -0
- package/src/package/linux/package.ts +293 -0
- package/src/package/linux/types.ts +30 -0
- package/src/release/installers.ts +148 -0
- package/src/release/previous.ts +105 -0
- package/src/release.ts +166 -116
- 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/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 {
|
|
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 {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
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
|
-
|
|
29
|
-
new Bun.CryptoHasher("sha256")
|
|
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
|
|
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
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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,
|
|
76
|
-
const bundleSha = sha256File(bundlePath);
|
|
77
|
-
const bundleSize =
|
|
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
|
|
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()
|
|
85
|
-
|
|
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(),
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|