mirinjs 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 +3 -1
- package/src/app/facade.ts +27 -6
- package/src/check/define.ts +24 -0
- package/src/check/index.ts +2 -0
- package/src/check/types.ts +95 -0
- package/src/config/manifest.ts +46 -0
- package/src/devtools/devtools.ts +213 -0
- package/src/devtools/index.ts +11 -0
- package/src/devtools/lib/actions.ts +185 -0
- package/src/devtools/lib/ax.ts +139 -0
- package/src/devtools/lib/capture.ts +203 -0
- package/src/devtools/lib/cdp.ts +307 -0
- package/src/devtools/lib/http.ts +73 -0
- package/src/devtools/lib/inspector.ts +208 -0
- package/src/devtools/lib/network.ts +185 -0
- package/src/devtools/lib/parse.ts +49 -0
- package/src/devtools/lib/query.ts +102 -0
- package/src/devtools/lib/renderer-taps.ts +186 -0
- package/src/devtools/lib/routes-cdp.ts +280 -0
- package/src/devtools/lib/state.ts +85 -0
- package/src/devtools/lib/taps.ts +168 -0
- package/src/devtools/options.ts +109 -0
- package/src/devtools/session.ts +264 -0
- package/src/devtools/sink.ts +227 -0
- package/src/devtools/types.ts +88 -0
- package/src/host.ts +7 -0
- package/src/index.ts +14 -1
- package/src/logger.ts +23 -2
- package/src/rpc-server.ts +117 -4
- package/src/runtime.ts +30 -2
- package/src/shared/format.ts +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mirinjs",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.19",
|
|
4
4
|
"description": "Build desktop apps with Bun, TypeScript, and Chromium (CEF). The Bun-native Electron alternative.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"exports": {
|
|
25
25
|
".": "./src/index.ts",
|
|
26
26
|
"./config": "./src/config/index.ts",
|
|
27
|
+
"./check": "./src/check/index.ts",
|
|
28
|
+
"./devtools/session": "./src/devtools/session.ts",
|
|
27
29
|
"./rpc": "./src/rpc.ts",
|
|
28
30
|
"./client": "./src/client.ts",
|
|
29
31
|
"./codec": "./src/codec.ts",
|
package/src/app/facade.ts
CHANGED
|
@@ -26,19 +26,36 @@ import type {
|
|
|
26
26
|
/** A live, typed handle to an open window. */
|
|
27
27
|
export class WindowHandle extends Emitter<WindowEvents> {
|
|
28
28
|
readonly id: number;
|
|
29
|
-
|
|
29
|
+
/** Always set: a window opened without a name is given one from its id. */
|
|
30
|
+
readonly name: string;
|
|
31
|
+
#url: string;
|
|
32
|
+
#title: string | undefined;
|
|
30
33
|
|
|
31
|
-
constructor(id: number, name: string
|
|
34
|
+
constructor(id: number, name: string, url = "", title?: string) {
|
|
32
35
|
super();
|
|
33
36
|
this.id = id;
|
|
34
37
|
this.name = name;
|
|
38
|
+
this.#url = url;
|
|
39
|
+
this.#title = title;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The URL this window was last asked to load. */
|
|
43
|
+
get url(): string {
|
|
44
|
+
return this.#url;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The title this window was last given, if the app set one. */
|
|
48
|
+
get title(): string | undefined {
|
|
49
|
+
return this.#title;
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
async setTitle(title: string): Promise<void> {
|
|
53
|
+
this.#title = title;
|
|
38
54
|
runtime().core.windowSetTitle(this.id, title);
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
async loadUrl(url: string): Promise<void> {
|
|
58
|
+
this.#url = url;
|
|
42
59
|
runtime().core.windowLoadUrl(this.id, url);
|
|
43
60
|
}
|
|
44
61
|
|
|
@@ -159,21 +176,24 @@ class Windows {
|
|
|
159
176
|
|
|
160
177
|
async open(options: WindowOpenOptions | string): Promise<WindowHandle> {
|
|
161
178
|
const opts = typeof options === "string" ? manifestWindow(options) : options;
|
|
179
|
+
const url = resolveUrl(opts.url);
|
|
162
180
|
const id = runtime().core.windowCreate(
|
|
163
181
|
JSON.stringify({
|
|
164
182
|
...opts,
|
|
165
|
-
url
|
|
183
|
+
url,
|
|
166
184
|
material: normalizeMaterial(opts.material),
|
|
167
185
|
}),
|
|
168
186
|
);
|
|
169
|
-
|
|
187
|
+
// Fall back to an id-derived name so every window is addressable by name, not
|
|
188
|
+
// just the ones declared in the manifest.
|
|
189
|
+
const handle = new WindowHandle(id, opts.name ?? `window-${id}`, url, opts.title);
|
|
170
190
|
this.#register(handle);
|
|
171
191
|
return handle;
|
|
172
192
|
}
|
|
173
193
|
|
|
174
194
|
#register(handle: WindowHandle): void {
|
|
175
195
|
this.#byId.set(handle.id, handle);
|
|
176
|
-
|
|
196
|
+
this.#byName.set(handle.name, handle);
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
/** @internal */
|
|
@@ -185,7 +205,8 @@ class Windows {
|
|
|
185
205
|
const handle = this.#byId.get(id);
|
|
186
206
|
if (handle) {
|
|
187
207
|
this.#byId.delete(id);
|
|
188
|
-
if
|
|
208
|
+
// Only if it is still the binding: a later window may have taken the name.
|
|
209
|
+
if (this.#byName.get(handle.name) === handle) this.#byName.delete(handle.name);
|
|
189
210
|
}
|
|
190
211
|
}
|
|
191
212
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CheckScenario } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Declare a `mirin check` scenario. Default-export the result from the file you
|
|
5
|
+
* pass to `mirin check --scenario`.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* import { defineCheck } from "mirinjs/check";
|
|
9
|
+
*
|
|
10
|
+
* export default defineCheck(async (app) => {
|
|
11
|
+
* app.step("add a todo");
|
|
12
|
+
* await app.click("text=add a todo");
|
|
13
|
+
* await app.type("ship it");
|
|
14
|
+
* await app.click("text=Add");
|
|
15
|
+
* await app.expectText("ship it");
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Identity at runtime — its whole job is to type the callback, so `app` is
|
|
20
|
+
* inferred without the file having to annotate it.
|
|
21
|
+
*/
|
|
22
|
+
export function defineCheck(scenario: CheckScenario): CheckScenario {
|
|
23
|
+
return scenario;
|
|
24
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract a `mirin check` scenario is written against. Types live in the
|
|
3
|
+
* runtime package so an app can import them without the CLI's internals; the
|
|
4
|
+
* implementation lives in the CLI, which holds the inspector connection.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DevEvent, DevEventQuery } from "../devtools/types.ts";
|
|
8
|
+
|
|
9
|
+
/** A window as the scenario sees it. */
|
|
10
|
+
export interface CheckWindow {
|
|
11
|
+
id: number;
|
|
12
|
+
name: string;
|
|
13
|
+
url?: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Which window an action targets. Omit when the app has only one. */
|
|
18
|
+
export interface WindowTarget {
|
|
19
|
+
window?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The app under check. Every method talks to the running app's inspector.
|
|
24
|
+
* Anything that cannot be done rejects, and an unhandled rejection fails the check.
|
|
25
|
+
*/
|
|
26
|
+
export interface CheckDriver {
|
|
27
|
+
/** Click an element. Selectors accept CSS or `text=Save`. */
|
|
28
|
+
click(selector: string, options?: WindowTarget & { clickCount?: number }): Promise<void>;
|
|
29
|
+
/** Type text, optionally focusing `selector` first. */
|
|
30
|
+
type(text: string, options?: WindowTarget & { selector?: string }): Promise<void>;
|
|
31
|
+
/** Press a named key, e.g. `Enter`, `Escape`, `Tab`. */
|
|
32
|
+
key(name: string, options?: WindowTarget): Promise<void>;
|
|
33
|
+
scroll(options: WindowTarget & { deltaY?: number; deltaX?: number }): Promise<void>;
|
|
34
|
+
/** Wait for a selector to match, rejecting if it never does. */
|
|
35
|
+
waitFor(selector: string, options?: WindowTarget & { timeoutMs?: number }): Promise<void>;
|
|
36
|
+
/** Wait a fixed time. Prefer `waitFor` — a sleep is a guess. */
|
|
37
|
+
sleep(ms: number): Promise<void>;
|
|
38
|
+
|
|
39
|
+
/** The accessibility tree, or the DOM with `format: "dom"`. */
|
|
40
|
+
snapshot(options?: WindowTarget & { format?: "ax" | "dom" }): Promise<string>;
|
|
41
|
+
/** Evaluate an expression in the page and return its value. */
|
|
42
|
+
evaluate<T = unknown>(expression: string, options?: WindowTarget): Promise<T>;
|
|
43
|
+
/** Capture a screenshot into the session dir; returns its path. */
|
|
44
|
+
screenshot(label?: string, options?: WindowTarget): Promise<string>;
|
|
45
|
+
windows(): Promise<CheckWindow[]>;
|
|
46
|
+
/** Slices the app published with `devtools.expose`. */
|
|
47
|
+
exposed(): Promise<Record<string, unknown>>;
|
|
48
|
+
/** The event stream so far, filtered the same way as the inspector's `/logs`. */
|
|
49
|
+
logs(query?: DevEventQuery): Promise<DevEvent[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Send a raw DevTools-protocol command, for anything the methods above do not cover.
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* const [call] = await app.logs({ type: "network.response", contains: "/api/todos" });
|
|
55
|
+
* const body = await app.cdp<{ body: string }>("Network.getResponseBody", {
|
|
56
|
+
* requestId: call.data.requestId,
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
cdp<T = Record<string, unknown>>(
|
|
61
|
+
method: string,
|
|
62
|
+
params?: Record<string, unknown>,
|
|
63
|
+
options?: WindowTarget,
|
|
64
|
+
): Promise<T>;
|
|
65
|
+
|
|
66
|
+
/** Fail the check unless `condition` is truthy, right now. After an interaction,
|
|
67
|
+
* prefer `waitUntil` — the RPC round trip has not landed yet. */
|
|
68
|
+
assert(condition: unknown, message: string): asserts condition;
|
|
69
|
+
/**
|
|
70
|
+
* Poll `condition` until it holds, failing the check with `message` if it never
|
|
71
|
+
* does. The general form of `expectText`.
|
|
72
|
+
*
|
|
73
|
+
* ```ts
|
|
74
|
+
* await app.waitUntil(
|
|
75
|
+
* async () => (await app.evaluate<number>("todos.length")) > 0,
|
|
76
|
+
* "the todo never reached the list",
|
|
77
|
+
* );
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
waitUntil(
|
|
81
|
+
condition: () => Promise<unknown> | unknown,
|
|
82
|
+
message: string,
|
|
83
|
+
options?: { timeoutMs?: number },
|
|
84
|
+
): Promise<void>;
|
|
85
|
+
/** Fail the check unless the window's accessibility tree contains `text`. Polls
|
|
86
|
+
* until the timeout (2s by default): a single read would be flaky by construction. */
|
|
87
|
+
expectText(text: string, options?: WindowTarget & { timeoutMs?: number }): Promise<void>;
|
|
88
|
+
|
|
89
|
+
/** Name the phase the scenario is in. Steps appear in the report and the event
|
|
90
|
+
* stream, so a failure says which step it happened in. */
|
|
91
|
+
step(name: string): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A scenario: drive the app, throw to fail. */
|
|
95
|
+
export type CheckScenario = (app: CheckDriver) => Promise<void> | void;
|
package/src/config/manifest.ts
CHANGED
|
@@ -176,6 +176,52 @@ export interface MirinConfig {
|
|
|
176
176
|
* multiple instances (each gets its own CEF cache dir).
|
|
177
177
|
*/
|
|
178
178
|
singleInstance?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Agent-facing developer tooling: the structured event stream and the loopback
|
|
181
|
+
* inspector (docs/agent-devtools.md). Fully enabled under `mirin dev` and
|
|
182
|
+
* fully disabled in packaged builds; omit unless you need to change that.
|
|
183
|
+
*/
|
|
184
|
+
devtools?: DevtoolsConfig;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Developer/agent observability settings. Under `mirin dev` every capability
|
|
189
|
+
* defaults to on; in a packaged build everything is **off** and the switches are
|
|
190
|
+
* ignored unless {@link DevtoolsConfig.production} is `true` — the inspector can
|
|
191
|
+
* evaluate JavaScript and synthesize input, so enable it only in a diagnostic
|
|
192
|
+
* build you control.
|
|
193
|
+
*/
|
|
194
|
+
export interface DevtoolsConfig {
|
|
195
|
+
/** Master switch for the whole subsystem. Default true where permitted. */
|
|
196
|
+
enabled?: boolean;
|
|
197
|
+
/** Bind the loopback inspector HTTP/SSE server. Default true where permitted. */
|
|
198
|
+
inspector?: boolean;
|
|
199
|
+
/** Mirror the event stream to `.mirin/dev/<session>/events.jsonl`. Default true. */
|
|
200
|
+
file?: boolean;
|
|
201
|
+
/** Events kept in memory for `/logs`. Default 2000. */
|
|
202
|
+
bufferSize?: number;
|
|
203
|
+
/**
|
|
204
|
+
* Include RPC inputs and results in traces. Default false: procedure payloads
|
|
205
|
+
* carry app data, and the stream is written to disk in plain text.
|
|
206
|
+
*/
|
|
207
|
+
rpcPayloads?: boolean;
|
|
208
|
+
/**
|
|
209
|
+
* Record HTTP metadata in the stream (headers redacted; bodies never captured —
|
|
210
|
+
* fetch one via `Network.getResponseBody` through `POST /cdp`). Default true
|
|
211
|
+
* where permitted. `false` silences successful traffic only; failures and
|
|
212
|
+
* 4xx/5xx are always recorded, so `mirin check` keeps its teeth.
|
|
213
|
+
*/
|
|
214
|
+
network?: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Attach to CEF's remote-debugging port, enabling `/screenshot`, `/snapshot`,
|
|
217
|
+
* `/eval`, and `/act`. Default true where permitted.
|
|
218
|
+
*/
|
|
219
|
+
cdp?: boolean;
|
|
220
|
+
/**
|
|
221
|
+
* Permit devtools in a packaged (non-dev) build. Default false. Read the
|
|
222
|
+
* warning on this interface before setting it.
|
|
223
|
+
*/
|
|
224
|
+
production?: boolean;
|
|
179
225
|
}
|
|
180
226
|
|
|
181
227
|
/** A bundled sidecar binary with optional hardened-runtime entitlements. */
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mirin/devtools — the agent-facing observability facade (docs/agent-devtools.md).
|
|
3
|
+
* Collects every event source into one queryable stream, mirrors it to
|
|
4
|
+
* `.mirin/dev/<session>/events.jsonl`, and serves it over loopback.
|
|
5
|
+
*
|
|
6
|
+
* devtools.event({ type: "route.change", msg: "/settings" });
|
|
7
|
+
* devtools.expose("store", () => store.getState());
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DevtoolsConfig } from "../config/index.ts";
|
|
11
|
+
import { maybeRuntime, onNativeEvent } from "../runtime.ts";
|
|
12
|
+
import { CdpBridge } from "./lib/cdp.ts";
|
|
13
|
+
import { type InspectorHandle, startInspector } from "./lib/inspector.ts";
|
|
14
|
+
import { recordCdpEvent } from "./lib/renderer-taps.ts";
|
|
15
|
+
import { cdpRoutes } from "./lib/routes-cdp.ts";
|
|
16
|
+
import { installNativeTap, installProcessTaps } from "./lib/taps.ts";
|
|
17
|
+
import { devtoolsOptions, resolveDevtoolsOptions, setDevtoolsOptions } from "./options.ts";
|
|
18
|
+
import {
|
|
19
|
+
DEV_CDP_PORT_ENV,
|
|
20
|
+
DEV_SESSION_ENV,
|
|
21
|
+
type SessionPaths,
|
|
22
|
+
sessionPaths,
|
|
23
|
+
writeInspectorEndpoint,
|
|
24
|
+
} from "./session.ts";
|
|
25
|
+
import { record, sink } from "./sink.ts";
|
|
26
|
+
import type { AppDevEvent, DevEvent, DevEventQuery, InspectorEndpoint } from "./types.ts";
|
|
27
|
+
|
|
28
|
+
const exposed = new Map<string, () => unknown>();
|
|
29
|
+
|
|
30
|
+
let paths: SessionPaths | undefined;
|
|
31
|
+
let inspector: InspectorHandle | undefined;
|
|
32
|
+
let bridge: CdpBridge | undefined;
|
|
33
|
+
let teardown: (() => void)[] = [];
|
|
34
|
+
|
|
35
|
+
/** CEF's remote-debugging port for this run, when one was supplied. */
|
|
36
|
+
function cdpPort(): number | undefined {
|
|
37
|
+
const value = Number(process.env[DEV_CDP_PORT_ENV] ?? "");
|
|
38
|
+
return Number.isSafeInteger(value) && value >= 1024 && value <= 65535 ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A registered getter's current value, or an error marker if it threw. */
|
|
42
|
+
function readExposed(getter: () => unknown): unknown {
|
|
43
|
+
try {
|
|
44
|
+
return getter();
|
|
45
|
+
} catch (err) {
|
|
46
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const devtools = {
|
|
51
|
+
get enabled(): boolean {
|
|
52
|
+
return devtoolsOptions().enabled;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
get session(): SessionPaths | undefined {
|
|
56
|
+
return paths;
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
get inspectorUrl(): string | undefined {
|
|
60
|
+
return inspector !== undefined ? `http://127.0.0.1:${inspector.port}` : undefined;
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/** Publish an app-defined event into the stream. Always safe: with devtools
|
|
64
|
+
* disabled the record lands in the in-memory buffer only. */
|
|
65
|
+
event(event: AppDevEvent): void {
|
|
66
|
+
record({
|
|
67
|
+
src: "app",
|
|
68
|
+
level: event.level ?? "info",
|
|
69
|
+
type: event.type,
|
|
70
|
+
msg: event.msg ?? event.type,
|
|
71
|
+
...(event.window !== undefined ? { window: event.window } : {}),
|
|
72
|
+
...(event.data !== undefined ? { data: event.data } : {}),
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
/** Publish a named slice of app state under the inspector's `/state`. The getter
|
|
77
|
+
* runs on each read; one that throws is reported against its own key, not the request. */
|
|
78
|
+
expose(name: string, getter: () => unknown): void {
|
|
79
|
+
exposed.set(name, getter);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
unexpose(name: string): void {
|
|
83
|
+
exposed.delete(name);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
state(): Record<string, unknown> {
|
|
87
|
+
const out: Record<string, unknown> = {};
|
|
88
|
+
for (const [name, getter] of exposed) out[name] = readExposed(getter);
|
|
89
|
+
return out;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/** Query the in-memory event buffer. */
|
|
93
|
+
read(query: DevEventQuery = {}): DevEvent[] {
|
|
94
|
+
return sink.read(query);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Boot devtools; called from the package entry right after `boot()`. Taps install
|
|
99
|
+
* here, not at import time; nothing is missed — native events sit in the core's
|
|
100
|
+
* queue until the Worker's first poll. */
|
|
101
|
+
export function startDevtools(override?: DevtoolsConfig): void {
|
|
102
|
+
const runtime = maybeRuntime();
|
|
103
|
+
const options = resolveDevtoolsOptions(override ?? runtime?.devtools, runtime?.isDev ?? false);
|
|
104
|
+
setDevtoolsOptions(options);
|
|
105
|
+
if (!options.enabled) return;
|
|
106
|
+
|
|
107
|
+
sink.resize(options.bufferSize);
|
|
108
|
+
|
|
109
|
+
const dir = process.env[DEV_SESSION_ENV];
|
|
110
|
+
if (dir !== undefined && dir.length > 0) {
|
|
111
|
+
paths = sessionPaths(dir);
|
|
112
|
+
if (options.file) sink.openFile(paths.events);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const port = options.cdp ? cdpPort() : undefined;
|
|
116
|
+
// Local binding: route closures must capture a definite bridge, not the
|
|
117
|
+
// reassignable module-level slot.
|
|
118
|
+
const attached = port !== undefined ? startCdpBridge(port) : undefined;
|
|
119
|
+
bridge = attached;
|
|
120
|
+
|
|
121
|
+
// `cdpCovers` is read per event, not captured now: CDP attachment completes later.
|
|
122
|
+
teardown.push(
|
|
123
|
+
installNativeTap({
|
|
124
|
+
cdpCovers: (window) =>
|
|
125
|
+
window !== undefined && (attached?.attachedWindows().includes(window) ?? false),
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
teardown.push(installProcessTaps());
|
|
129
|
+
const screenshotDir = paths?.screenshots;
|
|
130
|
+
|
|
131
|
+
inspector = startInspector({
|
|
132
|
+
exposed: () => devtools.state(),
|
|
133
|
+
...(attached !== undefined
|
|
134
|
+
? {
|
|
135
|
+
extraRoutes: () =>
|
|
136
|
+
cdpRoutes({
|
|
137
|
+
bridge: attached,
|
|
138
|
+
...(screenshotDir !== undefined ? { screenshotDir } : {}),
|
|
139
|
+
}),
|
|
140
|
+
}
|
|
141
|
+
: {}),
|
|
142
|
+
});
|
|
143
|
+
if (inspector !== undefined) {
|
|
144
|
+
publishInspectorEndpoint({
|
|
145
|
+
version: 1,
|
|
146
|
+
port: inspector.port,
|
|
147
|
+
token: inspector.token,
|
|
148
|
+
pid: process.pid,
|
|
149
|
+
startedAt: Date.now(),
|
|
150
|
+
...(port !== undefined ? { cdpPort: port } : {}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
record({
|
|
155
|
+
src: "main",
|
|
156
|
+
level: "info",
|
|
157
|
+
type: "devtools.ready",
|
|
158
|
+
msg: "devtools attached",
|
|
159
|
+
data: {
|
|
160
|
+
file: sink.filePath ?? null,
|
|
161
|
+
inspector: inspector !== undefined ? `http://127.0.0.1:${inspector.port}` : null,
|
|
162
|
+
cdpPort: port ?? null,
|
|
163
|
+
bufferSize: options.bufferSize,
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Create the CDP bridge and start attaching. Attachment is async and not awaited:
|
|
169
|
+
* CEF binds its debugging port after the Worker is already running, and app
|
|
170
|
+
* startup must not wait on diagnostics. */
|
|
171
|
+
function startCdpBridge(port: number): CdpBridge {
|
|
172
|
+
const created = new CdpBridge(port, recordCdpEvent);
|
|
173
|
+
teardown.push(onNativeEvent("window.created", () => void created.refresh(true)));
|
|
174
|
+
|
|
175
|
+
void created.attachWhenReady().then((attached) => {
|
|
176
|
+
record({
|
|
177
|
+
src: "main",
|
|
178
|
+
level: attached ? "info" : "warn",
|
|
179
|
+
type: attached ? "devtools.cdp-attached" : "devtools.cdp-unavailable",
|
|
180
|
+
msg: attached
|
|
181
|
+
? `attached to the DevTools protocol on port ${port}`
|
|
182
|
+
: `no webview attached on DevTools port ${port} — screenshots and snapshots are unavailable`,
|
|
183
|
+
data: { port, windows: created.attachedWindows() },
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return created;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function publishInspectorEndpoint(endpoint: InspectorEndpoint): void {
|
|
191
|
+
if (paths === undefined) return;
|
|
192
|
+
try {
|
|
193
|
+
writeInspectorEndpoint(paths, endpoint);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
record({
|
|
196
|
+
src: "main",
|
|
197
|
+
level: "warn",
|
|
198
|
+
type: "devtools.error",
|
|
199
|
+
msg: `could not publish inspector endpoint: ${err instanceof Error ? err.message : err}`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Flush and detach. Called on Worker shutdown paths. */
|
|
205
|
+
export function stopDevtools(): void {
|
|
206
|
+
for (const off of teardown) off();
|
|
207
|
+
teardown = [];
|
|
208
|
+
void inspector?.stop();
|
|
209
|
+
inspector = undefined;
|
|
210
|
+
bridge?.close();
|
|
211
|
+
bridge = undefined;
|
|
212
|
+
sink.close();
|
|
213
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { devtools, publishInspectorEndpoint, startDevtools, stopDevtools } from "./devtools.ts";
|
|
2
|
+
export type { DevtoolsOptions } from "./options.ts";
|
|
3
|
+
export { devtoolsOptions } from "./options.ts";
|
|
4
|
+
export type {
|
|
5
|
+
AppDevEvent,
|
|
6
|
+
DevEvent,
|
|
7
|
+
DevEventLevel,
|
|
8
|
+
DevEventQuery,
|
|
9
|
+
DevEventSource,
|
|
10
|
+
InspectorEndpoint,
|
|
11
|
+
} from "./types.ts";
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic input over CDP (docs/agent-devtools.md). `text=Save` selectors match
|
|
3
|
+
* labels, since the AX snapshot a reader works from has no class attributes; input
|
|
4
|
+
* goes through `Input.dispatch*Event`, not `element.click()`, so a real event
|
|
5
|
+
* sequence is exercised. Selectors are untrusted and are always embedded with
|
|
6
|
+
* `JSON.stringify`, never concatenated into the script.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { asBoolean, asNumber, asRecord, asString } from "./parse.ts";
|
|
10
|
+
|
|
11
|
+
/** Keys worth naming. Printable characters go through `type` instead. */
|
|
12
|
+
const KEYS: Record<string, { code: string; keyCode: number; text?: string }> = {
|
|
13
|
+
Enter: { code: "Enter", keyCode: 13, text: "\r" },
|
|
14
|
+
Tab: { code: "Tab", keyCode: 9 },
|
|
15
|
+
Escape: { code: "Escape", keyCode: 27 },
|
|
16
|
+
Backspace: { code: "Backspace", keyCode: 8 },
|
|
17
|
+
Delete: { code: "Delete", keyCode: 46 },
|
|
18
|
+
ArrowLeft: { code: "ArrowLeft", keyCode: 37 },
|
|
19
|
+
ArrowUp: { code: "ArrowUp", keyCode: 38 },
|
|
20
|
+
ArrowRight: { code: "ArrowRight", keyCode: 39 },
|
|
21
|
+
ArrowDown: { code: "ArrowDown", keyCode: 40 },
|
|
22
|
+
Home: { code: "Home", keyCode: 36 },
|
|
23
|
+
End: { code: "End", keyCode: 35 },
|
|
24
|
+
PageUp: { code: "PageUp", keyCode: 33 },
|
|
25
|
+
PageDown: { code: "PageDown", keyCode: 34 },
|
|
26
|
+
Space: { code: "Space", keyCode: 32, text: " " },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const KEY_NAMES: string[] = Object.keys(KEYS);
|
|
30
|
+
|
|
31
|
+
/** Sends a CDP command for one page. Supplied by the bridge. */
|
|
32
|
+
export type Send = (
|
|
33
|
+
method: string,
|
|
34
|
+
params?: Record<string, unknown>,
|
|
35
|
+
) => Promise<Record<string, unknown>>;
|
|
36
|
+
|
|
37
|
+
export interface Located {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
tag: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The page script resolving a selector to a click point. `text=…` matches an
|
|
44
|
+
* element's label (text, `value`, `aria-label`, `placeholder`, `title`), exact
|
|
45
|
+
* first, then deepest, so a wrapper never wins over the button inside it. */
|
|
46
|
+
export function locateScript(selector: string): string {
|
|
47
|
+
const text = selector.startsWith("text=") ? selector.slice(5) : undefined;
|
|
48
|
+
const css = selector.startsWith("css=") ? selector.slice(4) : selector;
|
|
49
|
+
|
|
50
|
+
const find =
|
|
51
|
+
text !== undefined
|
|
52
|
+
? `(() => {
|
|
53
|
+
const want = ${JSON.stringify(text)}.trim().toLowerCase();
|
|
54
|
+
const label = (el) => (
|
|
55
|
+
el.getAttribute?.("aria-label") ||
|
|
56
|
+
el.getAttribute?.("placeholder") ||
|
|
57
|
+
el.getAttribute?.("title") ||
|
|
58
|
+
(el.value != null && typeof el.value === "string" ? el.value : "") ||
|
|
59
|
+
el.textContent || ""
|
|
60
|
+
).trim().toLowerCase();
|
|
61
|
+
const all = Array.from(document.querySelectorAll("*"))
|
|
62
|
+
.filter((el) => !["SCRIPT","STYLE","HEAD","META","LINK","TITLE"].includes(el.tagName));
|
|
63
|
+
const exact = all.filter((el) => label(el) === want);
|
|
64
|
+
const partial = exact.length ? [] : all.filter((el) => label(el).includes(want));
|
|
65
|
+
const pool = exact.length ? exact : partial;
|
|
66
|
+
// Deepest candidate: the innermost element still carrying the label.
|
|
67
|
+
return pool.sort((a, b) => b.querySelectorAll("*").length - a.querySelectorAll("*").length).pop() || null;
|
|
68
|
+
})()`
|
|
69
|
+
: `document.querySelector(${JSON.stringify(css)})`;
|
|
70
|
+
|
|
71
|
+
return `(() => {
|
|
72
|
+
const el = ${find};
|
|
73
|
+
if (!el) return { found: false };
|
|
74
|
+
if (el.scrollIntoView) el.scrollIntoView({ block: "center", inline: "center" });
|
|
75
|
+
const r = el.getBoundingClientRect();
|
|
76
|
+
if (r.width === 0 && r.height === 0) return { found: true, visible: false, tag: el.tagName.toLowerCase() };
|
|
77
|
+
return {
|
|
78
|
+
found: true,
|
|
79
|
+
visible: true,
|
|
80
|
+
x: r.left + r.width / 2,
|
|
81
|
+
y: r.top + r.height / 2,
|
|
82
|
+
tag: el.tagName.toLowerCase(),
|
|
83
|
+
};
|
|
84
|
+
})()`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class ActionError extends Error {
|
|
88
|
+
constructor(message: string) {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = "ActionError";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Resolve a selector to a click point in the page's viewport. */
|
|
95
|
+
export async function locate(send: Send, selector: string): Promise<Located> {
|
|
96
|
+
const result = await send("Runtime.evaluate", {
|
|
97
|
+
expression: locateScript(selector),
|
|
98
|
+
returnByValue: true,
|
|
99
|
+
});
|
|
100
|
+
const value = asRecord(asRecord(result.result)?.value);
|
|
101
|
+
if (asBoolean(value?.found) !== true) {
|
|
102
|
+
throw new ActionError(`no element matched ${selector}`);
|
|
103
|
+
}
|
|
104
|
+
if (asBoolean(value?.visible) !== true) {
|
|
105
|
+
throw new ActionError(`element matched ${selector} but has no visible box`);
|
|
106
|
+
}
|
|
107
|
+
const x = asNumber(value?.x);
|
|
108
|
+
const y = asNumber(value?.y);
|
|
109
|
+
if (x === undefined || y === undefined) {
|
|
110
|
+
throw new ActionError(`element matched ${selector} but has no position`);
|
|
111
|
+
}
|
|
112
|
+
return { x, y, tag: asString(value?.tag) ?? "" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Press and release the left button at a selector's center. */
|
|
116
|
+
export async function click(send: Send, selector: string, clickCount = 1): Promise<Located> {
|
|
117
|
+
const at = await locate(send, selector);
|
|
118
|
+
const base = { x: at.x, y: at.y, button: "left", clickCount, buttons: 1 };
|
|
119
|
+
await send("Input.dispatchMouseEvent", { type: "mousePressed", ...base });
|
|
120
|
+
await send("Input.dispatchMouseEvent", { type: "mouseReleased", ...base });
|
|
121
|
+
return at;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Insert text as if typed, into the selector's element when one is given. Focus
|
|
125
|
+
* comes from a real click, not `el.focus()`, so the field sees real events. */
|
|
126
|
+
export async function type(send: Send, text: string, selector?: string): Promise<void> {
|
|
127
|
+
if (selector !== undefined) await click(send, selector);
|
|
128
|
+
// insertText goes through the same IME path as real typing and handles the whole
|
|
129
|
+
// string at once, which is both faster and less flaky than per-character events.
|
|
130
|
+
await send("Input.insertText", { text });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Press a named key (see {@link KEY_NAMES}). */
|
|
134
|
+
export async function key(send: Send, name: string): Promise<void> {
|
|
135
|
+
const spec = KEYS[name];
|
|
136
|
+
if (spec === undefined) {
|
|
137
|
+
throw new ActionError(`unknown key "${name}" — try one of: ${KEY_NAMES.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
const base = {
|
|
140
|
+
key: name === "Space" ? " " : name,
|
|
141
|
+
code: spec.code,
|
|
142
|
+
windowsVirtualKeyCode: spec.keyCode,
|
|
143
|
+
nativeVirtualKeyCode: spec.keyCode,
|
|
144
|
+
...(spec.text !== undefined ? { text: spec.text } : {}),
|
|
145
|
+
};
|
|
146
|
+
// Printable keys need `keyDown` to deliver their text; the rest use rawKeyDown.
|
|
147
|
+
await send("Input.dispatchKeyEvent", {
|
|
148
|
+
type: spec.text !== undefined ? "keyDown" : "rawKeyDown",
|
|
149
|
+
...base,
|
|
150
|
+
});
|
|
151
|
+
await send("Input.dispatchKeyEvent", { type: "keyUp", ...base });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Scroll by a wheel delta, at a selector's center or the viewport's top-left. */
|
|
155
|
+
export async function scroll(
|
|
156
|
+
send: Send,
|
|
157
|
+
deltaY: number,
|
|
158
|
+
deltaX = 0,
|
|
159
|
+
selector?: string,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const at = selector !== undefined ? await locate(send, selector) : { x: 10, y: 10 };
|
|
162
|
+
await send("Input.dispatchMouseEvent", {
|
|
163
|
+
type: "mouseWheel",
|
|
164
|
+
x: at.x,
|
|
165
|
+
y: at.y,
|
|
166
|
+
deltaX,
|
|
167
|
+
deltaY,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Poll until a selector matches, or throw when the deadline passes. */
|
|
172
|
+
export async function waitFor(send: Send, selector: string, timeoutMs: number): Promise<void> {
|
|
173
|
+
const deadline = Date.now() + timeoutMs;
|
|
174
|
+
let lastError = `no element matched ${selector}`;
|
|
175
|
+
while (Date.now() < deadline) {
|
|
176
|
+
try {
|
|
177
|
+
await locate(send, selector);
|
|
178
|
+
return;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
181
|
+
}
|
|
182
|
+
await Bun.sleep(100);
|
|
183
|
+
}
|
|
184
|
+
throw new ActionError(`timed out after ${timeoutMs}ms: ${lastError}`);
|
|
185
|
+
}
|