haltija 1.3.0-beta.9 → 1.3.0
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 +30 -3
- 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
|
@@ -76,6 +76,26 @@ Haltija connects to the browser you're already using. The one with the bug, the
|
|
|
76
76
|
|
|
77
77
|
---
|
|
78
78
|
|
|
79
|
+
## Haltija vs. Claude in Chrome
|
|
80
|
+
|
|
81
|
+
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.
|
|
82
|
+
|
|
83
|
+
| Axis | Haltija | Claude in Chrome |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| Setup / auth | ✅ `bunx haltija` — zero auth, self-spawns | ⚠️ Extension + claude.ai login + per-site permissions |
|
|
86
|
+
| How the agent invokes it | ✅ Plain `hj` CLI / REST — always available in a shell | ⚠️ MCP tools; must be loaded + extension connected |
|
|
87
|
+
| Project integration | ✅ *Is* your test harness — JSON tests (`hj test-suite`), record→replay, CI | ❌ None |
|
|
88
|
+
| 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 |
|
|
89
|
+
| Isolation | ✅ Own instance on its own port — never touches your browsing | ❌ Drives tabs in your live session |
|
|
90
|
+
| 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 |
|
|
91
|
+
| Authenticated / external sites | ✅ Inject into your logged-in browser on a chosen port *(one-time setup step)* | ✅ Native — already your logged-in profile |
|
|
92
|
+
| Flakiness / contention | ✅ A private port per project = dedicated instance *(a shared instance can still flake under contention)* | ⚠️ Depends on live browser + extension state |
|
|
93
|
+
| Screenshots | ✅ Native (desktop) or WebRTC `getDisplayMedia` (browser) | ✅ Vision-native |
|
|
94
|
+
|
|
95
|
+
**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.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
79
99
|
## How It Works
|
|
80
100
|
|
|
81
101
|
```
|
|
@@ -246,18 +266,25 @@ The server rejects every REST/WebSocket request without a matching `X-Haltija-To
|
|
|
246
266
|
## Installation
|
|
247
267
|
|
|
248
268
|
```bash
|
|
249
|
-
bunx haltija #
|
|
250
|
-
bunx haltija --server # Server only
|
|
269
|
+
bunx haltija # Bundled browser + embedded server (easiest start)
|
|
270
|
+
bunx haltija --server # Server only — your browser, per-project dev/debug
|
|
251
271
|
npm install -g haltija # Install globally
|
|
252
272
|
|
|
253
273
|
# Server options
|
|
254
274
|
haltija --https # HTTPS mode
|
|
255
275
|
haltija --port 3000 # Custom port
|
|
276
|
+
haltija --name <proj> # Register a per-project instance (hj --name <proj>)
|
|
256
277
|
haltija --token <secret> # Require X-Haltija-Token on every request
|
|
257
|
-
haltija --headless # For CI pipelines
|
|
278
|
+
haltija --headless # For CI pipelines (no desktop app needed)
|
|
258
279
|
haltija --setup-mcp # Configure Claude Desktop
|
|
259
280
|
```
|
|
260
281
|
|
|
282
|
+
**Which mode?** For day-to-day dev/debugging, run a per-project server
|
|
283
|
+
(`haltija --server --name <proj>`) and drive it with the `hj` CLI or your coding
|
|
284
|
+
agent — that's the paved path. For CI, `haltija --headless` runs a deterministic
|
|
285
|
+
headless Chromium with no desktop app required. A downloadable notarized desktop
|
|
286
|
+
app also exists but isn't required for either.
|
|
287
|
+
|
|
261
288
|
---
|
|
262
289
|
|
|
263
290
|
## Security
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.3.0
|
|
49
|
+
var VERSION = "1.3.0";
|
|
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",
|