create-mirinjs 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 +5 -3
- package/skill/SKILL.md +118 -0
- package/skill/reference/inspector.md +175 -0
- package/skill/reference/scenarios.md +146 -0
- package/src/index.ts +2 -2
- package/src/scaffold.ts +15 -8
- package/src/skill.ts +37 -0
- package/template/AGENTS.md +92 -0
- package/template/README.md +8 -3
- package/template/main/main.ts +9 -2
- package/template/package.json +2 -0
- package/template/vite.config.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-mirinjs",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.20",
|
|
4
4
|
"description": "Scaffold a new mirin desktop app: bun create mirinjs",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
"create-mirinjs": "./src/index.ts"
|
|
23
23
|
},
|
|
24
24
|
"exports": {
|
|
25
|
-
".": "./src/scaffold.ts"
|
|
25
|
+
".": "./src/scaffold.ts",
|
|
26
|
+
"./skill": "./src/skill.ts"
|
|
26
27
|
},
|
|
27
28
|
"files": [
|
|
28
29
|
"src",
|
|
29
|
-
"template"
|
|
30
|
+
"template",
|
|
31
|
+
"skill"
|
|
30
32
|
],
|
|
31
33
|
"engines": {
|
|
32
34
|
"bun": ">=1.2.0"
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mirin
|
|
3
|
+
description: Verify and drive a mirin desktop app without a terminal in front of you. Use when working in a project with a mirin.config.ts and you need to answer "did my change actually work?" — booting the app headlessly, reading its structured event stream, capturing a screenshot or UI snapshot, driving a real user flow and asserting on it, or diagnosing a window that never appeared, a blank screen, a renderer exception, or a failing RPC call. Triggers include "run the app", "check the app", "does this still work", "screenshot the app", "why is the window blank", "the app won't start", "test this flow", "click the button and see what happens".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Verifying a mirin app
|
|
7
|
+
|
|
8
|
+
mirin apps are native desktop apps: a Bun main process, a CEF webview, typed RPC
|
|
9
|
+
between them. `bun run dev` opens a real window and never exits, which means a tool
|
|
10
|
+
that backgrounds it has a pid and no idea whether anything worked.
|
|
11
|
+
|
|
12
|
+
Everything below exists so you never have to guess. **Never report a change as
|
|
13
|
+
working because the code looks right — run `mirin check` and read the result.**
|
|
14
|
+
|
|
15
|
+
## The loop
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
mirin check # boot once, capture, report, exit non-zero on failure
|
|
19
|
+
mirin check --json # one JSON object on stdout, nothing else
|
|
20
|
+
mirin check --scenario ./check.ts # drive a real user flow and assert, then capture
|
|
21
|
+
mirin doctor # environment/config preflight, without building
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`mirin check` runs the real startup path, waits for a window, captures a screenshot
|
|
25
|
+
and an accessibility snapshot, collects every error the app produced, stops the app,
|
|
26
|
+
and **exits non-zero when something went wrong**. Treat the exit code as a test
|
|
27
|
+
result. If the project has a `check` script, `bun run check` is the same thing.
|
|
28
|
+
|
|
29
|
+
Exit 1 means: no window appeared, the inspector never came up, an `error`-level
|
|
30
|
+
event was recorded, or a scenario assertion failed. The `reason` field says which.
|
|
31
|
+
|
|
32
|
+
## Reading the result
|
|
33
|
+
|
|
34
|
+
In `--json`, every stdout line parses — progress events, then one `result` object.
|
|
35
|
+
Subprocess chatter goes to stderr, so `mirin check --json | jq` is safe.
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
{
|
|
39
|
+
"phase": "result",
|
|
40
|
+
"ok": false,
|
|
41
|
+
"reason": "scenario failed at \"typed mutation updates the list\": the todo never reached the list",
|
|
42
|
+
"windows": [{ "id": 1, "name": "main", "title": "…" }],
|
|
43
|
+
"screenshot": "/…/.mirin/dev/<session>/screenshots/….png",
|
|
44
|
+
"snapshot": "RootWebArea …", // the UI as an accessibility tree
|
|
45
|
+
"errors": [ /* full events, with stack traces */ ],
|
|
46
|
+
"scenario": { "steps": [ /* name, ok, ms */ ] }
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Read `errors` first — each carries `src` (`main`, `renderer`, `native`, `rpc`), the
|
|
51
|
+
message, and a stack when there is one. `snapshot` is the UI in a few hundred bytes;
|
|
52
|
+
prefer it over the DOM. `screenshot` is a **path** — read the file to look at it.
|
|
53
|
+
|
|
54
|
+
## When the app already exited
|
|
55
|
+
|
|
56
|
+
Every run writes a session directory, so you can diagnose a crash with file access
|
|
57
|
+
alone — no live process, no terminal.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
SESSION=$(bun -e 'console.log(JSON.parse(await Bun.file(".mirin/dev/current.json").text()).session)')
|
|
61
|
+
|
|
62
|
+
tail -n 50 "$SESSION/events.jsonl" # the full stream: logs, exceptions, RPC, native events
|
|
63
|
+
cat "$SESSION/session.json" # startup phase timeline — how far did it get?
|
|
64
|
+
cat "$SESSION/exit.json" # post-mortem: exit code, signal, last 50 events
|
|
65
|
+
ls "$SESSION/screenshots"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`session.json` answers "how far did startup get?" (`compile` → `bundle` → `vite` →
|
|
69
|
+
`launch`, each with a status). `exit.json` answers "how did it die?".
|
|
70
|
+
|
|
71
|
+
## Driving a real flow
|
|
72
|
+
|
|
73
|
+
A scenario clicks, types, and asserts against the running app, then fails the check
|
|
74
|
+
when an assertion does. This is how you verify a *flow*, not just a boot.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// check.ts
|
|
78
|
+
import { defineCheck } from "mirinjs/check";
|
|
79
|
+
|
|
80
|
+
export default defineCheck(async (app) => {
|
|
81
|
+
app.step("adding a todo");
|
|
82
|
+
await app.click("text=add a todo"); // CSS, or text= matching the a11y name
|
|
83
|
+
await app.type("ship it");
|
|
84
|
+
await app.click("text=Add");
|
|
85
|
+
await app.waitUntil(
|
|
86
|
+
async () => (await app.evaluate<number>('document.querySelectorAll("li").length')) > 0,
|
|
87
|
+
"the todo never reached the list",
|
|
88
|
+
);
|
|
89
|
+
await app.screenshot("todo-added");
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Run it with `mirin check --scenario ./check.ts`. Read
|
|
94
|
+
`reference/scenarios.md` for the full driver API and **two mistakes that produce
|
|
95
|
+
passing checks which verify nothing** — read that file before writing assertions.
|
|
96
|
+
|
|
97
|
+
## Inspecting a running app
|
|
98
|
+
|
|
99
|
+
While `mirin dev` is up, the same stream plus live state, screenshots, snapshots,
|
|
100
|
+
expression evaluation and synthetic input are available over a token-authenticated
|
|
101
|
+
loopback HTTP server. Use this for exploration; use `check --scenario` for anything
|
|
102
|
+
you want to be repeatable.
|
|
103
|
+
|
|
104
|
+
See `reference/inspector.md` for auth and the full route list.
|
|
105
|
+
|
|
106
|
+
## Rules
|
|
107
|
+
|
|
108
|
+
- Prefer `mirin check` over `mirin dev`. `dev` never exits; backgrounding it and
|
|
109
|
+
guessing is the failure mode this whole surface exists to remove.
|
|
110
|
+
- Report what the run actually said. If `check` exits 1, say so and quote the
|
|
111
|
+
`reason` — do not describe a change as working against a failing check.
|
|
112
|
+
- The screenshot of a transparent window is composited over an opaque backdrop so
|
|
113
|
+
it is readable; the response sets `composited: true`. It is not pixel-truth.
|
|
114
|
+
- This is a development surface and is off in packaged builds. Do not set
|
|
115
|
+
`devtools.production` to make something work — it ships an endpoint that can
|
|
116
|
+
evaluate arbitrary code in the app.
|
|
117
|
+
- Do not add a test framework to answer "does this still work". A scenario file and
|
|
118
|
+
`mirin check` already are the test.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# The inspector: driving a running app over HTTP
|
|
2
|
+
|
|
3
|
+
While `mirin dev` is running, the app serves a token-authenticated loopback HTTP
|
|
4
|
+
server. `mirin dev` prints the URL; the endpoint and token are also on disk.
|
|
5
|
+
|
|
6
|
+
Use this for exploration and one-off questions. For anything you want to be
|
|
7
|
+
repeatable, write a scenario (`reference/scenarios.md`) — it runs the same
|
|
8
|
+
operations through `mirin check` and gates on the result.
|
|
9
|
+
|
|
10
|
+
## Connecting
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
SESSION=$(bun -e 'console.log(JSON.parse(await Bun.file(".mirin/dev/current.json").text()).session)')
|
|
14
|
+
PORT=$(bun -e "console.log(JSON.parse(await Bun.file('$SESSION/inspector.json').text()).port)")
|
|
15
|
+
TOKEN=$(bun -e "console.log(JSON.parse(await Bun.file('$SESSION/inspector.json').text()).token)")
|
|
16
|
+
|
|
17
|
+
curl -s "http://127.0.0.1:$PORT/?token=$TOKEN" # route index — the surface is self-describing
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Every request needs the token, as `?token=…` or `Authorization: Bearer …`.
|
|
21
|
+
|
|
22
|
+
## Routes
|
|
23
|
+
|
|
24
|
+
| Route | Purpose |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `GET /` | Route index |
|
|
27
|
+
| `GET /health` | Liveness, pid, stream cursor, log file path |
|
|
28
|
+
| `GET /state` | Windows, RPC routes, app-published state, platform |
|
|
29
|
+
| `GET /logs` | The event stream, filterable |
|
|
30
|
+
| `GET /logs/stream` | The same as SSE, with replay |
|
|
31
|
+
| `GET /screenshot` | PNG of a window |
|
|
32
|
+
| `GET /snapshot` | Accessibility tree (default) or DOM HTML |
|
|
33
|
+
| `POST /eval` | Evaluate an expression in a window |
|
|
34
|
+
| `POST /act` | `click` / `type` / `key` / `scroll` / `wait` |
|
|
35
|
+
| `POST /cdp` | Any DevTools-protocol command |
|
|
36
|
+
| `GET /cdp/targets` | Which windows the DevTools bridge is attached to |
|
|
37
|
+
|
|
38
|
+
## Answering common questions
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# What is open, what RPC exists, what has the app published?
|
|
42
|
+
curl -s "http://127.0.0.1:$PORT/state?token=$TOKEN"
|
|
43
|
+
|
|
44
|
+
# Just the errors
|
|
45
|
+
curl -s "http://127.0.0.1:$PORT/logs?level=error&token=$TOKEN"
|
|
46
|
+
|
|
47
|
+
# What did the app do since sequence 42? (cursor from a previous lastSeq)
|
|
48
|
+
curl -s "http://127.0.0.1:$PORT/logs?since=42&token=$TOKEN"
|
|
49
|
+
|
|
50
|
+
# Every failed RPC call
|
|
51
|
+
curl -s "http://127.0.0.1:$PORT/logs?type=rpc.error&token=$TOKEN"
|
|
52
|
+
|
|
53
|
+
# The UI, cheaply — a few hundred bytes where the DOM is tens of kilobytes
|
|
54
|
+
curl -s "http://127.0.0.1:$PORT/snapshot?token=$TOKEN"
|
|
55
|
+
|
|
56
|
+
# Watch live (replays from `since` first)
|
|
57
|
+
curl -sN "http://127.0.0.1:$PORT/logs/stream?since=0&token=$TOKEN"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`/logs` accepts `since` (exclusive), `level` (minimum severity), `src`, `window`,
|
|
61
|
+
`type` (prefix match), `contains`, and `limit`. Unknown filter values are dropped
|
|
62
|
+
rather than rejected, so a near-miss still returns logs.
|
|
63
|
+
|
|
64
|
+
## HTTP traffic
|
|
65
|
+
|
|
66
|
+
Every request and response the page makes is in the stream — method, URL, status,
|
|
67
|
+
type, time to headers, and headers.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# everything the app called
|
|
71
|
+
curl -s "http://127.0.0.1:$PORT/logs?type=network&token=$TOKEN"
|
|
72
|
+
|
|
73
|
+
# just the failures
|
|
74
|
+
curl -s "http://127.0.0.1:$PORT/logs?type=network&level=warn&token=$TOKEN"
|
|
75
|
+
|
|
76
|
+
# one endpoint
|
|
77
|
+
curl -s "http://127.0.0.1:$PORT/logs?type=network&contains=/api/todos&token=$TOKEN"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Successful traffic is `debug`, a 4xx is `warn`, a 5xx is `error` — so a failing API
|
|
81
|
+
call fails `mirin check`, and a missing favicon does not.
|
|
82
|
+
|
|
83
|
+
**Bodies are not in the stream** — they are the most likely place for a credential
|
|
84
|
+
to sit, and the stream is plaintext on disk. Each event carries a `requestId`;
|
|
85
|
+
fetch the body when you actually need it:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
curl -s -X POST "http://127.0.0.1:$PORT/cdp?token=$TOKEN" \
|
|
89
|
+
-H 'content-type: application/json' \
|
|
90
|
+
-d '{"method":"Network.getResponseBody","params":{"requestId":"76030.15"}}'
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Headers and URLs are recorded with credential values replaced by `[redacted]`
|
|
94
|
+
(`Authorization`, `Cookie`, anything that looks like a token/key/secret/session, in
|
|
95
|
+
a header or a query parameter). If you need the real value, you are debugging the
|
|
96
|
+
credential itself — read it from the app's own config, not from the stream.
|
|
97
|
+
|
|
98
|
+
## Raw DevTools protocol
|
|
99
|
+
|
|
100
|
+
`POST /cdp` sends any CDP command, for whatever the routes above do not cover:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
curl -s -X POST "http://127.0.0.1:$PORT/cdp?token=$TOKEN" \
|
|
104
|
+
-H 'content-type: application/json' \
|
|
105
|
+
-d '{"method":"Performance.getMetrics"}'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Useful ones: `Network.getResponseBody`, `Performance.getMetrics` (heap size, node
|
|
109
|
+
count, layout count — call `Performance.enable` first), `DOM.getDocument`,
|
|
110
|
+
`CSS.getComputedStyleForNode`, `Profiler.*`, `HeapProfiler.*`.
|
|
111
|
+
|
|
112
|
+
Returns `{ ok, method, window, result }`. A protocol error comes back as
|
|
113
|
+
`{ ok: false, error }` — a stale `requestId` or an unsupported domain is
|
|
114
|
+
information, not a crash. Inside a scenario, the same thing is `app.cdp(method,
|
|
115
|
+
params)`.
|
|
116
|
+
|
|
117
|
+
## Screenshots
|
|
118
|
+
|
|
119
|
+
`GET /screenshot` writes the PNG into the session's `screenshots/` dir and returns
|
|
120
|
+
`{ path, bytes, format, window, composited }`. Read the path — a path costs a few
|
|
121
|
+
dozen bytes where an inlined base64 PNG costs megabytes. `?inline=1` returns raw
|
|
122
|
+
bytes. Also accepts `window`, `format=png|jpeg`, `quality`, `full=1`, and `label`.
|
|
123
|
+
|
|
124
|
+
If the window is transparent (a glass/OSR window), the page has no background of its
|
|
125
|
+
own and a raw capture is alpha 0 — every viewer composites it onto white and it
|
|
126
|
+
reads as blank. Such a page is captured over an opaque backdrop chosen to contrast
|
|
127
|
+
with its text, and the response sets `composited: true`. That image is readable but
|
|
128
|
+
is **not** pixel-truth, and it does not include the native material behind the
|
|
129
|
+
webview. `?backdrop=#rrggbb` forces a colour; `?backdrop=none` opts out.
|
|
130
|
+
|
|
131
|
+
## Driving
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
curl -s -X POST "http://127.0.0.1:$PORT/act?token=$TOKEN" \
|
|
135
|
+
-H 'content-type: application/json' \
|
|
136
|
+
-d '{"action":"click","selector":"text=Open settings"}'
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Actions: `click` (`selector`, `clickCount`), `type` (`text`, optional `selector`),
|
|
140
|
+
`key` (`key`), `scroll` (`deltaY`, `deltaX`), `wait` (`selector`, `timeoutMs`).
|
|
141
|
+
Selectors accept CSS or `text=…` matching the accessible label. Add `?window=2` to
|
|
142
|
+
target a specific window.
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
curl -s -X POST "http://127.0.0.1:$PORT/eval?token=$TOKEN" \
|
|
146
|
+
-H 'content-type: application/json' \
|
|
147
|
+
-d '{"expression":"document.title"}'
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Making app state visible
|
|
151
|
+
|
|
152
|
+
If you find yourself guessing at main-process state, publish it — it then appears in
|
|
153
|
+
`/state`, in `app.exposed()` inside a scenario, and in `mirin check`'s report
|
|
154
|
+
(`exposed`, printed as `app state:` when non-empty):
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { devtools } from "mirinjs";
|
|
158
|
+
|
|
159
|
+
devtools.expose("route", () => currentRoute);
|
|
160
|
+
devtools.event({ type: "order.submitted", msg: id });
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
This is the highest-leverage thing you can add to a mirin app for a tool's benefit:
|
|
164
|
+
it lifts state that no DOM snapshot could ever show into the same surface as windows
|
|
165
|
+
and logs.
|
|
166
|
+
|
|
167
|
+
## Security
|
|
168
|
+
|
|
169
|
+
The inspector can evaluate JavaScript and synthesize input, so it is a capability,
|
|
170
|
+
not a read-only view. It binds `127.0.0.1` only, requires the session token, and
|
|
171
|
+
rejects requests whose `Host` header does not name loopback.
|
|
172
|
+
|
|
173
|
+
It is **off in packaged builds**. Do not set `devtools.production` to make something
|
|
174
|
+
work — that ships an endpoint which can run arbitrary code in the app. `mirin
|
|
175
|
+
doctor` warns when it is set.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Scenarios: driving and asserting
|
|
2
|
+
|
|
3
|
+
A scenario is a module that default-exports an async function. `mirin check
|
|
4
|
+
--scenario ./check.ts` boots the app, waits for the first window to settle, runs the
|
|
5
|
+
scenario, then captures — so the screenshot in the report shows the state the run
|
|
6
|
+
ended in, including the state it failed in.
|
|
7
|
+
|
|
8
|
+
The file is loaded *before* the build, so a bad path fails in milliseconds.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { defineCheck } from "mirinjs/check";
|
|
12
|
+
|
|
13
|
+
export default defineCheck(async (app) => {
|
|
14
|
+
app.step("a name for this phase");
|
|
15
|
+
// …drive and assert; throw (or fail an assertion) to fail the check
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Two traps
|
|
20
|
+
|
|
21
|
+
Both produce a **passing check that verifies nothing**. Both were hit while building
|
|
22
|
+
this surface, on the first real scenario.
|
|
23
|
+
|
|
24
|
+
### 1. Assert on the thing that changed
|
|
25
|
+
|
|
26
|
+
`expectText` matches anywhere in the accessibility tree — including the input you
|
|
27
|
+
just typed into.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
await app.click("text=add a todo");
|
|
31
|
+
await app.type("ship it");
|
|
32
|
+
await app.click("text=Add");
|
|
33
|
+
await app.expectText("ship it"); // ✗ passes even when the mutation is broken:
|
|
34
|
+
// the text is still sitting in the input
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Assert against the thing the interaction was supposed to change:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
await app.waitUntil(
|
|
41
|
+
async () =>
|
|
42
|
+
(await app.evaluate<string[]>('[...document.querySelectorAll("li")].map((n) => n.textContent)'))
|
|
43
|
+
.includes("ship it"),
|
|
44
|
+
"the todo never reached the list",
|
|
45
|
+
);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### 2. Use `waitUntil`, not `assert`, after an interaction
|
|
49
|
+
|
|
50
|
+
An action returns as soon as the input is dispatched. The RPC round trip it
|
|
51
|
+
triggered has *not* landed yet, so a plain `assert` reads the previous state and
|
|
52
|
+
races.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
await app.click("text=Add");
|
|
56
|
+
const items = await app.evaluate<number>('document.querySelectorAll("li").length');
|
|
57
|
+
app.assert(items > 0, "…"); // ✗ races the round trip
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`waitUntil` polls (2s default) and only fails if the condition never holds.
|
|
61
|
+
`expectText` already polls for the same reason. `assert` is for facts that are
|
|
62
|
+
already true — a value you just read, a shape you already have.
|
|
63
|
+
|
|
64
|
+
## Driver API
|
|
65
|
+
|
|
66
|
+
Every method targets the only window unless you pass `{ window: id }`.
|
|
67
|
+
|
|
68
|
+
### Drive
|
|
69
|
+
|
|
70
|
+
| Method | Notes |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `click(selector, { clickCount?, window? })` | `clickCount: 3` selects a field's contents so `type` replaces them |
|
|
73
|
+
| `type(text, { selector?, window? })` | Focuses `selector` first when given |
|
|
74
|
+
| `key(name, { window? })` | `Enter`, `Escape`, `Tab`, … |
|
|
75
|
+
| `scroll({ deltaY?, deltaX?, window? })` | |
|
|
76
|
+
| `waitFor(selector, { timeoutMs?, window? })` | Waits for a selector to match |
|
|
77
|
+
| `sleep(ms)` | A guess — prefer `waitFor`/`waitUntil` |
|
|
78
|
+
|
|
79
|
+
Selectors are CSS **or** `text=Save`, which matches an element's accessible label:
|
|
80
|
+
trimmed text, `value`, `aria-label`, `placeholder`, or `title`. Prefer `text=` — it
|
|
81
|
+
matches the names `snapshot()` prints, so you never guess at class attributes.
|
|
82
|
+
|
|
83
|
+
Input is synthesized through the DevTools protocol, not `element.click()`, so a
|
|
84
|
+
scenario exercises the same event path a person's interaction would.
|
|
85
|
+
|
|
86
|
+
### Observe
|
|
87
|
+
|
|
88
|
+
| Method | Returns |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `snapshot({ format?, window? })` | Accessibility tree (default) or `format: "dom"` for HTML |
|
|
91
|
+
| `evaluate<T>(expression, { window? })` | The expression's value from the page |
|
|
92
|
+
| `screenshot(label?, { window? })` | Path to the PNG; `label` goes into the file name |
|
|
93
|
+
| `windows()` | `{ id, name, url, title }[]` — every window has a name |
|
|
94
|
+
| `exposed()` | Slices the app published with `devtools.expose` |
|
|
95
|
+
| `logs(query?)` | The event stream, filtered like the inspector's `/logs` |
|
|
96
|
+
| `cdp<T>(method, params?, { window? })` | Any DevTools-protocol command |
|
|
97
|
+
|
|
98
|
+
`logs` is how you assert on things the DOM cannot show:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const failed = await app.logs({ type: "rpc.error" });
|
|
102
|
+
app.assert(failed.length === 0, `${failed.length} RPC call(s) failed`);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
HTTP traffic is in the same stream, so a scenario can assert on what the app called
|
|
106
|
+
and what came back — including the body, fetched on demand:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const [call] = await app.logs({ type: "network.response", contains: "/api/todos" });
|
|
110
|
+
app.assert(call !== undefined, "the app never called /api/todos");
|
|
111
|
+
app.assert(call.data.status === 200, `/api/todos returned ${call.data.status}`);
|
|
112
|
+
|
|
113
|
+
const { body } = await app.cdp<{ body: string }>("Network.getResponseBody", {
|
|
114
|
+
requestId: call.data.requestId,
|
|
115
|
+
});
|
|
116
|
+
app.assert(JSON.parse(body).length > 0, "the API returned an empty list");
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Bodies are never captured into the stream (it is plaintext on disk), and headers and
|
|
120
|
+
URLs have credential values redacted — so `logs` will not hand you a token.
|
|
121
|
+
|
|
122
|
+
### Verify
|
|
123
|
+
|
|
124
|
+
| Method | Notes |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `assert(condition, message)` | Right now. Not after an interaction — see trap 2 |
|
|
127
|
+
| `waitUntil(condition, message, { timeoutMs? })` | Polls until truthy; a throwing condition counts as "not yet" |
|
|
128
|
+
| `expectText(text, { timeoutMs?, window? })` | Polls the a11y tree — see trap 1 |
|
|
129
|
+
|
|
130
|
+
### Report
|
|
131
|
+
|
|
132
|
+
`app.step(name)` names the phase. Steps appear in the report with pass/fail and a
|
|
133
|
+
duration, stream as `step` events in `--json`, and a failure names the step it
|
|
134
|
+
happened in:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
✓ typed query round-trips to the main process (21ms)
|
|
138
|
+
✗ typed mutation updates the list (11ms)
|
|
139
|
+
→ the todo never reached the list (waited 2000ms)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Worked examples
|
|
143
|
+
|
|
144
|
+
In the mirin repo: `examples/hello-react/check.ts` (typed query, mutation, push
|
|
145
|
+
events, no failed RPC) and `examples/kitchen-sink/check.ts` (app-shell state via
|
|
146
|
+
`exposed()`, opening a second window and checking it is addressable).
|
package/src/index.ts
CHANGED
|
@@ -16,8 +16,8 @@ try {
|
|
|
16
16
|
console.log(` cd ${target}`);
|
|
17
17
|
console.log(" bun install");
|
|
18
18
|
console.log(" bun run dev # launches a native window with HMR + typed RPC");
|
|
19
|
-
console.log(" bun run build # packages a standalone
|
|
20
|
-
console.log("Requires macOS arm64,
|
|
19
|
+
console.log(" bun run build # packages a standalone app\n");
|
|
20
|
+
console.log("Requires Bun on macOS arm64, Windows x64/arm64, or Linux x64/arm64.");
|
|
21
21
|
} catch (err) {
|
|
22
22
|
console.error(`create-mirinjs: ${err instanceof Error ? err.message : err}`);
|
|
23
23
|
process.exit(1);
|
package/src/scaffold.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
6
6
|
import {
|
|
7
7
|
cpSync,
|
|
8
8
|
existsSync,
|
|
9
|
-
readFileSync,
|
|
10
9
|
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
11
|
renameSync,
|
|
12
12
|
statSync,
|
|
13
13
|
writeFileSync,
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
+
import { installSkill } from "./skill.ts";
|
|
16
17
|
|
|
17
18
|
const TEMPLATE_DIR = join(import.meta.dir, "..", "template");
|
|
18
19
|
|
|
@@ -37,17 +38,23 @@ export function scaffold(targetDir: string, options: ScaffoldOptions = {}): stri
|
|
|
37
38
|
const ignore = join(targetDir, "_gitignore");
|
|
38
39
|
if (existsSync(ignore)) renameSync(ignore, join(targetDir, ".gitignore"));
|
|
39
40
|
|
|
40
|
-
const replacements
|
|
41
|
-
__APP_NAME__
|
|
42
|
-
__APP_ID__
|
|
43
|
-
__MIRIN_VERSION__
|
|
44
|
-
|
|
41
|
+
const replacements = [
|
|
42
|
+
["__APP_NAME__", appName],
|
|
43
|
+
["__APP_ID__", appId],
|
|
44
|
+
["__MIRIN_VERSION__", version],
|
|
45
|
+
] as const;
|
|
45
46
|
for (const file of walk(targetDir)) applyReplacements(file, replacements);
|
|
46
47
|
|
|
48
|
+
// After the replacement pass: the skill has no placeholders and must not be walked.
|
|
49
|
+
installSkill(targetDir);
|
|
50
|
+
|
|
47
51
|
return appName;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
function applyReplacements(
|
|
54
|
+
function applyReplacements(
|
|
55
|
+
file: string,
|
|
56
|
+
replacements: readonly (readonly [string, string])[],
|
|
57
|
+
): void {
|
|
51
58
|
let text: string;
|
|
52
59
|
try {
|
|
53
60
|
text = readFileSync(file, "utf8");
|
|
@@ -55,7 +62,7 @@ function applyReplacements(file: string, replacements: Record<string, string>):
|
|
|
55
62
|
return; // skip binary/unreadable files
|
|
56
63
|
}
|
|
57
64
|
let changed = text;
|
|
58
|
-
for (const [from, to] of
|
|
65
|
+
for (const [from, to] of replacements) {
|
|
59
66
|
changed = changed.split(from).join(to);
|
|
60
67
|
}
|
|
61
68
|
if (changed !== text) writeFileSync(file, changed);
|
package/src/skill.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install the mirin agent skill into a project. Ships in this package because both
|
|
3
|
+
* `bun create mirinjs` (no CLI) and `mirin skill`/`mirin init` need it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const SKILL_DIR = join(import.meta.dir, "..", "skill");
|
|
10
|
+
|
|
11
|
+
/** Where a Claude Code project skill lives. */
|
|
12
|
+
const INSTALL_PATH = join(".claude", "skills", "mirin");
|
|
13
|
+
|
|
14
|
+
export interface InstallSkillResult {
|
|
15
|
+
/** Absolute path the skill was written to. */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Relative to the project, for printing. */
|
|
18
|
+
relativePath: string;
|
|
19
|
+
replaced: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Copy the skill into `projectDir/.claude/skills/mirin`. An existing install is
|
|
23
|
+
* replaced wholesale, not merged: a stale reference file would read as current. */
|
|
24
|
+
export function installSkill(projectDir: string): InstallSkillResult {
|
|
25
|
+
if (!existsSync(SKILL_DIR)) {
|
|
26
|
+
throw new Error(`mirin skill assets are missing from the package (${SKILL_DIR})`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const path = join(projectDir, INSTALL_PATH);
|
|
30
|
+
const replaced = existsSync(path) && readdirSync(path).length > 0;
|
|
31
|
+
if (replaced) rmSync(path, { recursive: true, force: true });
|
|
32
|
+
|
|
33
|
+
mkdirSync(path, { recursive: true });
|
|
34
|
+
cpSync(SKILL_DIR, path, { recursive: true });
|
|
35
|
+
|
|
36
|
+
return { path, relativePath: INSTALL_PATH, replaced };
|
|
37
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# AGENTS.md — __APP_NAME__
|
|
2
|
+
|
|
3
|
+
Notes for AI coding tools (and anyone driving this app without a terminal in front
|
|
4
|
+
of them). This app is built with [mirin](https://github.com/Netko-Labs/mirin), which
|
|
5
|
+
ships a development observability surface so you can see what the app is doing
|
|
6
|
+
instead of guessing.
|
|
7
|
+
|
|
8
|
+
If your tool supports skills, the detail below is also installed as one at
|
|
9
|
+
`.claude/skills/mirin/`, loaded on demand rather than kept in context. Refresh it
|
|
10
|
+
after upgrading the CLI with `bunx mirin skill`.
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
bun install
|
|
16
|
+
bun run dev # native window with Vite HMR + typed RPC (long-lived, interactive)
|
|
17
|
+
bun run check # boot once, capture a screenshot + UI snapshot, report, exit
|
|
18
|
+
bun run doctor # check the project and environment without building
|
|
19
|
+
bun run build # standalone app in ./build
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Verifying a change
|
|
23
|
+
|
|
24
|
+
Prefer `bun run check` over `bun run dev`. It runs the same startup path but is
|
|
25
|
+
**not** interactive: it waits for a window, captures a screenshot and an
|
|
26
|
+
accessibility snapshot, collects errors, stops the app, and **exits non-zero if
|
|
27
|
+
anything went wrong**. Treat it like a test.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun run check # human summary
|
|
31
|
+
bun run check --json # one JSON object on stdout
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If it fails, `bun run doctor` names environmental causes (missing entry file, port
|
|
35
|
+
in use, unsupported platform) and reports how the previous run ended.
|
|
36
|
+
|
|
37
|
+
## Seeing what the app did
|
|
38
|
+
|
|
39
|
+
Every run writes a structured event stream to a gitignored session directory. It
|
|
40
|
+
contains main-process logs, renderer console output, uncaught exceptions with stack
|
|
41
|
+
traces, failed network requests, every RPC call, and native window events.
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# the newest session directory
|
|
45
|
+
SESSION=$(bun -e 'console.log(JSON.parse(await Bun.file(".mirin/dev/current.json").text()).session)')
|
|
46
|
+
|
|
47
|
+
tail -n 50 "$SESSION/events.jsonl" # the stream
|
|
48
|
+
cat "$SESSION/session.json" # startup phase timeline
|
|
49
|
+
cat "$SESSION/exit.json" # post-mortem, after the app exits
|
|
50
|
+
ls "$SESSION/screenshots" # captures
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
While the app is running, the same stream is queryable over a loopback HTTP
|
|
54
|
+
inspector, along with live window state, a screenshot endpoint, an accessibility
|
|
55
|
+
snapshot of the UI, expression evaluation, and synthetic input (click/type/key).
|
|
56
|
+
`bun run dev` prints its URL; the token is in `$SESSION/inspector.json`.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
PORT=$(bun -e 'console.log(JSON.parse(await Bun.file(process.argv[2]).text()).port)' "$SESSION/inspector.json")
|
|
60
|
+
TOKEN=$(bun -e 'console.log(JSON.parse(await Bun.file(process.argv[2]).text()).token)' "$SESSION/inspector.json")
|
|
61
|
+
|
|
62
|
+
curl -s "http://127.0.0.1:$PORT/state?token=$TOKEN" # windows, RPC routes, app state
|
|
63
|
+
curl -s "http://127.0.0.1:$PORT/logs?level=error&token=$TOKEN" # just the errors
|
|
64
|
+
curl -s "http://127.0.0.1:$PORT/snapshot?token=$TOKEN" # the UI as an accessibility tree
|
|
65
|
+
curl -s "http://127.0.0.1:$PORT/screenshot?token=$TOKEN" # returns a PNG path to read
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`GET /` lists every route. See mirin's `docs/agent-devtools.md` for the full surface.
|
|
69
|
+
|
|
70
|
+
This is a development surface only: it is disabled in packaged builds.
|
|
71
|
+
|
|
72
|
+
## Making app state visible
|
|
73
|
+
|
|
74
|
+
If you find yourself guessing at internal state, publish it — it then appears in
|
|
75
|
+
`/state` and in `mirin check` output:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { devtools } from "mirinjs";
|
|
79
|
+
|
|
80
|
+
devtools.expose("route", () => currentRoute);
|
|
81
|
+
devtools.event({ type: "order.submitted", data: { id } });
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Layout
|
|
85
|
+
|
|
86
|
+
- `mirin.config.ts` — the app manifest (windows, ids, devtools settings).
|
|
87
|
+
- `main/` — the Bun main process: RPC handlers, app lifecycle. Runs in a Worker.
|
|
88
|
+
- `ui/` — the React UI, served over the `app://` scheme in builds.
|
|
89
|
+
|
|
90
|
+
The UI and the main process talk over typed RPC, not HTTP. Add a procedure in
|
|
91
|
+
`main/rpc.ts` and call it from `ui/` through the generated client — don't reach for
|
|
92
|
+
`fetch` between them.
|
package/template/README.md
CHANGED
|
@@ -5,12 +5,17 @@ TypeScript + Chromium.
|
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
7
|
bun install
|
|
8
|
-
bun run dev
|
|
9
|
-
bun run
|
|
8
|
+
bun run dev # native window with Vite HMR + typed RPC
|
|
9
|
+
bun run check # boot once, capture a screenshot + UI snapshot, exit non-zero on errors
|
|
10
|
+
bun run doctor # check the project and environment without building
|
|
11
|
+
bun run build # standalone app in ./build
|
|
10
12
|
```
|
|
11
13
|
|
|
12
14
|
- `mirin.config.ts` — the app manifest (windows, ids).
|
|
13
15
|
- `main/` — the Bun main process (RPC handlers, app lifecycle).
|
|
14
16
|
- `ui/` — the React UI, served via the `app://` scheme in builds.
|
|
17
|
+
- `AGENTS.md` — how to inspect a running app (structured logs, screenshots, UI
|
|
18
|
+
snapshots), for AI coding tools and for you.
|
|
15
19
|
|
|
16
|
-
Requires macOS arm64,
|
|
20
|
+
Requires Bun on macOS arm64, Windows x64/arm64, or Linux x64/arm64. macOS
|
|
21
|
+
distribution builds also need the Xcode command-line tools for signing.
|
package/template/main/main.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import { app } from "mirinjs";
|
|
1
|
+
import { app, devtools } from "mirinjs";
|
|
2
2
|
import { router } from "./rpc.ts";
|
|
3
3
|
|
|
4
4
|
const mirin = app.serve(router);
|
|
5
5
|
|
|
6
|
+
let count = 0;
|
|
7
|
+
|
|
8
|
+
// Publish main-process state to the devtools inspector's `/state`. A tool driving
|
|
9
|
+
// this app can read the page's DOM, but nothing here — the tick counter lives in
|
|
10
|
+
// the Bun process. The getter runs per read, so this is always current.
|
|
11
|
+
// See docs/agent-devtools.md; delete it if you don't want the visibility.
|
|
12
|
+
devtools.expose("app", () => ({ ticks: count, windows: app.windows.all().length }));
|
|
13
|
+
|
|
6
14
|
app.on("ready", () => {
|
|
7
15
|
// Push a typed tick to every webview once a second.
|
|
8
|
-
let count = 0;
|
|
9
16
|
setInterval(() => {
|
|
10
17
|
mirin.rpc.tick.broadcast({ count: ++count });
|
|
11
18
|
}, 1000);
|
package/template/package.json
CHANGED