haltija 1.3.0-beta.9 → 1.3.1
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/README.md +46 -13
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +137 -56
- package/bin/cli-subcommand.mjs +152 -5
- package/bin/hints.json +1 -1
- package/bin/hj.mjs +102 -2
- package/dist/agent-message-format.d.ts +56 -0
- package/dist/agent-shell.d.ts +161 -0
- package/dist/api-handlers.d.ts +107 -0
- package/dist/api-router.d.ts +29 -0
- package/dist/api-schema.d.ts +894 -0
- package/dist/bookmarklet.d.ts +21 -0
- package/dist/browser-tests.d.ts +49 -0
- package/dist/client.d.ts +168 -0
- package/dist/codemirror-bundle.d.ts +16 -0
- package/dist/component.d.ts +403 -0
- package/dist/component.esm.js +7545 -0
- package/dist/component.js +137 -56
- package/dist/embedded-assets.d.ts +18 -0
- package/dist/hj.js +235 -5
- package/dist/index.d.ts +46 -0
- package/dist/index.js +7947 -227
- package/dist/key-codes.d.ts +12 -0
- package/dist/mcp-config.d.ts +71 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +7947 -227
- package/dist/sessions.d.ts +49 -0
- package/dist/task-board.d.ts +45 -0
- package/dist/tasks.d.ts +77 -0
- package/dist/terminal.d.ts +154 -0
- package/dist/test-data.d.ts +37 -0
- package/dist/test-formatters.d.ts +68 -0
- package/dist/test-generator.d.ts +37 -0
- package/dist/test-page.d.ts +8 -0
- package/dist/test.d.ts +136 -0
- package/dist/text-selector.d.ts +30 -0
- package/dist/types.d.ts +1054 -0
- package/dist/version.d.ts +11 -0
- package/llms.txt +60 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -61,18 +61,44 @@ Full API: `hj docs` — or `hj api` for complete reference
|
|
|
61
61
|
|
|
62
62
|
---
|
|
63
63
|
|
|
64
|
-
##
|
|
64
|
+
## Haltija vs. Playwright
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|---|---------|----------------|
|
|
68
|
-
| **Browser** | Your real browser | Separate headless instance |
|
|
69
|
-
| **State** | Already logged in, cookies, extensions | Clean slate every time |
|
|
70
|
-
| **Setup** | `bunx haltija` | Install Playwright, configure MCP |
|
|
71
|
-
| **Protocol** | Simple REST/curl | Complex CDP |
|
|
72
|
-
| **Feedback** | "Button hidden by modal" | `TimeoutError: element not found` |
|
|
73
|
-
| **Visibility** | Watch it happen live | Background process |
|
|
66
|
+
Playwright is a mature, cross-browser automation framework with deep tooling (trace viewer, codegen, test runner, fixtures) and an official MCP that hands agents a structured accessibility snapshot. Reach for it when you want cross-browser coverage or a full test framework. Haltija aims at something narrower: letting an agent drive **a browser you're already running**, over plain HTTP — or spawn its own when you'd rather it did.
|
|
74
67
|
|
|
75
|
-
|
|
68
|
+
| | Haltija | Playwright (+ MCP) |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| Browser | Attach to your real, already-open browser — *or* spawn its own | Usually its own managed context; can connect to an existing browser over CDP |
|
|
71
|
+
| Cross-browser | ⚠️ Chromium only | ✅ Chromium, Firefox, WebKit |
|
|
72
|
+
| Session / auth | Uses your live logged-in session as-is | Fresh context by default; reusable via `storageState` / persistent context |
|
|
73
|
+
| How an agent drives it | Plain HTTP/REST — curl, any language, no client library | Playwright client library, or the MCP tools |
|
|
74
|
+
| Page model | DOM tree with stable ref IDs + `eval` | Accessibility-tree snapshot with ref IDs (MCP), or the full API in code |
|
|
75
|
+
| Interaction | Per-character, framework-triggering synthetic events you can steer live over HTTP | Trusted CDP-level input |
|
|
76
|
+
| Feedback when a step can't proceed | **Fails fast** — reports what's blocking it (missing / hidden / covered) right away | **Auto-waits to a timeout** (tens of seconds) — smooths over races, but a genuinely broken step burns the full timeout before failing |
|
|
77
|
+
| Test generation | record→replay → JSON tests | codegen → Playwright code |
|
|
78
|
+
| Debugging | Live widget, console capture, semantic events, click diff | Trace viewer — time-travel step snapshots |
|
|
79
|
+
| Embed in your app | ✅ Ship the widget inside your own product | Not designed for that |
|
|
80
|
+
|
|
81
|
+
**The short version:** they're complementary, and this project uses both — Playwright for cross-browser smoke checks, Haltija for the fast end-to-end pass. Reach for Haltija when you want to point an agent at the browser (and logged-in session) you already have, or embed agent-control into your own app — over nothing more than HTTP.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Haltija vs. Claude in Chrome
|
|
86
|
+
|
|
87
|
+
Both let an agent drive a browser — they're built for different jobs. Because Haltija can either **spawn its own isolated browser** *or* **attach to your real one** (via a script tag / bookmarklet on a port you choose), it covers the axes a live-session-only tool can't.
|
|
88
|
+
|
|
89
|
+
| Axis | Haltija | Claude in Chrome |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| Setup / auth | ✅ `bunx haltija` — zero auth, self-spawns | ⚠️ Extension + claude.ai login + per-site permissions |
|
|
92
|
+
| How the agent invokes it | ✅ Plain `hj` CLI / REST — always available in a shell | ⚠️ MCP tools; must be loaded + extension connected |
|
|
93
|
+
| Project integration | ✅ *Is* your test harness — JSON tests (`hj test-suite`), record→replay, CI | ❌ None |
|
|
94
|
+
| Determinism / cost | ✅ DOM + `eval` with stable ref IDs — cheap text, scriptable, headless-CI-able | ⚠️ Has structured reads (page text, find-by-text) *and* vision/coordinate actions; the vision path is token-heavy and coordinate clicks less deterministic |
|
|
95
|
+
| Isolation | ✅ Own instance on its own port — never touches your browsing | ❌ Drives tabs in your live session |
|
|
96
|
+
| Real-browser fidelity (WebGL/3D, pixels) | ✅ Attach to a real GPU Chrome or the desktop app *(headless GPU is the one weak spot)* | ✅ Full real Chrome GPU |
|
|
97
|
+
| Authenticated / external sites | ✅ Inject into your logged-in browser on a chosen port *(one-time setup step)* | ✅ Native — already your logged-in profile |
|
|
98
|
+
| Flakiness / contention | ✅ A private port per project = dedicated instance *(a shared instance can still flake under contention)* | ⚠️ Depends on live browser + extension state |
|
|
99
|
+
| Screenshots | ✅ Native (desktop) or WebRTC `getDisplayMedia` (browser) | ✅ Vision-native |
|
|
100
|
+
|
|
101
|
+
**The short version:** reach for Claude in Chrome to glance at the tab you're already looking at; reach for Haltija when you want a browser your agent *controls* — reproducibly, cheaply, in CI, and as the regression harness your project already has.
|
|
76
102
|
|
|
77
103
|
---
|
|
78
104
|
|
|
@@ -246,18 +272,25 @@ The server rejects every REST/WebSocket request without a matching `X-Haltija-To
|
|
|
246
272
|
## Installation
|
|
247
273
|
|
|
248
274
|
```bash
|
|
249
|
-
bunx haltija #
|
|
250
|
-
bunx haltija --server # Server only
|
|
275
|
+
bunx haltija # Bundled browser + embedded server (easiest start)
|
|
276
|
+
bunx haltija --server # Server only — your browser, per-project dev/debug
|
|
251
277
|
npm install -g haltija # Install globally
|
|
252
278
|
|
|
253
279
|
# Server options
|
|
254
280
|
haltija --https # HTTPS mode
|
|
255
281
|
haltija --port 3000 # Custom port
|
|
282
|
+
haltija --name <proj> # Register a per-project instance (hj --name <proj>)
|
|
256
283
|
haltija --token <secret> # Require X-Haltija-Token on every request
|
|
257
|
-
haltija --headless # For CI pipelines
|
|
284
|
+
haltija --headless # For CI pipelines (no desktop app needed)
|
|
258
285
|
haltija --setup-mcp # Configure Claude Desktop
|
|
259
286
|
```
|
|
260
287
|
|
|
288
|
+
**Which mode?** For day-to-day dev/debugging, run a per-project server
|
|
289
|
+
(`haltija --server --name <proj>`) and drive it with the `hj` CLI or your coding
|
|
290
|
+
agent — that's the paved path. For CI, `haltija --headless` runs a deterministic
|
|
291
|
+
headless Chromium with no desktop app required. A downloadable notarized desktop
|
|
292
|
+
app also exists but isn't required for either.
|
|
293
|
+
|
|
261
294
|
---
|
|
262
295
|
|
|
263
296
|
## Security
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.3.
|
|
49
|
+
var VERSION = "1.3.1";
|
|
50
50
|
|
|
51
51
|
// src/text-selector.ts
|
|
52
52
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
|
|
@@ -98,6 +98,47 @@
|
|
|
98
98
|
return lower.includes(parsed.searchText);
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// src/key-codes.ts
|
|
102
|
+
function keyToCode(key) {
|
|
103
|
+
const specialKeys = {
|
|
104
|
+
Enter: "Enter",
|
|
105
|
+
Escape: "Escape",
|
|
106
|
+
Tab: "Tab",
|
|
107
|
+
Backspace: "Backspace",
|
|
108
|
+
Delete: "Delete",
|
|
109
|
+
ArrowUp: "ArrowUp",
|
|
110
|
+
ArrowDown: "ArrowDown",
|
|
111
|
+
ArrowLeft: "ArrowLeft",
|
|
112
|
+
ArrowRight: "ArrowRight",
|
|
113
|
+
Home: "Home",
|
|
114
|
+
End: "End",
|
|
115
|
+
PageUp: "PageUp",
|
|
116
|
+
PageDown: "PageDown",
|
|
117
|
+
" ": "Space",
|
|
118
|
+
Space: "Space",
|
|
119
|
+
".": "Period",
|
|
120
|
+
",": "Comma",
|
|
121
|
+
"/": "Slash",
|
|
122
|
+
";": "Semicolon",
|
|
123
|
+
"'": "Quote",
|
|
124
|
+
"[": "BracketLeft",
|
|
125
|
+
"]": "BracketRight",
|
|
126
|
+
"\\": "Backslash",
|
|
127
|
+
"-": "Minus",
|
|
128
|
+
"=": "Equal",
|
|
129
|
+
"`": "Backquote"
|
|
130
|
+
};
|
|
131
|
+
if (specialKeys[key])
|
|
132
|
+
return specialKeys[key];
|
|
133
|
+
if (/^F\d{1,2}$/.test(key))
|
|
134
|
+
return key;
|
|
135
|
+
if (/^[a-zA-Z]$/.test(key))
|
|
136
|
+
return `Key${key.toUpperCase()}`;
|
|
137
|
+
if (/^[0-9]$/.test(key))
|
|
138
|
+
return `Digit${key}`;
|
|
139
|
+
return key;
|
|
140
|
+
}
|
|
141
|
+
|
|
101
142
|
// src/component.ts
|
|
102
143
|
var VERSION2 = VERSION;
|
|
103
144
|
var PRODUCT_NAME = "Haltija";
|
|
@@ -1435,7 +1476,7 @@
|
|
|
1435
1476
|
if (htmlEl.disabled) {
|
|
1436
1477
|
flags.disabled = true;
|
|
1437
1478
|
}
|
|
1438
|
-
if (htmlEl.readOnly) {
|
|
1479
|
+
if ("readOnly" in htmlEl && htmlEl.readOnly) {
|
|
1439
1480
|
flags.readOnly = true;
|
|
1440
1481
|
}
|
|
1441
1482
|
if (!htmlEl.validity.valid) {
|
|
@@ -1717,6 +1758,8 @@
|
|
|
1717
1758
|
isElectron = false;
|
|
1718
1759
|
browserId = uid();
|
|
1719
1760
|
killed = false;
|
|
1761
|
+
displayStream = null;
|
|
1762
|
+
displayVideo = null;
|
|
1720
1763
|
isActive = true;
|
|
1721
1764
|
homeLeft = 0;
|
|
1722
1765
|
homeBottom = 16;
|
|
@@ -1918,6 +1961,7 @@
|
|
|
1918
1961
|
this.restoreDialogs();
|
|
1919
1962
|
this.clearEventWatchers();
|
|
1920
1963
|
this.stopMutationWatch();
|
|
1964
|
+
this.stopScreenCapture();
|
|
1921
1965
|
}
|
|
1922
1966
|
attributeChangedCallback(name, _old, value) {
|
|
1923
1967
|
if (name === "server") {
|
|
@@ -2085,6 +2129,10 @@
|
|
|
2085
2129
|
color: white;
|
|
2086
2130
|
animation: pulse 1s infinite;
|
|
2087
2131
|
}
|
|
2132
|
+
.btn[data-action="screen"].sharing {
|
|
2133
|
+
background: #3b82f6;
|
|
2134
|
+
color: white;
|
|
2135
|
+
}
|
|
2088
2136
|
.btn[data-action="logs"] {
|
|
2089
2137
|
font-size: 10px;
|
|
2090
2138
|
font-weight: 500;
|
|
@@ -2485,6 +2533,7 @@
|
|
|
2485
2533
|
<div class="controls">
|
|
2486
2534
|
<button class="btn" data-action="select" title="Click or drag to select elements" aria-label="Select elements">\uD83D\uDC46</button>
|
|
2487
2535
|
<button class="btn" data-action="record" title="Record test (click to start/stop)" aria-label="Record test">REC</button>
|
|
2536
|
+
<button class="btn" data-action="screen" title="Share screen so the agent can take screenshots (browser only; not needed in Haltija desktop app)" aria-label="Share screen for screenshots">\uD83D\uDDA5</button>
|
|
2488
2537
|
<button class="btn" data-action="logs" title="Show event log panel" aria-label="Toggle event log">LOG</button>
|
|
2489
2538
|
<button class="btn info-btn" data-action="stats" title="Copy stats to clipboard" aria-label="Copy stats">i</button>
|
|
2490
2539
|
<button class="btn" data-action="minimize" title="Minimize widget (⌥Tab)" aria-label="Minimize">─</button>
|
|
@@ -2566,6 +2615,8 @@
|
|
|
2566
2615
|
this.toggleRecording();
|
|
2567
2616
|
if (action2 === "select")
|
|
2568
2617
|
this.startSelection();
|
|
2618
|
+
if (action2 === "screen")
|
|
2619
|
+
this.toggleScreenCapture();
|
|
2569
2620
|
if (action2 === "stats")
|
|
2570
2621
|
this.copyStatsToClipboard();
|
|
2571
2622
|
if (action2 === "close-modal")
|
|
@@ -3016,7 +3067,7 @@
|
|
|
3016
3067
|
if (options.addAssertions && inputValue) {
|
|
3017
3068
|
steps.push({
|
|
3018
3069
|
action: "assert",
|
|
3019
|
-
assertion: { type: "value", selector,
|
|
3070
|
+
assertion: { type: "value", selector, value: inputValue },
|
|
3020
3071
|
description: `Verify ${inputLabel} is "${inputValue}"`
|
|
3021
3072
|
});
|
|
3022
3073
|
}
|
|
@@ -3845,6 +3896,55 @@ ${elementSummary}${moreText}`;
|
|
|
3845
3896
|
this.hide();
|
|
3846
3897
|
}
|
|
3847
3898
|
}
|
|
3899
|
+
async toggleScreenCapture() {
|
|
3900
|
+
if (this.displayStream) {
|
|
3901
|
+
this.stopScreenCapture();
|
|
3902
|
+
return;
|
|
3903
|
+
}
|
|
3904
|
+
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getDisplayMedia) {
|
|
3905
|
+
console.warn(`${LOG_PREFIX} Screen capture not supported in this browser.`);
|
|
3906
|
+
return;
|
|
3907
|
+
}
|
|
3908
|
+
try {
|
|
3909
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
3910
|
+
video: { frameRate: { ideal: 5 } },
|
|
3911
|
+
audio: false
|
|
3912
|
+
});
|
|
3913
|
+
this.displayStream = stream;
|
|
3914
|
+
const video = document.createElement("video");
|
|
3915
|
+
video.srcObject = stream;
|
|
3916
|
+
video.muted = true;
|
|
3917
|
+
video.playsInline = true;
|
|
3918
|
+
await video.play().catch(() => {});
|
|
3919
|
+
this.displayVideo = video;
|
|
3920
|
+
stream.getVideoTracks()[0]?.addEventListener("ended", () => {
|
|
3921
|
+
this.stopScreenCapture();
|
|
3922
|
+
});
|
|
3923
|
+
const btn = this.shadowRoot?.querySelector('.btn[data-action="screen"]');
|
|
3924
|
+
btn?.classList.add("sharing");
|
|
3925
|
+
} catch (err) {
|
|
3926
|
+
console.log(`${LOG_PREFIX} Screen capture not started: ${err.message}`);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
stopScreenCapture() {
|
|
3930
|
+
if (this.displayStream) {
|
|
3931
|
+
for (const track of this.displayStream.getTracks()) {
|
|
3932
|
+
try {
|
|
3933
|
+
track.stop();
|
|
3934
|
+
} catch {}
|
|
3935
|
+
}
|
|
3936
|
+
this.displayStream = null;
|
|
3937
|
+
}
|
|
3938
|
+
if (this.displayVideo) {
|
|
3939
|
+
try {
|
|
3940
|
+
this.displayVideo.pause();
|
|
3941
|
+
} catch {}
|
|
3942
|
+
this.displayVideo.srcObject = null;
|
|
3943
|
+
this.displayVideo = null;
|
|
3944
|
+
}
|
|
3945
|
+
const btn = this.shadowRoot?.querySelector('.btn[data-action="screen"]');
|
|
3946
|
+
btn?.classList.remove("sharing");
|
|
3947
|
+
}
|
|
3848
3948
|
toggleMinimize() {
|
|
3849
3949
|
if (this.headless)
|
|
3850
3950
|
return;
|
|
@@ -5336,7 +5436,8 @@ ${elementSummary}${moreText}`;
|
|
|
5336
5436
|
scroll: 0,
|
|
5337
5437
|
mutation: 0,
|
|
5338
5438
|
console: 0,
|
|
5339
|
-
focus: 0
|
|
5439
|
+
focus: 0,
|
|
5440
|
+
recording: 0
|
|
5340
5441
|
};
|
|
5341
5442
|
this.statsStartTime = Date.now();
|
|
5342
5443
|
this.startSemanticEvents();
|
|
@@ -5781,7 +5882,37 @@ ${elementSummary}${moreText}`;
|
|
|
5781
5882
|
return;
|
|
5782
5883
|
}
|
|
5783
5884
|
}
|
|
5784
|
-
this.
|
|
5885
|
+
if (this.displayVideo && this.displayStream) {
|
|
5886
|
+
try {
|
|
5887
|
+
const video = this.displayVideo;
|
|
5888
|
+
const w = video.videoWidth;
|
|
5889
|
+
const h = video.videoHeight;
|
|
5890
|
+
if (!w || !h) {
|
|
5891
|
+
this.respond(msg2.id, false, null, "Screen capture stream has no frame data yet — try again in a moment.");
|
|
5892
|
+
return;
|
|
5893
|
+
}
|
|
5894
|
+
const canvas = document.createElement("canvas");
|
|
5895
|
+
canvas.width = w;
|
|
5896
|
+
canvas.height = h;
|
|
5897
|
+
const ctx = canvas.getContext("2d");
|
|
5898
|
+
ctx.drawImage(video, 0, 0, w, h);
|
|
5899
|
+
const dataUrl = canvas.toDataURL("image/png");
|
|
5900
|
+
const converted = await convertFormat(dataUrl);
|
|
5901
|
+
this.respond(msg2.id, true, {
|
|
5902
|
+
image: converted.image,
|
|
5903
|
+
viewport,
|
|
5904
|
+
format,
|
|
5905
|
+
width: converted.width,
|
|
5906
|
+
height: converted.height,
|
|
5907
|
+
source: "getDisplayMedia"
|
|
5908
|
+
});
|
|
5909
|
+
return;
|
|
5910
|
+
} catch (err) {
|
|
5911
|
+
this.respond(msg2.id, false, null, `Screen capture frame failed: ${err.message}`);
|
|
5912
|
+
return;
|
|
5913
|
+
}
|
|
5914
|
+
}
|
|
5915
|
+
this.respond(msg2.id, false, null, "No screenshot capture available. Either run the Haltija Desktop app (npx haltija@latest -f) or click the \uD83D\uDDA5 button in the Haltija widget to share your screen.");
|
|
5785
5916
|
} catch (err) {
|
|
5786
5917
|
this.respond(msg2.id, false, null, err.message);
|
|
5787
5918
|
}
|
|
@@ -6193,31 +6324,6 @@ ${elementSummary}${moreText}`;
|
|
|
6193
6324
|
el.value = value;
|
|
6194
6325
|
}
|
|
6195
6326
|
}
|
|
6196
|
-
getKeyCode(char) {
|
|
6197
|
-
const upper = char.toUpperCase();
|
|
6198
|
-
if (upper >= "A" && upper <= "Z")
|
|
6199
|
-
return `Key${upper}`;
|
|
6200
|
-
if (char >= "0" && char <= "9")
|
|
6201
|
-
return `Digit${char}`;
|
|
6202
|
-
const specialKeys = {
|
|
6203
|
-
" ": "Space",
|
|
6204
|
-
".": "Period",
|
|
6205
|
-
",": "Comma",
|
|
6206
|
-
"/": "Slash",
|
|
6207
|
-
";": "Semicolon",
|
|
6208
|
-
"'": "Quote",
|
|
6209
|
-
"[": "BracketLeft",
|
|
6210
|
-
"]": "BracketRight",
|
|
6211
|
-
"\\": "Backslash",
|
|
6212
|
-
"-": "Minus",
|
|
6213
|
-
"=": "Equal",
|
|
6214
|
-
"`": "Backquote",
|
|
6215
|
-
Enter: "Enter",
|
|
6216
|
-
Tab: "Tab",
|
|
6217
|
-
Backspace: "Backspace"
|
|
6218
|
-
};
|
|
6219
|
-
return specialKeys[char] || `Key${upper}`;
|
|
6220
|
-
}
|
|
6221
6327
|
getAdjacentKeys() {
|
|
6222
6328
|
return {
|
|
6223
6329
|
a: ["s", "q", "w", "z"],
|
|
@@ -6464,32 +6570,7 @@ ${elementSummary}${moreText}`;
|
|
|
6464
6570
|
}
|
|
6465
6571
|
}
|
|
6466
6572
|
getKeyCode(key) {
|
|
6467
|
-
|
|
6468
|
-
Enter: "Enter",
|
|
6469
|
-
Escape: "Escape",
|
|
6470
|
-
Tab: "Tab",
|
|
6471
|
-
Backspace: "Backspace",
|
|
6472
|
-
Delete: "Delete",
|
|
6473
|
-
ArrowUp: "ArrowUp",
|
|
6474
|
-
ArrowDown: "ArrowDown",
|
|
6475
|
-
ArrowLeft: "ArrowLeft",
|
|
6476
|
-
ArrowRight: "ArrowRight",
|
|
6477
|
-
Home: "Home",
|
|
6478
|
-
End: "End",
|
|
6479
|
-
PageUp: "PageUp",
|
|
6480
|
-
PageDown: "PageDown",
|
|
6481
|
-
" ": "Space",
|
|
6482
|
-
Space: "Space"
|
|
6483
|
-
};
|
|
6484
|
-
if (specialKeys[key])
|
|
6485
|
-
return specialKeys[key];
|
|
6486
|
-
if (/^F\d{1,2}$/.test(key))
|
|
6487
|
-
return key;
|
|
6488
|
-
if (/^[a-zA-Z]$/.test(key))
|
|
6489
|
-
return `Key${key.toUpperCase()}`;
|
|
6490
|
-
if (/^[0-9]$/.test(key))
|
|
6491
|
-
return `Digit${key}`;
|
|
6492
|
-
return key;
|
|
6573
|
+
return keyToCode(key);
|
|
6493
6574
|
}
|
|
6494
6575
|
getElementLabel(el) {
|
|
6495
6576
|
const text = el.textContent?.trim().slice(0, 30);
|
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -97,6 +97,7 @@ export const ARG_MAPS = {
|
|
|
97
97
|
highlight: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), label: args[1] }),
|
|
98
98
|
unhighlight: () => ({}),
|
|
99
99
|
find: (args) => ({ text: args.join(' ') }),
|
|
100
|
+
form: (args) => parseFormArgs(args),
|
|
100
101
|
wait: (args) => parseWaitArgs(args),
|
|
101
102
|
call: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), method: args[1], args: args.slice(2).map(tryParseJSON) }),
|
|
102
103
|
fetch: (args) => ({ url: args[0], prompt: args.slice(1).join(' ') || undefined }),
|
|
@@ -106,6 +107,14 @@ export const ARG_MAPS = {
|
|
|
106
107
|
for (let i = 0; i < args.length; i++) {
|
|
107
108
|
const a = args[i]
|
|
108
109
|
if (a === '--data-url') { body.file = false; continue }
|
|
110
|
+
if (a === '--format') { body.format = args[++i]; continue }
|
|
111
|
+
if (a === '--quality') {
|
|
112
|
+
// Accept both 0–1 (canvas-native) and 0–100 (documented) — normalize
|
|
113
|
+
// anything > 1 down to the 0–1 the widget's toDataURL expects.
|
|
114
|
+
const q = num(args[++i])
|
|
115
|
+
if (q != null && !Number.isNaN(q)) body.quality = q > 1 ? q / 100 : q
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
109
118
|
if (a === '--scale') { body.scale = num(args[++i]); continue }
|
|
110
119
|
if (a === '--maxWidth' || a === '--max-width') { body.maxWidth = num(args[++i]); continue }
|
|
111
120
|
if (a === '--maxHeight' || a === '--max-height') { body.maxHeight = num(args[++i]); continue }
|
|
@@ -135,7 +144,6 @@ export const ARG_MAPS = {
|
|
|
135
144
|
'events-watch': (args) => ({ preset: args[0] || 'interactive' }),
|
|
136
145
|
'mutations-watch': (args) => ({ preset: args[0] || 'smart' }),
|
|
137
146
|
'network-watch': (args) => ({ preset: args[0] || 'standard' }),
|
|
138
|
-
form: (args) => parseTargetArgs(args),
|
|
139
147
|
// send <agent> <message> or send selection/recording
|
|
140
148
|
// --no-submit flag prevents auto-submit (paste only)
|
|
141
149
|
'test-run': (args) => {
|
|
@@ -270,6 +278,20 @@ export function parseClickArgs(args) {
|
|
|
270
278
|
return Object.keys(body).length ? body : {}
|
|
271
279
|
}
|
|
272
280
|
|
|
281
|
+
/** Parse form args: optional form selector + --include-disabled/--include-hidden */
|
|
282
|
+
export function parseFormArgs(args) {
|
|
283
|
+
const body = {}
|
|
284
|
+
const positional = []
|
|
285
|
+
for (let i = 0; i < args.length; i++) {
|
|
286
|
+
const a = args[i]
|
|
287
|
+
if (a === '--include-disabled') { body.includeDisabled = true; continue }
|
|
288
|
+
if (a === '--include-hidden') { body.includeHidden = true; continue }
|
|
289
|
+
if (!a.startsWith('-')) { positional.push(a); continue }
|
|
290
|
+
}
|
|
291
|
+
if (positional.length) body.selector = positional[0]
|
|
292
|
+
return Object.keys(body).length ? body : undefined
|
|
293
|
+
}
|
|
294
|
+
|
|
273
295
|
/** Parse inspect args: selector/ref + CSS flags */
|
|
274
296
|
export function parseInspectArgs(args) {
|
|
275
297
|
const body = {}
|
|
@@ -621,10 +643,107 @@ async function ensureBrowserConnected(port) {
|
|
|
621
643
|
// Commands that don't need a browser window to be connected
|
|
622
644
|
const INFO_COMMANDS = new Set(['status', 'windows', 'version', 'help'])
|
|
623
645
|
|
|
646
|
+
// Commands whose payload lives in DevResponse.data and should be printed
|
|
647
|
+
// unwrapped: strings verbatim, objects/arrays as pretty JSON, no envelope.
|
|
648
|
+
// Trailing command hint is suppressed for these so agents can pipe stdout
|
|
649
|
+
// directly. Pass --json to get the full DevResponse envelope instead.
|
|
650
|
+
const UNWRAP_DATA_SUBCOMMANDS = new Set([
|
|
651
|
+
'eval', // JS expression result (any type)
|
|
652
|
+
'call', // method-call result on an element
|
|
653
|
+
'fetch', // fetched URL response (body + headers + status)
|
|
654
|
+
'location', // current URL + title
|
|
655
|
+
'query', // matched element info (single or array)
|
|
656
|
+
'inspect', // single-element details
|
|
657
|
+
'inspectAll', // array of element details
|
|
658
|
+
'find', // elements located by text
|
|
659
|
+
'console', // console buffer entries
|
|
660
|
+
'form', // form field values
|
|
661
|
+
])
|
|
662
|
+
|
|
624
663
|
// ============================================
|
|
625
664
|
// Main subcommand execution
|
|
626
665
|
// ============================================
|
|
627
666
|
|
|
667
|
+
// Flags each flag-oriented subcommand recognizes. Used to (a) accept
|
|
668
|
+
// `--flag=value` as well as `--flag value`, and (b) warn — not fail — when an
|
|
669
|
+
// agent passes a flag the command will otherwise silently ignore. Commands that
|
|
670
|
+
// take free-form text (type, eval, find, snapshot, send…) are intentionally
|
|
671
|
+
// ABSENT: a leading-dash token there is content, not a flag, so we leave them
|
|
672
|
+
// untouched.
|
|
673
|
+
const GLOBAL_FLAGS = ['--json', '--window', '--port', '--name', '--token', '--no-launch', '--help']
|
|
674
|
+
export const KNOWN_FLAGS = {
|
|
675
|
+
tree: ['--depth', '-d', '--selector', '-s', '--compact', '-c', '--interactive', '-i', '--visible', '-v', '--text', '--no-text', '--shadow', '--frames', '--no-frames'],
|
|
676
|
+
click: ['--diff', '--delay'],
|
|
677
|
+
form: ['--include-disabled', '--include-hidden'],
|
|
678
|
+
inspect: ['--full-styles', '--styles', '--matched-rules', '--rules', '--ancestors'],
|
|
679
|
+
inspectAll: ['--full-styles', '--styles', '--matched-rules', '--rules', '--ancestors'],
|
|
680
|
+
key: ['--ctrl', '-c', '--shift', '-s', '--alt', '-a', '--meta', '-m'],
|
|
681
|
+
screenshot: ['--data-url', '--format', '--quality', '--scale', '--maxWidth', '--max-width', '--maxHeight', '--max-height', '--delay', '--no-chyron'],
|
|
682
|
+
'video-start': ['--maxDuration', '--max-duration'],
|
|
683
|
+
refresh: ['--soft'],
|
|
684
|
+
'test-run': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
|
|
685
|
+
'test-validate': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
|
|
686
|
+
'test-suite': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Split `--flag=value` into `--flag`, `value` (first `=` only). Long flags only. */
|
|
690
|
+
export function normalizeEqualsFlags(args) {
|
|
691
|
+
const out = []
|
|
692
|
+
for (const a of args) {
|
|
693
|
+
if (a.startsWith('--') && a.includes('=')) {
|
|
694
|
+
const eq = a.indexOf('=')
|
|
695
|
+
out.push(a.slice(0, eq), a.slice(eq + 1))
|
|
696
|
+
} else {
|
|
697
|
+
out.push(a)
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return out
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Levenshtein distance, for "did you mean" suggestions. */
|
|
704
|
+
function editDistance(a, b) {
|
|
705
|
+
const m = a.length, n = b.length
|
|
706
|
+
const d = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)])
|
|
707
|
+
for (let j = 0; j <= n; j++) d[0][j] = j
|
|
708
|
+
for (let i = 1; i <= m; i++) {
|
|
709
|
+
for (let j = 1; j <= n; j++) {
|
|
710
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
711
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return d[m][n]
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Closest known flag within a small edit distance, or null. */
|
|
718
|
+
function closestFlag(input, candidates) {
|
|
719
|
+
let best = null, bestD = Infinity
|
|
720
|
+
for (const c of candidates) {
|
|
721
|
+
const dist = editDistance(input, c)
|
|
722
|
+
if (dist < bestD) { bestD = dist; best = c }
|
|
723
|
+
}
|
|
724
|
+
return bestD <= 3 ? best : null
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Warn (to stderr, non-fatal) about dashed tokens a flag-oriented command
|
|
729
|
+
* doesn't recognize, so a typo like `--frmat` or an unsupported flag stops
|
|
730
|
+
* silently doing nothing. No-op for free-text commands (not in KNOWN_FLAGS).
|
|
731
|
+
*/
|
|
732
|
+
export function warnUnknownFlags(subcommand, args) {
|
|
733
|
+
const known = KNOWN_FLAGS[subcommand]
|
|
734
|
+
if (!known) return
|
|
735
|
+
const allowed = new Set([...known, ...GLOBAL_FLAGS])
|
|
736
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
737
|
+
for (const a of args) {
|
|
738
|
+
if (!a.startsWith('-')) continue // positional or a flag's value
|
|
739
|
+
if (/^-\d/.test(a)) continue // negative number, not a flag
|
|
740
|
+
if (allowed.has(a)) continue
|
|
741
|
+
const suggestion = closestFlag(a, known)
|
|
742
|
+
const hint = suggestion ? ` (did you mean ${suggestion}?)` : ''
|
|
743
|
+
process.stderr.write(dim(`[hj] warning: unknown flag "${a}" ignored${hint}`) + '\n')
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
628
747
|
export async function runSubcommand(subcommand, subArgs, port = '8700', options = {}) {
|
|
629
748
|
const baseUrl = `http://localhost:${port}`
|
|
630
749
|
const jsonOutput = subArgs.includes('--json')
|
|
@@ -638,6 +757,13 @@ export async function runSubcommand(subcommand, subArgs, port = '8700', options
|
|
|
638
757
|
filteredArgs = [...filteredArgs.slice(0, windowIdx), ...filteredArgs.slice(windowIdx + 2)]
|
|
639
758
|
}
|
|
640
759
|
|
|
760
|
+
// For flag-oriented commands, accept `--flag=value` and surface unknown flags
|
|
761
|
+
// instead of silently dropping them. Free-text commands are left untouched.
|
|
762
|
+
if (KNOWN_FLAGS[subcommand]) {
|
|
763
|
+
filteredArgs = normalizeEqualsFlags(filteredArgs)
|
|
764
|
+
warnUnknownFlags(subcommand, filteredArgs)
|
|
765
|
+
}
|
|
766
|
+
|
|
641
767
|
// Check if server is running, auto-start if not
|
|
642
768
|
if (!(await isServerRunning(port))) {
|
|
643
769
|
// Respect "user manually quit Haltija" before we try to spawn anything.
|
|
@@ -765,6 +891,25 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
765
891
|
console.log(bold(json.data.path))
|
|
766
892
|
const meta = [json.data.duration ? `${json.data.duration.toFixed(1)}s` : null, json.data.size ? `${(json.data.size / 1024).toFixed(0)}KB` : null, json.data.format].filter(Boolean).join(', ')
|
|
767
893
|
if (meta) console.log(dim(meta))
|
|
894
|
+
} else if (!jsonOutput && UNWRAP_DATA_SUBCOMMANDS.has(subcommand)) {
|
|
895
|
+
// Print the inner DevResponse.data unwrapped so agents (and humans) can
|
|
896
|
+
// read it directly. Strings go to stdout as-is — no JSON escaping of
|
|
897
|
+
// newlines, quotes, etc. Objects/arrays still pretty-print as JSON.
|
|
898
|
+
// Failures go to stderr with a non-zero exit. Pass --json to get the
|
|
899
|
+
// full DevResponse envelope instead.
|
|
900
|
+
if (json.success === false) {
|
|
901
|
+
console.error(`${subcommand} failed: ${json.error || 'unknown error'}`)
|
|
902
|
+
process.exit(1)
|
|
903
|
+
}
|
|
904
|
+
const result = json.data
|
|
905
|
+
if (result === null || result === undefined) {
|
|
906
|
+
// nothing to print
|
|
907
|
+
} else if (typeof result === 'string') {
|
|
908
|
+
process.stdout.write(result)
|
|
909
|
+
if (!result.endsWith('\n')) process.stdout.write('\n')
|
|
910
|
+
} else {
|
|
911
|
+
console.log(JSON.stringify(result, null, 2))
|
|
912
|
+
}
|
|
768
913
|
} else {
|
|
769
914
|
console.log(JSON.stringify(json, null, 2))
|
|
770
915
|
}
|
|
@@ -773,8 +918,10 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
773
918
|
console.log(text)
|
|
774
919
|
}
|
|
775
920
|
|
|
776
|
-
// Show hint for this command (if available and successful)
|
|
777
|
-
|
|
921
|
+
// Show hint for this command (if available and successful).
|
|
922
|
+
// Skip for commands whose stdout is meant to be piped/consumed verbatim
|
|
923
|
+
// — agents shouldn't have to strip a trailing hint line.
|
|
924
|
+
if (resp.ok && !jsonOutput && !UNWRAP_DATA_SUBCOMMANDS.has(subcommand)) {
|
|
778
925
|
const hint = COMMAND_HINTS[subcommand]
|
|
779
926
|
if (hint) {
|
|
780
927
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
@@ -798,7 +945,7 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
798
945
|
|
|
799
946
|
/** Known valid subcommands */
|
|
800
947
|
export const KNOWN_COMMANDS = new Set([
|
|
801
|
-
'tree', 'query', 'inspect', 'inspectAll', 'styles', 'find',
|
|
948
|
+
'tree', 'query', 'inspect', 'inspectAll', 'styles', 'find', 'form',
|
|
802
949
|
'click', 'type', 'key', 'drag', 'scroll', 'call',
|
|
803
950
|
'navigate', 'refresh', 'location',
|
|
804
951
|
'events', 'events-watch', 'events-unwatch', 'console',
|
|
@@ -812,7 +959,7 @@ export const KNOWN_COMMANDS = new Set([
|
|
|
812
959
|
'recording', 'recording-start', 'recording-stop', 'recording-generate', 'recordings',
|
|
813
960
|
'test-run', 'test-validate', 'test-suite',
|
|
814
961
|
'send', 'send-message', 'send-selection', 'send-recording',
|
|
815
|
-
'status', 'version', 'docs', 'api', 'stats'
|
|
962
|
+
'status', 'version', 'docs', 'api', 'stats', 'where'
|
|
816
963
|
])
|
|
817
964
|
|
|
818
965
|
/** Common typos/aliases mapped to correct commands */
|
package/bin/hints.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"events": "events-watch first | see: recording, console, mutations-watch",
|
|
13
13
|
"eval": "\"code\" (returns result) | see: console, snapshot",
|
|
14
14
|
"call": "@ref or \"selector\" <method>, --args [...] | see: eval, inspect",
|
|
15
|
-
"screenshot": "[selector], --scale 0.5, --maxWidth 800 | see: highlight, snapshot",
|
|
15
|
+
"screenshot": "[selector], --format webp, --scale 0.5, --maxWidth 800 | see: highlight, snapshot",
|
|
16
16
|
"windows": "--json | see: tabs-open, tabs-close, tabs-focus, status",
|
|
17
17
|
"tabs-open": "[url] | see: tabs-focus, tabs-close, windows",
|
|
18
18
|
"tabs-close": "<window-id> | see: windows, tabs-focus, tabs-open",
|