haltija 1.1.21 → 1.2.3
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/apps/desktop/index.html +1 -1
- package/apps/desktop/main.js +264 -64
- package/apps/desktop/package.json +11 -3
- package/apps/desktop/preload.js +17 -0
- package/apps/desktop/renderer/agent-status.js +210 -0
- package/apps/desktop/renderer/settings.js +55 -0
- package/apps/desktop/renderer/state.js +98 -0
- package/apps/desktop/renderer/status.js +38 -0
- package/apps/desktop/renderer/tabs.js +374 -0
- package/apps/desktop/renderer/ui-utils.js +180 -0
- package/apps/desktop/renderer/video-capture.js +154 -0
- package/apps/desktop/renderer/webview-events.js +225 -0
- package/apps/desktop/renderer.js +98 -1604
- package/apps/desktop/resources/component.js +265 -55
- package/apps/desktop/webview-preload.js +19 -1
- package/bin/cli-subcommand.mjs +90 -27
- package/bin/hints.json +9 -4
- package/bin/hj.mjs +61 -2
- package/bin/test-data.mjs +291 -0
- package/bin/tosijs-dev.mjs +95 -20
- package/dist/client.js +5 -1
- package/dist/component.js +265 -55
- package/dist/hj.js +1600 -0
- package/dist/index.js +473 -91
- package/dist/server.js +473 -91
- package/dist/test.js +847 -0
- package/package.json +6 -1
package/dist/hj.js
ADDED
|
@@ -0,0 +1,1600 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
// bin/cli-subcommand.mjs
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
|
|
8
|
+
import { dirname, join } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
|
|
11
|
+
// bin/format-tree.mjs
|
|
12
|
+
var MAX_TEXT_LEN = 80;
|
|
13
|
+
function formatTree(node, indent = 0) {
|
|
14
|
+
if (!node)
|
|
15
|
+
return "";
|
|
16
|
+
const lines = [];
|
|
17
|
+
formatNode(node, indent, lines);
|
|
18
|
+
lines.push("---");
|
|
19
|
+
lines.push("hj tree --json");
|
|
20
|
+
return lines.join(`
|
|
21
|
+
`);
|
|
22
|
+
}
|
|
23
|
+
function formatNode(node, indent, lines) {
|
|
24
|
+
if (!node)
|
|
25
|
+
return;
|
|
26
|
+
if (node.tag === "haltija-dev")
|
|
27
|
+
return;
|
|
28
|
+
const prefix = " ".repeat(indent);
|
|
29
|
+
const hasChildren = node.children && node.children.length > 0 || node.shadowChildren && node.shadowChildren.length > 0;
|
|
30
|
+
const line = buildLine(node);
|
|
31
|
+
if (hasChildren) {
|
|
32
|
+
lines.push(`${prefix}( ${line}`);
|
|
33
|
+
if (node.children) {
|
|
34
|
+
for (const child of node.children) {
|
|
35
|
+
formatNode(child, indent + 2, lines);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (node.shadowChildren) {
|
|
39
|
+
for (const child of node.shadowChildren) {
|
|
40
|
+
if (child.classes && child.classes.includes("widget"))
|
|
41
|
+
continue;
|
|
42
|
+
formatNode(child, indent + 2, lines);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
lines.push(`${prefix})`);
|
|
46
|
+
} else {
|
|
47
|
+
lines.push(`${prefix}${line}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function buildLine(node) {
|
|
51
|
+
const parts = [];
|
|
52
|
+
parts.push(node.ref || "?");
|
|
53
|
+
let tagPart = node.tag || "?";
|
|
54
|
+
if (node.id)
|
|
55
|
+
tagPart += `#${node.id}`;
|
|
56
|
+
if (node.classes && node.classes.length) {
|
|
57
|
+
tagPart += "." + node.classes.join(".");
|
|
58
|
+
}
|
|
59
|
+
parts.push(tagPart);
|
|
60
|
+
if (node.attrs) {
|
|
61
|
+
for (const [key, val] of Object.entries(node.attrs)) {
|
|
62
|
+
if (val === "" || val === "true") {
|
|
63
|
+
parts.push(key);
|
|
64
|
+
} else if (/\s/.test(val) || val.length > 40) {
|
|
65
|
+
parts.push(`${key}="${truncate(val, 40)}"`);
|
|
66
|
+
} else {
|
|
67
|
+
parts.push(`${key}=${val}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (node.value !== undefined && node.value !== "") {
|
|
72
|
+
parts.push(`value="${truncate(node.value, 30)}"`);
|
|
73
|
+
}
|
|
74
|
+
if (node.checked !== undefined) {
|
|
75
|
+
parts.push(node.checked ? "checked" : "unchecked");
|
|
76
|
+
}
|
|
77
|
+
const flags = formatFlags(node.flags);
|
|
78
|
+
if (flags)
|
|
79
|
+
parts.push(flags);
|
|
80
|
+
if (node.text) {
|
|
81
|
+
parts.push(`"${truncate(node.text, MAX_TEXT_LEN)}"`);
|
|
82
|
+
}
|
|
83
|
+
if (node.truncated && node.childCount) {
|
|
84
|
+
parts.push(`(${node.childCount} children)`);
|
|
85
|
+
}
|
|
86
|
+
return parts.join(" ");
|
|
87
|
+
}
|
|
88
|
+
function formatFlags(flags) {
|
|
89
|
+
if (!flags)
|
|
90
|
+
return "";
|
|
91
|
+
const parts = [];
|
|
92
|
+
if (flags.interactive)
|
|
93
|
+
parts.push("interactive");
|
|
94
|
+
if (flags.disabled)
|
|
95
|
+
parts.push("disabled");
|
|
96
|
+
if (flags.required)
|
|
97
|
+
parts.push("required");
|
|
98
|
+
if (flags.readOnly)
|
|
99
|
+
parts.push("readonly");
|
|
100
|
+
if (flags.focused)
|
|
101
|
+
parts.push("focused");
|
|
102
|
+
if (flags.hidden && flags.hiddenReason) {
|
|
103
|
+
parts.push(`hidden:${flags.hiddenReason}`);
|
|
104
|
+
} else if (flags.hidden) {
|
|
105
|
+
parts.push("hidden");
|
|
106
|
+
}
|
|
107
|
+
if (flags.offScreen && !flags.hidden)
|
|
108
|
+
parts.push("offscreen");
|
|
109
|
+
if (flags.customElement)
|
|
110
|
+
parts.push("custom");
|
|
111
|
+
if (flags.hasAria)
|
|
112
|
+
parts.push("aria");
|
|
113
|
+
return parts.join(" ");
|
|
114
|
+
}
|
|
115
|
+
function truncate(str, max) {
|
|
116
|
+
if (!str)
|
|
117
|
+
return "";
|
|
118
|
+
if (str.length <= max)
|
|
119
|
+
return str;
|
|
120
|
+
return str.slice(0, max - 1) + "…";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// bin/format-events.mjs
|
|
124
|
+
function formatEvents(response) {
|
|
125
|
+
const events = response?.events || response;
|
|
126
|
+
if (!events || !Array.isArray(events) || events.length === 0) {
|
|
127
|
+
return `(no events)
|
|
128
|
+
---
|
|
129
|
+
hj events --json`;
|
|
130
|
+
}
|
|
131
|
+
const lines = events.map((ev) => {
|
|
132
|
+
const parts = [];
|
|
133
|
+
parts.push(String(ev.timestamp));
|
|
134
|
+
parts.push(ev.type);
|
|
135
|
+
const target = formatTarget(ev.target);
|
|
136
|
+
if (target)
|
|
137
|
+
parts.push(target);
|
|
138
|
+
const summary = extractPayloadSummary(ev);
|
|
139
|
+
if (summary)
|
|
140
|
+
parts.push(summary);
|
|
141
|
+
return parts.join(" ");
|
|
142
|
+
});
|
|
143
|
+
const sinceTs = events[0].timestamp;
|
|
144
|
+
lines.push("---");
|
|
145
|
+
lines.push(`hj events --json --since=${sinceTs}`);
|
|
146
|
+
return lines.join(`
|
|
147
|
+
`);
|
|
148
|
+
}
|
|
149
|
+
function formatTarget(target) {
|
|
150
|
+
if (!target)
|
|
151
|
+
return "";
|
|
152
|
+
let result = target.tag || "";
|
|
153
|
+
if (target.id) {
|
|
154
|
+
result += `#${target.id}`;
|
|
155
|
+
} else if (target.selector) {
|
|
156
|
+
return target.selector;
|
|
157
|
+
}
|
|
158
|
+
return result || "";
|
|
159
|
+
}
|
|
160
|
+
function extractPayloadSummary(ev) {
|
|
161
|
+
const { type, payload, target } = ev;
|
|
162
|
+
if (!payload && !target)
|
|
163
|
+
return "";
|
|
164
|
+
if (type === "input:typed") {
|
|
165
|
+
return quote(payload?.text || payload?.finalValue || "");
|
|
166
|
+
}
|
|
167
|
+
if (type === "interaction:click") {
|
|
168
|
+
return quote(payload?.text || target?.text || "");
|
|
169
|
+
}
|
|
170
|
+
if (type === "interaction:submit") {
|
|
171
|
+
return payload?.formAction || payload?.formId || "";
|
|
172
|
+
}
|
|
173
|
+
if (type?.startsWith("navigation:")) {
|
|
174
|
+
return payload?.to || payload?.url || "";
|
|
175
|
+
}
|
|
176
|
+
if (type?.startsWith("console:")) {
|
|
177
|
+
return quote(truncate2(payload?.message || "", 120));
|
|
178
|
+
}
|
|
179
|
+
if (type === "scroll:stop") {
|
|
180
|
+
return `${payload?.direction || ""} ${payload?.distance || 0}px`;
|
|
181
|
+
}
|
|
182
|
+
if (type === "hover:dwell") {
|
|
183
|
+
return `${payload?.duration || 0}ms`;
|
|
184
|
+
}
|
|
185
|
+
if (type === "mutation:change") {
|
|
186
|
+
const what = payload?.changeType || "";
|
|
187
|
+
const el = payload?.element || "";
|
|
188
|
+
return `${what} ${el}`.trim();
|
|
189
|
+
}
|
|
190
|
+
if (type === "focus:focus" || type === "focus:blur") {
|
|
191
|
+
return target?.text || target?.selector || "";
|
|
192
|
+
}
|
|
193
|
+
if (payload) {
|
|
194
|
+
for (const val of Object.values(payload)) {
|
|
195
|
+
if (typeof val === "string" && val.length > 0 && val.length < 200) {
|
|
196
|
+
return quote(truncate2(val, 80));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return "";
|
|
201
|
+
}
|
|
202
|
+
function quote(s) {
|
|
203
|
+
if (!s)
|
|
204
|
+
return "";
|
|
205
|
+
return `"${s}"`;
|
|
206
|
+
}
|
|
207
|
+
function truncate2(str, max) {
|
|
208
|
+
if (!str || str.length <= max)
|
|
209
|
+
return str;
|
|
210
|
+
return str.slice(0, max - 1) + "…";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// bin/format-test.mjs
|
|
214
|
+
function formatTestResult(result) {
|
|
215
|
+
if (!result)
|
|
216
|
+
return `(no result)
|
|
217
|
+
---
|
|
218
|
+
hj test-run --json`;
|
|
219
|
+
const lines = [];
|
|
220
|
+
const status = result.passed ? "ok" : "FAIL";
|
|
221
|
+
const name = result.test || "unnamed";
|
|
222
|
+
const duration = result.duration ? `${result.duration}ms` : "";
|
|
223
|
+
const counts = result.summary ? `${result.summary.passed}/${result.summary.total}` : "";
|
|
224
|
+
lines.push([status, name, duration, counts].filter(Boolean).join(" "));
|
|
225
|
+
if (result.steps) {
|
|
226
|
+
for (const step of result.steps) {
|
|
227
|
+
const stepStatus = step.passed ? "ok" : step.error === "skipped" ? "skip" : "FAIL";
|
|
228
|
+
const desc = formatStepDescription(step);
|
|
229
|
+
const dur = step.duration ? `${step.duration}ms` : "";
|
|
230
|
+
const err = !step.passed && step.error && step.error !== "skipped" ? step.error : "";
|
|
231
|
+
lines.push(` ${step.index + 1} ${[stepStatus, desc, dur, err].filter(Boolean).join(" ")}`);
|
|
232
|
+
if (!step.passed && step.context) {
|
|
233
|
+
const detail = formatFailureContext(step.context);
|
|
234
|
+
if (detail)
|
|
235
|
+
lines.push(` > ${detail}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (result.patience) {
|
|
240
|
+
const p = result.patience;
|
|
241
|
+
lines.push(` patience ${p.remaining}/${p.allowed} remaining streak=${p.consecutiveFailures}/${p.streak} timeout=${p.finalTimeoutMs}ms`);
|
|
242
|
+
}
|
|
243
|
+
lines.push("---");
|
|
244
|
+
lines.push("hj test-run --json");
|
|
245
|
+
return lines.join(`
|
|
246
|
+
`);
|
|
247
|
+
}
|
|
248
|
+
function formatSuiteResult(result) {
|
|
249
|
+
if (!result)
|
|
250
|
+
return `(no result)
|
|
251
|
+
---
|
|
252
|
+
hj test-run --json`;
|
|
253
|
+
const lines = [];
|
|
254
|
+
const status = result.summary?.failed === 0 ? "ok" : "FAIL";
|
|
255
|
+
const duration = result.duration ? `${result.duration}ms` : "";
|
|
256
|
+
const counts = result.summary ? `${result.summary.passed}/${result.summary.total} tests` : "";
|
|
257
|
+
lines.push([status, "suite", duration, counts].filter(Boolean).join(" "));
|
|
258
|
+
if (result.results) {
|
|
259
|
+
for (const testResult of result.results) {
|
|
260
|
+
const tStatus = testResult.passed ? "ok" : "FAIL";
|
|
261
|
+
const name = testResult.test || "unnamed";
|
|
262
|
+
const dur = testResult.duration ? `${testResult.duration}ms` : "";
|
|
263
|
+
const tCounts = testResult.summary ? `${testResult.summary.passed}/${testResult.summary.total}` : "";
|
|
264
|
+
lines.push(` ${[tStatus, name, dur, tCounts].filter(Boolean).join(" ")}`);
|
|
265
|
+
if (!testResult.passed && testResult.steps) {
|
|
266
|
+
const failed = testResult.steps.find((s) => !s.passed);
|
|
267
|
+
if (failed) {
|
|
268
|
+
const desc = formatStepDescription(failed);
|
|
269
|
+
const err = failed.error || "";
|
|
270
|
+
lines.push(` step ${failed.index + 1}: ${[desc, err].filter(Boolean).join(" ")}`);
|
|
271
|
+
if (failed.context) {
|
|
272
|
+
const detail = formatFailureContext(failed.context);
|
|
273
|
+
if (detail)
|
|
274
|
+
lines.push(` > ${detail}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
lines.push("---");
|
|
281
|
+
lines.push("hj test-run --json");
|
|
282
|
+
return lines.join(`
|
|
283
|
+
`);
|
|
284
|
+
}
|
|
285
|
+
function formatStepDescription(step) {
|
|
286
|
+
const s = step.step || step;
|
|
287
|
+
const action = s.action || step.description || "";
|
|
288
|
+
switch (action) {
|
|
289
|
+
case "navigate":
|
|
290
|
+
return `navigate ${s.url || ""}`;
|
|
291
|
+
case "click":
|
|
292
|
+
return `click ${s.selector || s.ref || ""}`;
|
|
293
|
+
case "type":
|
|
294
|
+
return `type ${s.selector || s.ref || ""} "${truncate3(s.text || "", 30)}"`;
|
|
295
|
+
case "key":
|
|
296
|
+
return `key ${s.key || ""}`;
|
|
297
|
+
case "wait":
|
|
298
|
+
return `wait ${s.selector || s.url || (s.forWindow ? "new window" : "") || (s.duration != null ? s.duration + "ms" : "") || ""}`;
|
|
299
|
+
case "assert": {
|
|
300
|
+
const a = s.assertion || {};
|
|
301
|
+
const sel = a.selector || "";
|
|
302
|
+
const val = a.text || a.value || a.pattern || "";
|
|
303
|
+
return `assert ${a.type || ""} ${sel} ${val ? '"' + truncate3(val, 30) + '"' : ""}`.trim();
|
|
304
|
+
}
|
|
305
|
+
case "check":
|
|
306
|
+
return `check ${s.selector || ""}`;
|
|
307
|
+
case "eval":
|
|
308
|
+
return `eval ${truncate3(s.code || "", 40)}`;
|
|
309
|
+
case "verify":
|
|
310
|
+
return `verify ${truncate3(s.eval || "", 40)}`;
|
|
311
|
+
case "tabs-open":
|
|
312
|
+
return `tabs-open ${s.url || ""}`;
|
|
313
|
+
case "tabs-close":
|
|
314
|
+
return `tabs-close ${s.window || ""}`;
|
|
315
|
+
case "tabs-focus":
|
|
316
|
+
return `tabs-focus ${s.window || ""}`;
|
|
317
|
+
default:
|
|
318
|
+
return step.description || action || "unknown";
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function formatFailureContext(context) {
|
|
322
|
+
const parts = [];
|
|
323
|
+
if (context.reason) {
|
|
324
|
+
parts.push(context.reason);
|
|
325
|
+
}
|
|
326
|
+
if (context.buttonsOnPage?.length) {
|
|
327
|
+
parts.push(`page shows: [${context.buttonsOnPage.join(", ")}]`);
|
|
328
|
+
}
|
|
329
|
+
if (context.actual !== undefined && context.expected !== undefined) {
|
|
330
|
+
parts.push(`expected "${context.expected}" got "${context.actual}"`);
|
|
331
|
+
}
|
|
332
|
+
if (context.suggestion) {
|
|
333
|
+
parts.push(context.suggestion);
|
|
334
|
+
}
|
|
335
|
+
return parts.join(", ");
|
|
336
|
+
}
|
|
337
|
+
function truncate3(str, max) {
|
|
338
|
+
if (!str || str.length <= max)
|
|
339
|
+
return str;
|
|
340
|
+
return str.slice(0, max - 1) + "…";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// bin/test-data.mjs
|
|
344
|
+
function xorshift32(state) {
|
|
345
|
+
let s = state | 0;
|
|
346
|
+
s ^= s << 13;
|
|
347
|
+
s ^= s >>> 17;
|
|
348
|
+
s ^= s << 5;
|
|
349
|
+
return [s >>> 0, s >>> 0];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
class SeededRandom {
|
|
353
|
+
constructor(seed) {
|
|
354
|
+
this.state = (seed === 0 ? 1 : seed) >>> 0;
|
|
355
|
+
}
|
|
356
|
+
next() {
|
|
357
|
+
const [value, newState] = xorshift32(this.state);
|
|
358
|
+
this.state = newState;
|
|
359
|
+
return value / 4294967296;
|
|
360
|
+
}
|
|
361
|
+
int(min, max) {
|
|
362
|
+
return min + Math.floor(this.next() * (max - min + 1));
|
|
363
|
+
}
|
|
364
|
+
pick(arr) {
|
|
365
|
+
return arr[this.int(0, arr.length - 1)];
|
|
366
|
+
}
|
|
367
|
+
hex(len) {
|
|
368
|
+
let s = "";
|
|
369
|
+
for (let i = 0;i < len; i++)
|
|
370
|
+
s += this.int(0, 15).toString(16);
|
|
371
|
+
return s;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
var FIRST_NAMES = [
|
|
375
|
+
"Tessia",
|
|
376
|
+
"Testopher",
|
|
377
|
+
"Testina",
|
|
378
|
+
"Qadir",
|
|
379
|
+
"Qaleen",
|
|
380
|
+
"Checkov",
|
|
381
|
+
"Validia",
|
|
382
|
+
"Assertia",
|
|
383
|
+
"Debugson",
|
|
384
|
+
"Mockwell",
|
|
385
|
+
"Fixturia",
|
|
386
|
+
"Stubson",
|
|
387
|
+
"Spectra",
|
|
388
|
+
"Suitewell",
|
|
389
|
+
"Runley",
|
|
390
|
+
"Passandra",
|
|
391
|
+
"Failsworth",
|
|
392
|
+
"Edgeworth",
|
|
393
|
+
"Boundara",
|
|
394
|
+
"Flaxton"
|
|
395
|
+
];
|
|
396
|
+
var WORDS = [
|
|
397
|
+
"quick",
|
|
398
|
+
"brown",
|
|
399
|
+
"fox",
|
|
400
|
+
"lazy",
|
|
401
|
+
"dog",
|
|
402
|
+
"test",
|
|
403
|
+
"data",
|
|
404
|
+
"jumps",
|
|
405
|
+
"over",
|
|
406
|
+
"fence",
|
|
407
|
+
"under",
|
|
408
|
+
"bridge",
|
|
409
|
+
"through",
|
|
410
|
+
"forest",
|
|
411
|
+
"around",
|
|
412
|
+
"mountain",
|
|
413
|
+
"beside",
|
|
414
|
+
"river",
|
|
415
|
+
"across",
|
|
416
|
+
"valley",
|
|
417
|
+
"between",
|
|
418
|
+
"clouds",
|
|
419
|
+
"above",
|
|
420
|
+
"ocean",
|
|
421
|
+
"below"
|
|
422
|
+
];
|
|
423
|
+
var COMPANIES = [
|
|
424
|
+
"Haltija Test Corp",
|
|
425
|
+
"QA Industries",
|
|
426
|
+
"Assertion Labs",
|
|
427
|
+
"Testify Inc",
|
|
428
|
+
"Validate Co",
|
|
429
|
+
"Fixture Holdings",
|
|
430
|
+
"Mock & Sons",
|
|
431
|
+
"Spec Systems",
|
|
432
|
+
"Check Group",
|
|
433
|
+
"Edge Corp"
|
|
434
|
+
];
|
|
435
|
+
var STREETS = [
|
|
436
|
+
"Test Avenue",
|
|
437
|
+
"QA Boulevard",
|
|
438
|
+
"Assertion Lane",
|
|
439
|
+
"Validate Street",
|
|
440
|
+
"Debug Drive",
|
|
441
|
+
"Fixture Road",
|
|
442
|
+
"Mock Court",
|
|
443
|
+
"Spec Way",
|
|
444
|
+
"Check Circle",
|
|
445
|
+
"Edge Parkway",
|
|
446
|
+
"Suite Plaza",
|
|
447
|
+
"Run Terrace"
|
|
448
|
+
];
|
|
449
|
+
var CITIES = [
|
|
450
|
+
"Testville",
|
|
451
|
+
"QA City",
|
|
452
|
+
"Assertonia",
|
|
453
|
+
"Validateburg",
|
|
454
|
+
"Debugton",
|
|
455
|
+
"Mockford",
|
|
456
|
+
"Specburgh",
|
|
457
|
+
"Fixtureopolis"
|
|
458
|
+
];
|
|
459
|
+
var EVIL_XSS = [
|
|
460
|
+
`<script>alert('xss')</script>`,
|
|
461
|
+
`"><img src=x onerror=alert('xss')>`,
|
|
462
|
+
`'><svg/onload=alert('xss')>`,
|
|
463
|
+
`javascript:alert('xss')`,
|
|
464
|
+
`<img src="x" onerror="alert(document.cookie)">`,
|
|
465
|
+
`<div onmouseover="alert('xss')">hover me</div>`,
|
|
466
|
+
`<script>alert('xss')</script>`,
|
|
467
|
+
`<iframe src="javascript:alert('xss')"></iframe>`,
|
|
468
|
+
`<body onload=alert('xss')>`,
|
|
469
|
+
`<input onfocus=alert('xss') autofocus>`
|
|
470
|
+
];
|
|
471
|
+
var EVIL_SQL = [
|
|
472
|
+
`'; DROP TABLE users; --`,
|
|
473
|
+
`1 OR 1=1`,
|
|
474
|
+
`' UNION SELECT * FROM users --`,
|
|
475
|
+
`1; UPDATE users SET role='admin' WHERE 1=1; --`,
|
|
476
|
+
`' OR '1'='1`,
|
|
477
|
+
`'; EXEC xp_cmdshell('whoami'); --`,
|
|
478
|
+
`1' AND (SELECT COUNT(*) FROM users) > 0 --`,
|
|
479
|
+
`admin'--`,
|
|
480
|
+
`' OR 1=1 LIMIT 1 --`,
|
|
481
|
+
`'; INSERT INTO log VALUES('pwned'); --`
|
|
482
|
+
];
|
|
483
|
+
var EVIL_UNICODE = [
|
|
484
|
+
"\uFEFF",
|
|
485
|
+
"Reverse",
|
|
486
|
+
"АВС",
|
|
487
|
+
"À́̂̃̄",
|
|
488
|
+
"���",
|
|
489
|
+
"\u2028\u2029",
|
|
490
|
+
"\x00\x01\x02",
|
|
491
|
+
"\uD800",
|
|
492
|
+
"a͏a",
|
|
493
|
+
""
|
|
494
|
+
];
|
|
495
|
+
var EVIL_EMOJI = [
|
|
496
|
+
"\uD83D\uDC68\uD83D\uDC69\uD83D\uDC67\uD83D\uDC66",
|
|
497
|
+
"\uD83D\uDC4B\uD83C\uDFFD",
|
|
498
|
+
"\uD83C\uDDFA\uD83C\uDDF8",
|
|
499
|
+
"\uD83D\uDC68\uD83D\uDCBB",
|
|
500
|
+
"\uD83C\uDFF3️\uD83C\uDF08",
|
|
501
|
+
"\uD83E\uDDD1\uD83E\uDDD1\uD83E\uDDD2",
|
|
502
|
+
"\uD83D\uDE00\uD83D\uDE01\uD83D\uDE02\uD83D\uDE03\uD83D\uDE04",
|
|
503
|
+
"#️⃣",
|
|
504
|
+
"\uD83E\uDEE0",
|
|
505
|
+
"\uD83D\uDE00\uD83D\uDE01\uD83D\uDE02\uD83E\uDD23\uD83D\uDE03\uD83D\uDE04\uD83D\uDE05\uD83D\uDE06\uD83D\uDE07\uD83E\uDD70"
|
|
506
|
+
];
|
|
507
|
+
var EVIL_WHITESPACE = [
|
|
508
|
+
`
|
|
509
|
+
\r\v\f`,
|
|
510
|
+
" ",
|
|
511
|
+
" ",
|
|
512
|
+
" ",
|
|
513
|
+
`\r
|
|
514
|
+
\r
|
|
515
|
+
|
|
516
|
+
\r`,
|
|
517
|
+
"\t\t\t\t\t\t\t\t",
|
|
518
|
+
" ",
|
|
519
|
+
" ",
|
|
520
|
+
" ",
|
|
521
|
+
""
|
|
522
|
+
];
|
|
523
|
+
var EVIL_NULL = [
|
|
524
|
+
"null",
|
|
525
|
+
"undefined",
|
|
526
|
+
"NaN",
|
|
527
|
+
"Infinity",
|
|
528
|
+
"-Infinity",
|
|
529
|
+
"true",
|
|
530
|
+
"false",
|
|
531
|
+
"0",
|
|
532
|
+
"-0",
|
|
533
|
+
"",
|
|
534
|
+
"None",
|
|
535
|
+
"nil",
|
|
536
|
+
"NULL",
|
|
537
|
+
"void",
|
|
538
|
+
"[object Object]"
|
|
539
|
+
];
|
|
540
|
+
var EVIL_PATH = [
|
|
541
|
+
"../../etc/passwd",
|
|
542
|
+
"C:\\windows\\system32\\config\\sam",
|
|
543
|
+
"/dev/null",
|
|
544
|
+
"..\\..\\..\\windows\\system32",
|
|
545
|
+
"file:///etc/passwd",
|
|
546
|
+
"\\\\server\\share\\file",
|
|
547
|
+
"/proc/self/environ",
|
|
548
|
+
"CON",
|
|
549
|
+
"PRN",
|
|
550
|
+
"AUX",
|
|
551
|
+
"NUL"
|
|
552
|
+
];
|
|
553
|
+
var EVIL_FORMAT = [
|
|
554
|
+
"%s%s%s%s%s%s%s%s%s%s",
|
|
555
|
+
"${7*7}",
|
|
556
|
+
'{{constructor.constructor("return this")()}}',
|
|
557
|
+
"#{7*7}",
|
|
558
|
+
"<%= 7*7 %>",
|
|
559
|
+
"{{7*7}}",
|
|
560
|
+
"${toString}",
|
|
561
|
+
"$(whoami)",
|
|
562
|
+
"`whoami`",
|
|
563
|
+
"{${<%[%'\"}}%\\."
|
|
564
|
+
];
|
|
565
|
+
var ALIASES = {
|
|
566
|
+
"NAME.FIRST": "PERSON.FIRST",
|
|
567
|
+
"NAME.LAST": "PERSON.LAST",
|
|
568
|
+
"NAME.FULL": "PERSON.FULL",
|
|
569
|
+
NAME: "PERSON.FULL",
|
|
570
|
+
"TEXT.SENTENCE": "TEXT",
|
|
571
|
+
WORD: "TEXT.SHORT",
|
|
572
|
+
INT: "NUMBER",
|
|
573
|
+
"ADDRESS.POSTAL": "ADDRESS.ZIP"
|
|
574
|
+
};
|
|
575
|
+
function createTestDataGenerator(seed) {
|
|
576
|
+
const actualSeed = seed ?? (Date.now() ^ Math.random() * 4294967296) >>> 0;
|
|
577
|
+
const rng = new SeededRandom(actualSeed);
|
|
578
|
+
const tag = rng.hex(4);
|
|
579
|
+
const cache = new Map;
|
|
580
|
+
function canonicalize(type) {
|
|
581
|
+
const upper = type.toUpperCase();
|
|
582
|
+
return ALIASES[upper] ?? upper;
|
|
583
|
+
}
|
|
584
|
+
function generate(type) {
|
|
585
|
+
const key = canonicalize(type);
|
|
586
|
+
if (cache.has(key))
|
|
587
|
+
return cache.get(key);
|
|
588
|
+
const value = generateFresh(key);
|
|
589
|
+
cache.set(key, value);
|
|
590
|
+
return value;
|
|
591
|
+
}
|
|
592
|
+
function generateFresh(upper) {
|
|
593
|
+
if (upper === "PERSON.FIRST")
|
|
594
|
+
return rng.pick(FIRST_NAMES);
|
|
595
|
+
if (upper === "PERSON.LAST")
|
|
596
|
+
return `Haltija-${tag}`;
|
|
597
|
+
if (upper === "PERSON.FULL")
|
|
598
|
+
return `${generate("PERSON.FIRST")} ${generate("PERSON.LAST")}`;
|
|
599
|
+
if (upper === "EMAIL")
|
|
600
|
+
return `${generate("PERSON.FIRST").toLowerCase()}.${tag}@haltija-test.example`;
|
|
601
|
+
if (upper === "PHONE")
|
|
602
|
+
return `+1-555-0${rng.int(100, 199)}`;
|
|
603
|
+
if (upper === "USERNAME")
|
|
604
|
+
return `test_${generate("PERSON.FIRST").toLowerCase()}_${tag}`;
|
|
605
|
+
if (upper === "PASSWORD")
|
|
606
|
+
return `Test!Pass#${tag}${rng.hex(2)}`;
|
|
607
|
+
if (upper === "TEXT") {
|
|
608
|
+
const len = rng.int(5, 10);
|
|
609
|
+
const words = Array.from({ length: len }, () => rng.pick(WORDS));
|
|
610
|
+
words[0] = words[0][0].toUpperCase() + words[0].slice(1);
|
|
611
|
+
return words.join(" ") + ".";
|
|
612
|
+
}
|
|
613
|
+
if (upper === "TEXT.SHORT")
|
|
614
|
+
return rng.pick(WORDS);
|
|
615
|
+
if (upper === "TEXT.PARAGRAPH") {
|
|
616
|
+
return Array.from({ length: rng.int(3, 6) }, () => generateFresh("TEXT")).join(" ");
|
|
617
|
+
}
|
|
618
|
+
if (upper === "NUMBER")
|
|
619
|
+
return String(rng.int(1, 9999));
|
|
620
|
+
const rangeMatch = upper.match(/^NUMBER\.RANGE\((\d+),\s*(\d+)\)$/);
|
|
621
|
+
if (rangeMatch)
|
|
622
|
+
return String(rng.int(parseInt(rangeMatch[1]), parseInt(rangeMatch[2])));
|
|
623
|
+
if (upper === "UUID")
|
|
624
|
+
return `hj-${rng.hex(8)}-${rng.hex(4)}-${rng.hex(4)}-${rng.hex(4)}-${rng.hex(12)}`;
|
|
625
|
+
if (upper === "DATE") {
|
|
626
|
+
const y = rng.int(2024, 2026), m = rng.int(1, 12), d = rng.int(1, 28);
|
|
627
|
+
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
628
|
+
}
|
|
629
|
+
if (upper === "DATE.FUTURE")
|
|
630
|
+
return new Date(Date.now() + rng.int(1, 365) * 86400000).toISOString().slice(0, 10);
|
|
631
|
+
if (upper === "DATE.PAST")
|
|
632
|
+
return new Date(Date.now() - rng.int(1, 365) * 86400000).toISOString().slice(0, 10);
|
|
633
|
+
if (upper === "URL")
|
|
634
|
+
return `https://haltija-test.example/${tag}`;
|
|
635
|
+
if (upper === "COMPANY")
|
|
636
|
+
return `${rng.pick(COMPANIES)} ${tag}`;
|
|
637
|
+
if (upper === "ADDRESS.STREET")
|
|
638
|
+
return `${rng.int(1, 9999)} ${rng.pick(STREETS)}`;
|
|
639
|
+
if (upper === "ADDRESS.CITY")
|
|
640
|
+
return rng.pick(CITIES);
|
|
641
|
+
if (upper === "ADDRESS.ZIP")
|
|
642
|
+
return `555${String(rng.int(0, 99)).padStart(2, "0")}`;
|
|
643
|
+
if (upper === "ADDRESS.FULL")
|
|
644
|
+
return `${generateFresh("ADDRESS.STREET")}, ${generateFresh("ADDRESS.CITY")} ${generateFresh("ADDRESS.ZIP")}`;
|
|
645
|
+
if (upper === "EVIL.XSS")
|
|
646
|
+
return rng.pick(EVIL_XSS);
|
|
647
|
+
if (upper === "EVIL.SQL")
|
|
648
|
+
return rng.pick(EVIL_SQL);
|
|
649
|
+
if (upper === "EVIL.UNICODE")
|
|
650
|
+
return rng.pick(EVIL_UNICODE);
|
|
651
|
+
if (upper === "EVIL.EMOJI")
|
|
652
|
+
return rng.pick(EVIL_EMOJI);
|
|
653
|
+
if (upper === "EVIL.WHITESPACE")
|
|
654
|
+
return rng.pick(EVIL_WHITESPACE);
|
|
655
|
+
if (upper === "EVIL.LONG")
|
|
656
|
+
return "A".repeat(1e4);
|
|
657
|
+
if (upper === "EVIL.EMPTY")
|
|
658
|
+
return "";
|
|
659
|
+
if (upper === "EVIL.NULL")
|
|
660
|
+
return rng.pick(EVIL_NULL);
|
|
661
|
+
if (upper === "EVIL.PATH")
|
|
662
|
+
return rng.pick(EVIL_PATH);
|
|
663
|
+
if (upper === "EVIL.FORMAT")
|
|
664
|
+
return rng.pick(EVIL_FORMAT);
|
|
665
|
+
if (upper === "EVIL") {
|
|
666
|
+
const cats = ["XSS", "SQL", "UNICODE", "EMOJI", "WHITESPACE", "NULL", "PATH", "FORMAT"];
|
|
667
|
+
return generateFresh(`EVIL.${rng.pick(cats)}`);
|
|
668
|
+
}
|
|
669
|
+
return `[unknown:${upper}]`;
|
|
670
|
+
}
|
|
671
|
+
return { generate, seed: actualSeed };
|
|
672
|
+
}
|
|
673
|
+
function substituteGeneratedVars(text, seed) {
|
|
674
|
+
const gen = createTestDataGenerator(seed);
|
|
675
|
+
const generated = {};
|
|
676
|
+
const result = text.replace(/\$\{GEN\.([^}]+)\}/g, (_match, type) => {
|
|
677
|
+
const value = gen.generate(type.trim());
|
|
678
|
+
generated[`GEN.${type.trim()}`] = value;
|
|
679
|
+
return value;
|
|
680
|
+
});
|
|
681
|
+
return { result, seed: gen.seed, generated };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// bin/cli-subcommand.mjs
|
|
685
|
+
var __dirname2 = dirname(fileURLToPath(import.meta.url));
|
|
686
|
+
var hintsPath = join(__dirname2, "hints.json");
|
|
687
|
+
var COMMAND_HINTS = existsSync(hintsPath) ? JSON.parse(readFileSync(hintsPath, "utf-8")) : {};
|
|
688
|
+
var GET_ENDPOINTS = new Set([
|
|
689
|
+
"location",
|
|
690
|
+
"events",
|
|
691
|
+
"console",
|
|
692
|
+
"windows",
|
|
693
|
+
"recordings",
|
|
694
|
+
"status",
|
|
695
|
+
"version",
|
|
696
|
+
"docs",
|
|
697
|
+
"api",
|
|
698
|
+
"stats"
|
|
699
|
+
]);
|
|
700
|
+
var COMPOUND_PATHS = {
|
|
701
|
+
styles: "/inspect",
|
|
702
|
+
"mutations-watch": "/mutations/watch",
|
|
703
|
+
"mutations-unwatch": "/mutations/unwatch",
|
|
704
|
+
"mutations-status": "/mutations/status",
|
|
705
|
+
"events-watch": "/events/watch",
|
|
706
|
+
"events-unwatch": "/events/unwatch",
|
|
707
|
+
"events-stats": "/events/stats",
|
|
708
|
+
"select-start": "/select/start",
|
|
709
|
+
"select-cancel": "/select/cancel",
|
|
710
|
+
"select-status": "/select/status",
|
|
711
|
+
"select-result": "/select/result",
|
|
712
|
+
"select-clear": "/select/clear",
|
|
713
|
+
"tabs-open": "/tabs/open",
|
|
714
|
+
"tabs-close": "/tabs/close",
|
|
715
|
+
"tabs-focus": "/tabs/focus",
|
|
716
|
+
"video-start": "/video/start",
|
|
717
|
+
"video-stop": "/video/stop",
|
|
718
|
+
"video-status": "/video/status",
|
|
719
|
+
"recording-start": "/recording/start",
|
|
720
|
+
"recording-stop": "/recording/stop",
|
|
721
|
+
"recording-generate": "/recording/generate",
|
|
722
|
+
"test-run": "/test/run",
|
|
723
|
+
"test-suite": "/test/suite",
|
|
724
|
+
"test-validate": "/test/validate",
|
|
725
|
+
"send-message": "/send/message",
|
|
726
|
+
"send-selection": "/send/selection",
|
|
727
|
+
"send-recording": "/send/recording"
|
|
728
|
+
};
|
|
729
|
+
var GET_COMPOUND = new Set([
|
|
730
|
+
"mutations-status",
|
|
731
|
+
"events-stats",
|
|
732
|
+
"select-status",
|
|
733
|
+
"select-result",
|
|
734
|
+
"video-status"
|
|
735
|
+
]);
|
|
736
|
+
var ARG_MAPS = {
|
|
737
|
+
click: (args) => parseClickArgs(args),
|
|
738
|
+
type: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), text: args.slice(1).join(" ") }),
|
|
739
|
+
key: (args) => ({ key: args[0], ...parseModifiers(args.slice(1)) }),
|
|
740
|
+
drag: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), deltaX: num(args[1]), deltaY: num(args[2]) }),
|
|
741
|
+
scroll: (args) => parseScrollArgs(args),
|
|
742
|
+
navigate: (args) => ({ url: args[0] }),
|
|
743
|
+
eval: (args) => ({ code: args.join(" ") }),
|
|
744
|
+
query: (args) => ({ selector: args[0] }),
|
|
745
|
+
inspect: (args) => parseInspectArgs(args),
|
|
746
|
+
inspectAll: (args) => parseInspectArgs(args),
|
|
747
|
+
styles: (args) => ({ ...parseTargetArgs(args), matchedRules: true }),
|
|
748
|
+
tree: (args) => parseTreeArgs(args),
|
|
749
|
+
highlight: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), label: args[1] }),
|
|
750
|
+
unhighlight: () => ({}),
|
|
751
|
+
find: (args) => ({ text: args.join(" ") }),
|
|
752
|
+
wait: (args) => parseWaitArgs(args),
|
|
753
|
+
call: (args) => ({ ...parseTargetArgs(args.slice(0, 1)), method: args[1], args: args.slice(2).map(tryParseJSON) }),
|
|
754
|
+
fetch: (args) => ({ url: args[0], prompt: args.slice(1).join(" ") || undefined }),
|
|
755
|
+
screenshot: (args) => {
|
|
756
|
+
const dataUrl = args.includes("--data-url");
|
|
757
|
+
const filtered = args.filter((a) => a !== "--data-url");
|
|
758
|
+
return { ...parseTargetArgs(filtered), file: !dataUrl };
|
|
759
|
+
},
|
|
760
|
+
snapshot: (args) => ({ context: args.join(" ") || undefined }),
|
|
761
|
+
select: (args) => ({ action: args[0] }),
|
|
762
|
+
"select-start": () => ({}),
|
|
763
|
+
"select-cancel": () => ({}),
|
|
764
|
+
"select-clear": () => ({}),
|
|
765
|
+
refresh: (args) => args.includes("--soft") ? { soft: true } : {},
|
|
766
|
+
"tabs-open": (args) => ({ url: args[0] }),
|
|
767
|
+
"tabs-close": (args) => ({ window: args[0] }),
|
|
768
|
+
"tabs-focus": (args) => ({ window: args[0] }),
|
|
769
|
+
"video-start": (args) => {
|
|
770
|
+
const body = {};
|
|
771
|
+
for (let i = 0;i < args.length; i++) {
|
|
772
|
+
if (args[i] === "--maxDuration" || args[i] === "--max-duration")
|
|
773
|
+
body.maxDuration = num(args[++i]);
|
|
774
|
+
}
|
|
775
|
+
return body;
|
|
776
|
+
},
|
|
777
|
+
"video-stop": () => ({}),
|
|
778
|
+
"events-watch": (args) => ({ preset: args[0] || "interactive" }),
|
|
779
|
+
"mutations-watch": (args) => ({ preset: args[0] || "smart" }),
|
|
780
|
+
form: (args) => parseTargetArgs(args),
|
|
781
|
+
"test-run": (args) => {
|
|
782
|
+
if (!args.length) {
|
|
783
|
+
console.error("Usage: hj test-run <file.json> [--vars JSON] [--seed N] [--timeoutMs N] [--allow-failures N]");
|
|
784
|
+
process.exit(1);
|
|
785
|
+
}
|
|
786
|
+
const { files, options, vars } = parseTestArgs(args);
|
|
787
|
+
if (!files.length) {
|
|
788
|
+
console.error("Usage: hj test-run <file.json>");
|
|
789
|
+
process.exit(1);
|
|
790
|
+
}
|
|
791
|
+
const { seed, ...restOptions } = options;
|
|
792
|
+
return { ...readTestFile(files[0], vars, seed), ...restOptions };
|
|
793
|
+
},
|
|
794
|
+
"test-validate": (args) => {
|
|
795
|
+
if (!args.length) {
|
|
796
|
+
console.error("Usage: hj test-validate <file.json> [--vars JSON]");
|
|
797
|
+
process.exit(1);
|
|
798
|
+
}
|
|
799
|
+
const { files, vars, options } = parseTestArgs(args);
|
|
800
|
+
if (!files.length) {
|
|
801
|
+
console.error("Usage: hj test-validate <file.json>");
|
|
802
|
+
process.exit(1);
|
|
803
|
+
}
|
|
804
|
+
return readTestFile(files[0], vars, options.seed);
|
|
805
|
+
},
|
|
806
|
+
"test-suite": (args) => {
|
|
807
|
+
if (!args.length) {
|
|
808
|
+
console.error("Usage: hj test-suite <dir|file...> [--vars JSON] [--seed N] [--timeoutMs N] [--allow-failures N]");
|
|
809
|
+
process.exit(1);
|
|
810
|
+
}
|
|
811
|
+
const { files: rawFiles, options, vars } = parseTestArgs(args);
|
|
812
|
+
const files = expandTestFiles(rawFiles);
|
|
813
|
+
if (!files.length) {
|
|
814
|
+
console.error("Error: No test files found");
|
|
815
|
+
process.exit(1);
|
|
816
|
+
}
|
|
817
|
+
const { seed, ...restOptions } = options;
|
|
818
|
+
const tests = files.map((f) => readTestFile(f, vars, seed).test);
|
|
819
|
+
return { tests, ...restOptions };
|
|
820
|
+
},
|
|
821
|
+
"send-message": (args) => {
|
|
822
|
+
const noSubmit = args.includes("--no-submit");
|
|
823
|
+
const filtered = args.filter((a) => a !== "--no-submit");
|
|
824
|
+
return { agent: filtered[0], message: filtered.slice(1).join(" "), submit: !noSubmit };
|
|
825
|
+
},
|
|
826
|
+
"send-selection": (args) => {
|
|
827
|
+
const noSubmit = args.includes("--no-submit");
|
|
828
|
+
const filtered = args.filter((a) => a !== "--no-submit");
|
|
829
|
+
return { agent: filtered[0], submit: !noSubmit };
|
|
830
|
+
},
|
|
831
|
+
"send-recording": (args) => {
|
|
832
|
+
const noSubmit = args.includes("--no-submit");
|
|
833
|
+
const filtered = args.filter((a) => a !== "--no-submit");
|
|
834
|
+
return { agent: filtered[0], description: filtered.slice(1).join(" ") || undefined, submit: !noSubmit };
|
|
835
|
+
},
|
|
836
|
+
recording: (args) => {
|
|
837
|
+
const action = args[0] || "status";
|
|
838
|
+
if (action === "replay") {
|
|
839
|
+
return { action, id: args[1] };
|
|
840
|
+
}
|
|
841
|
+
if (action === "generate" || action === "start") {
|
|
842
|
+
return { action, name: args.slice(1).join(" ") || undefined };
|
|
843
|
+
}
|
|
844
|
+
return { action };
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
function parseTargetArgs(args) {
|
|
848
|
+
if (!args.length || !args[0])
|
|
849
|
+
return {};
|
|
850
|
+
const target = args[0];
|
|
851
|
+
if (/^@?\d+$/.test(target))
|
|
852
|
+
return { ref: target.replace("@", "") };
|
|
853
|
+
return { selector: target };
|
|
854
|
+
}
|
|
855
|
+
function parseTreeArgs(args) {
|
|
856
|
+
const body = {};
|
|
857
|
+
for (let i = 0;i < args.length; i++) {
|
|
858
|
+
const a = args[i];
|
|
859
|
+
if (a === "--depth" || a === "-d") {
|
|
860
|
+
body.depth = num(args[++i]);
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
if (a === "--selector" || a === "-s") {
|
|
864
|
+
body.selector = args[++i];
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (a === "--compact" || a === "-c") {
|
|
868
|
+
body.compact = true;
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
if (a === "--visible") {
|
|
872
|
+
body.visibleOnly = true;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
if (a === "--text") {
|
|
876
|
+
body.includeText = true;
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
if (a === "--no-text") {
|
|
880
|
+
body.includeText = false;
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (a === "--shadow") {
|
|
884
|
+
body.pierceShadow = true;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (a === "--frames") {
|
|
888
|
+
body.pierceFrames = true;
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (a === "--no-frames") {
|
|
892
|
+
body.pierceFrames = false;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (!a.startsWith("-")) {
|
|
896
|
+
body.selector = a;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return Object.keys(body).length ? body : undefined;
|
|
901
|
+
}
|
|
902
|
+
function parseScrollArgs(args) {
|
|
903
|
+
if (!args.length)
|
|
904
|
+
return {};
|
|
905
|
+
const first = args[0];
|
|
906
|
+
if (first.startsWith(".") || first.startsWith("#") || first.startsWith("[")) {
|
|
907
|
+
return { selector: first };
|
|
908
|
+
}
|
|
909
|
+
if (args.length >= 2 && !isNaN(args[0]) && !isNaN(args[1])) {
|
|
910
|
+
return { deltaX: num(args[0]), deltaY: num(args[1]) };
|
|
911
|
+
}
|
|
912
|
+
if (!isNaN(first))
|
|
913
|
+
return { deltaY: num(first) };
|
|
914
|
+
return parseTargetArgs(args);
|
|
915
|
+
}
|
|
916
|
+
function parseWaitArgs(args) {
|
|
917
|
+
if (!args.length)
|
|
918
|
+
return { ms: 1000 };
|
|
919
|
+
const first = args[0];
|
|
920
|
+
if (!isNaN(first))
|
|
921
|
+
return { ms: num(first) };
|
|
922
|
+
return { ...parseTargetArgs([first]), timeout: args[1] ? num(args[1]) : undefined };
|
|
923
|
+
}
|
|
924
|
+
function parseClickArgs(args) {
|
|
925
|
+
const body = {};
|
|
926
|
+
const positional = [];
|
|
927
|
+
for (let i = 0;i < args.length; i++) {
|
|
928
|
+
const a = args[i];
|
|
929
|
+
if (a === "--diff") {
|
|
930
|
+
body.diff = true;
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (a === "--delay" && args[i + 1]) {
|
|
934
|
+
body.diffDelay = num(args[++i]);
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
if (!a.startsWith("-")) {
|
|
938
|
+
positional.push(a);
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
if (positional.length) {
|
|
943
|
+
const target = positional[0];
|
|
944
|
+
if (/^@?\d+$/.test(target)) {
|
|
945
|
+
body.ref = target.replace("@", "");
|
|
946
|
+
} else {
|
|
947
|
+
body.selector = target;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return Object.keys(body).length ? body : {};
|
|
951
|
+
}
|
|
952
|
+
function parseInspectArgs(args) {
|
|
953
|
+
const body = {};
|
|
954
|
+
const positional = [];
|
|
955
|
+
for (let i = 0;i < args.length; i++) {
|
|
956
|
+
const a = args[i];
|
|
957
|
+
if (a === "--full-styles" || a === "--styles") {
|
|
958
|
+
body.fullStyles = true;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
if (a === "--matched-rules" || a === "--rules") {
|
|
962
|
+
body.matchedRules = true;
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
if (a === "--ancestors") {
|
|
966
|
+
body.ancestors = true;
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (!a.startsWith("-")) {
|
|
970
|
+
positional.push(a);
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
if (positional.length) {
|
|
975
|
+
const target = positional[0];
|
|
976
|
+
if (/^@?\d+$/.test(target)) {
|
|
977
|
+
body.ref = target.replace("@", "");
|
|
978
|
+
} else {
|
|
979
|
+
body.selector = target;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
return Object.keys(body).length ? body : undefined;
|
|
983
|
+
}
|
|
984
|
+
function parseModifiers(args) {
|
|
985
|
+
const mods = {};
|
|
986
|
+
for (const a of args) {
|
|
987
|
+
if (a === "--ctrl" || a === "-c")
|
|
988
|
+
mods.ctrl = true;
|
|
989
|
+
if (a === "--shift" || a === "-s")
|
|
990
|
+
mods.shift = true;
|
|
991
|
+
if (a === "--alt" || a === "-a")
|
|
992
|
+
mods.alt = true;
|
|
993
|
+
if (a === "--meta" || a === "-m")
|
|
994
|
+
mods.meta = true;
|
|
995
|
+
}
|
|
996
|
+
return Object.keys(mods).length ? mods : {};
|
|
997
|
+
}
|
|
998
|
+
function substituteVars(text, vars = {}, seed) {
|
|
999
|
+
let genInfo = null;
|
|
1000
|
+
if (/\$\{GEN\./i.test(text)) {
|
|
1001
|
+
genInfo = substituteGeneratedVars(text, seed);
|
|
1002
|
+
text = genInfo.result;
|
|
1003
|
+
}
|
|
1004
|
+
const result = text.replace(/\$\{([^}]+)\}/g, (match, varName) => {
|
|
1005
|
+
const trimmed = varName.trim();
|
|
1006
|
+
if (trimmed in vars)
|
|
1007
|
+
return vars[trimmed];
|
|
1008
|
+
if (trimmed in process.env)
|
|
1009
|
+
return process.env[trimmed];
|
|
1010
|
+
return match;
|
|
1011
|
+
});
|
|
1012
|
+
return { text: result, genInfo };
|
|
1013
|
+
}
|
|
1014
|
+
function readTestFile(filePath, vars = {}, seed) {
|
|
1015
|
+
if (!existsSync(filePath)) {
|
|
1016
|
+
console.error(`Error: File not found: ${filePath}`);
|
|
1017
|
+
process.exit(1);
|
|
1018
|
+
}
|
|
1019
|
+
try {
|
|
1020
|
+
const content = readFileSync(filePath, "utf-8");
|
|
1021
|
+
const { text: processed, genInfo } = substituteVars(content, vars, seed);
|
|
1022
|
+
if (genInfo && Object.keys(genInfo.generated).length > 0) {
|
|
1023
|
+
const dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
1024
|
+
console.error(dim(`[test-data] seed: ${genInfo.seed}`));
|
|
1025
|
+
for (const [key, value] of Object.entries(genInfo.generated)) {
|
|
1026
|
+
const display = value.length > 60 ? value.slice(0, 57) + "..." : value;
|
|
1027
|
+
console.error(dim(` ${key} = ${JSON.stringify(display)}`));
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
const parsed = JSON.parse(processed);
|
|
1031
|
+
return { test: parsed };
|
|
1032
|
+
} catch (err) {
|
|
1033
|
+
console.error(`Error: Failed to parse ${filePath}: ${err.message}`);
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
function parseTestArgs(args) {
|
|
1038
|
+
const files = [];
|
|
1039
|
+
const options = {};
|
|
1040
|
+
let vars = {};
|
|
1041
|
+
let i = 0;
|
|
1042
|
+
while (i < args.length) {
|
|
1043
|
+
const arg = args[i];
|
|
1044
|
+
if (arg === "--timeoutMs" && args[i + 1]) {
|
|
1045
|
+
options.timeout = parseInt(args[i + 1], 10);
|
|
1046
|
+
i += 2;
|
|
1047
|
+
} else if (arg === "--allow-failures" && args[i + 1]) {
|
|
1048
|
+
options.patience = parseInt(args[i + 1], 10);
|
|
1049
|
+
i += 2;
|
|
1050
|
+
} else if (arg === "--allow-failures-streak" && args[i + 1]) {
|
|
1051
|
+
options.patienceStreak = parseInt(args[i + 1], 10);
|
|
1052
|
+
i += 2;
|
|
1053
|
+
} else if (arg === "--step-delay" && args[i + 1]) {
|
|
1054
|
+
options.stepDelay = parseInt(args[i + 1], 10);
|
|
1055
|
+
i += 2;
|
|
1056
|
+
} else if (arg === "--seed" && args[i + 1]) {
|
|
1057
|
+
options.seed = parseInt(args[i + 1], 10);
|
|
1058
|
+
i += 2;
|
|
1059
|
+
} else if (arg === "--vars" && args[i + 1]) {
|
|
1060
|
+
try {
|
|
1061
|
+
vars = { ...vars, ...JSON.parse(args[i + 1]) };
|
|
1062
|
+
} catch (err) {
|
|
1063
|
+
console.error(`Error: Invalid JSON for --vars: ${args[i + 1]}`);
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
i += 2;
|
|
1067
|
+
} else if (arg.startsWith("--")) {
|
|
1068
|
+
i++;
|
|
1069
|
+
} else {
|
|
1070
|
+
files.push(arg);
|
|
1071
|
+
i++;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
return { files, options, vars };
|
|
1075
|
+
}
|
|
1076
|
+
function expandTestFiles(args) {
|
|
1077
|
+
const files = [];
|
|
1078
|
+
for (const arg of args) {
|
|
1079
|
+
if (!existsSync(arg)) {
|
|
1080
|
+
console.error(`Error: Not found: ${arg}`);
|
|
1081
|
+
process.exit(1);
|
|
1082
|
+
}
|
|
1083
|
+
const stat = statSync(arg);
|
|
1084
|
+
if (stat.isDirectory()) {
|
|
1085
|
+
const jsonFiles = readdirSync(arg).filter((f) => f.endsWith(".json")).sort().map((f) => join(arg, f));
|
|
1086
|
+
files.push(...jsonFiles);
|
|
1087
|
+
} else {
|
|
1088
|
+
files.push(arg);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return files;
|
|
1092
|
+
}
|
|
1093
|
+
function num(s) {
|
|
1094
|
+
return s != null ? Number(s) : undefined;
|
|
1095
|
+
}
|
|
1096
|
+
function tryParseJSON(s) {
|
|
1097
|
+
try {
|
|
1098
|
+
return JSON.parse(s);
|
|
1099
|
+
} catch {
|
|
1100
|
+
return s;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
function clean(obj) {
|
|
1104
|
+
if (!obj)
|
|
1105
|
+
return;
|
|
1106
|
+
const result = {};
|
|
1107
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
1108
|
+
if (v !== undefined)
|
|
1109
|
+
result[k] = v;
|
|
1110
|
+
}
|
|
1111
|
+
return Object.keys(result).length ? result : undefined;
|
|
1112
|
+
}
|
|
1113
|
+
async function isServerRunning(port) {
|
|
1114
|
+
try {
|
|
1115
|
+
const resp = await fetch(`http://localhost:${port}/status`, {
|
|
1116
|
+
signal: AbortSignal.timeout(1000)
|
|
1117
|
+
});
|
|
1118
|
+
return resp.ok;
|
|
1119
|
+
} catch {
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
function resolveServerPath() {
|
|
1124
|
+
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
|
1125
|
+
const execDir = dirname(process.execPath);
|
|
1126
|
+
const bundledServerPath = join(execDir, `haltija-server-${arch}`);
|
|
1127
|
+
const devServerPath = join(__dirname2, "../dist/server.js");
|
|
1128
|
+
if (existsSync(bundledServerPath)) {
|
|
1129
|
+
return { type: "bundled", path: bundledServerPath };
|
|
1130
|
+
} else if (existsSync(devServerPath)) {
|
|
1131
|
+
return { type: "dev", path: devServerPath };
|
|
1132
|
+
}
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
async function startServerInBackground(port) {
|
|
1136
|
+
const resolved = resolveServerPath();
|
|
1137
|
+
if (!resolved) {
|
|
1138
|
+
console.error("Error: Server not found. Run `bun run build` first.");
|
|
1139
|
+
process.exit(1);
|
|
1140
|
+
}
|
|
1141
|
+
let command, cmdArgs;
|
|
1142
|
+
if (resolved.type === "bundled") {
|
|
1143
|
+
command = resolved.path;
|
|
1144
|
+
cmdArgs = [];
|
|
1145
|
+
} else {
|
|
1146
|
+
command = "bun";
|
|
1147
|
+
cmdArgs = ["run", resolved.path];
|
|
1148
|
+
try {
|
|
1149
|
+
const { execSync } = await import("child_process");
|
|
1150
|
+
execSync("bun --version", { stdio: "ignore" });
|
|
1151
|
+
} catch {
|
|
1152
|
+
command = "node";
|
|
1153
|
+
cmdArgs = [resolved.path];
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
const child = spawn(command, cmdArgs, {
|
|
1157
|
+
env: { ...process.env, DEV_CHANNEL_PORT: String(port) },
|
|
1158
|
+
stdio: "ignore",
|
|
1159
|
+
detached: true
|
|
1160
|
+
});
|
|
1161
|
+
child.unref();
|
|
1162
|
+
const maxWait = 5000;
|
|
1163
|
+
const start = Date.now();
|
|
1164
|
+
while (Date.now() - start < maxWait) {
|
|
1165
|
+
if (await isServerRunning(port))
|
|
1166
|
+
return true;
|
|
1167
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1168
|
+
}
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1171
|
+
async function runSubcommand(subcommand, subArgs, port = "8700") {
|
|
1172
|
+
const baseUrl = `http://localhost:${port}`;
|
|
1173
|
+
const jsonOutput = subArgs.includes("--json");
|
|
1174
|
+
let filteredArgs = subArgs.filter((a) => a !== "--json");
|
|
1175
|
+
let targetWindowId = undefined;
|
|
1176
|
+
const windowIdx = filteredArgs.indexOf("--window");
|
|
1177
|
+
if (windowIdx !== -1) {
|
|
1178
|
+
targetWindowId = filteredArgs[windowIdx + 1];
|
|
1179
|
+
filteredArgs = [...filteredArgs.slice(0, windowIdx), ...filteredArgs.slice(windowIdx + 2)];
|
|
1180
|
+
}
|
|
1181
|
+
if (!await isServerRunning(port)) {
|
|
1182
|
+
process.stderr.write("\x1B[2mStarting Haltija server...\x1B[0m");
|
|
1183
|
+
const started = await startServerInBackground(port);
|
|
1184
|
+
if (started) {
|
|
1185
|
+
process.stderr.write(`\x1B[2m done\x1B[0m
|
|
1186
|
+
`);
|
|
1187
|
+
} else {
|
|
1188
|
+
process.stderr.write(`
|
|
1189
|
+
`);
|
|
1190
|
+
console.error("Error: Could not start server. Run `haltija --server` in another terminal.");
|
|
1191
|
+
process.exit(1);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (subcommand === "send") {
|
|
1195
|
+
const firstArg = filteredArgs[0]?.toLocaleLowerCase();
|
|
1196
|
+
if (firstArg === "selection") {
|
|
1197
|
+
subcommand = "send-selection";
|
|
1198
|
+
filteredArgs.shift();
|
|
1199
|
+
} else if (firstArg === "recording") {
|
|
1200
|
+
subcommand = "send-recording";
|
|
1201
|
+
filteredArgs.shift();
|
|
1202
|
+
} else {
|
|
1203
|
+
subcommand = "send-message";
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
const path = COMPOUND_PATHS[subcommand] || `/${subcommand}`;
|
|
1207
|
+
const isGet = GET_ENDPOINTS.has(subcommand) || GET_COMPOUND.has(subcommand);
|
|
1208
|
+
let body = undefined;
|
|
1209
|
+
if (!isGet) {
|
|
1210
|
+
const mapper = ARG_MAPS[subcommand];
|
|
1211
|
+
if (mapper) {
|
|
1212
|
+
body = clean(mapper(filteredArgs));
|
|
1213
|
+
} else if (filteredArgs.length) {
|
|
1214
|
+
const joined = filteredArgs.join(" ");
|
|
1215
|
+
try {
|
|
1216
|
+
body = JSON.parse(joined);
|
|
1217
|
+
} catch {
|
|
1218
|
+
body = parseTargetArgs(filteredArgs);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
if (targetWindowId) {
|
|
1223
|
+
if (isGet) {
|
|
1224
|
+
const url2 = new URL(path, baseUrl);
|
|
1225
|
+
url2.searchParams.set("window", targetWindowId);
|
|
1226
|
+
return doRequest(url2.toString(), "GET", undefined, { subcommand, jsonOutput });
|
|
1227
|
+
} else {
|
|
1228
|
+
if (!body)
|
|
1229
|
+
body = {};
|
|
1230
|
+
body.window = targetWindowId;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
const url = `${baseUrl}${path}`;
|
|
1234
|
+
return doRequest(url, isGet ? "GET" : "POST", body, { subcommand, jsonOutput });
|
|
1235
|
+
}
|
|
1236
|
+
async function doRequest(url, method, body, context = {}) {
|
|
1237
|
+
const { subcommand, jsonOutput } = context;
|
|
1238
|
+
try {
|
|
1239
|
+
const opts = { method };
|
|
1240
|
+
if (body) {
|
|
1241
|
+
opts.headers = { "Content-Type": "application/json" };
|
|
1242
|
+
opts.body = JSON.stringify(body);
|
|
1243
|
+
}
|
|
1244
|
+
const resp = await fetch(url, opts);
|
|
1245
|
+
const contentType = resp.headers.get("content-type") || "";
|
|
1246
|
+
if (contentType.includes("application/json")) {
|
|
1247
|
+
const json = await resp.json();
|
|
1248
|
+
if (!jsonOutput && subcommand === "tree" && json.success && json.data) {
|
|
1249
|
+
console.log(formatTree(json.data));
|
|
1250
|
+
} else if (!jsonOutput && subcommand === "events" && (json.events || Array.isArray(json))) {
|
|
1251
|
+
console.log(formatEvents(json));
|
|
1252
|
+
} else if (!jsonOutput && subcommand === "test-run" && json.test) {
|
|
1253
|
+
console.log(formatTestResult(json));
|
|
1254
|
+
} else if (!jsonOutput && subcommand === "test-suite" && json.results) {
|
|
1255
|
+
console.log(formatSuiteResult(json));
|
|
1256
|
+
} else if (!jsonOutput && subcommand === "screenshot" && json.data?.path) {
|
|
1257
|
+
const bold = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
1258
|
+
const dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
1259
|
+
console.log(bold(json.data.path));
|
|
1260
|
+
const meta = [json.data.width && json.data.height ? `${json.data.width}×${json.data.height}` : null, json.data.format, json.data.source].filter(Boolean).join(", ");
|
|
1261
|
+
if (meta)
|
|
1262
|
+
console.log(dim(meta));
|
|
1263
|
+
} else if (!jsonOutput && subcommand === "video-stop" && json.data?.path) {
|
|
1264
|
+
const bold = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
1265
|
+
const dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
1266
|
+
console.log(bold(json.data.path));
|
|
1267
|
+
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(", ");
|
|
1268
|
+
if (meta)
|
|
1269
|
+
console.log(dim(meta));
|
|
1270
|
+
} else {
|
|
1271
|
+
console.log(JSON.stringify(json, null, 2));
|
|
1272
|
+
}
|
|
1273
|
+
} else {
|
|
1274
|
+
const text = await resp.text();
|
|
1275
|
+
console.log(text);
|
|
1276
|
+
}
|
|
1277
|
+
if (resp.ok && !jsonOutput) {
|
|
1278
|
+
const hint = COMMAND_HINTS[subcommand];
|
|
1279
|
+
if (hint) {
|
|
1280
|
+
const dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
1281
|
+
console.log(dim(`
|
|
1282
|
+
hj ${subcommand} : ${hint}`));
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if (!resp.ok) {
|
|
1286
|
+
process.exit(1);
|
|
1287
|
+
}
|
|
1288
|
+
} catch (err) {
|
|
1289
|
+
if (err.cause?.code === "ECONNREFUSED") {
|
|
1290
|
+
console.error("Error: Cannot connect to Haltija server.");
|
|
1291
|
+
console.error("Start the server with: haltija --server");
|
|
1292
|
+
} else {
|
|
1293
|
+
console.error(`Error: ${err.message}`);
|
|
1294
|
+
}
|
|
1295
|
+
process.exit(1);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
var KNOWN_COMMANDS = new Set([
|
|
1299
|
+
"tree",
|
|
1300
|
+
"query",
|
|
1301
|
+
"inspect",
|
|
1302
|
+
"inspectAll",
|
|
1303
|
+
"styles",
|
|
1304
|
+
"find",
|
|
1305
|
+
"click",
|
|
1306
|
+
"type",
|
|
1307
|
+
"key",
|
|
1308
|
+
"drag",
|
|
1309
|
+
"scroll",
|
|
1310
|
+
"call",
|
|
1311
|
+
"navigate",
|
|
1312
|
+
"refresh",
|
|
1313
|
+
"location",
|
|
1314
|
+
"events",
|
|
1315
|
+
"events-watch",
|
|
1316
|
+
"events-unwatch",
|
|
1317
|
+
"console",
|
|
1318
|
+
"mutations-watch",
|
|
1319
|
+
"mutations-unwatch",
|
|
1320
|
+
"mutations-status",
|
|
1321
|
+
"eval",
|
|
1322
|
+
"fetch",
|
|
1323
|
+
"screenshot",
|
|
1324
|
+
"snapshot",
|
|
1325
|
+
"highlight",
|
|
1326
|
+
"unhighlight",
|
|
1327
|
+
"select-start",
|
|
1328
|
+
"select-result",
|
|
1329
|
+
"select-cancel",
|
|
1330
|
+
"select-clear",
|
|
1331
|
+
"windows",
|
|
1332
|
+
"tabs-open",
|
|
1333
|
+
"tabs-close",
|
|
1334
|
+
"tabs-focus",
|
|
1335
|
+
"video-start",
|
|
1336
|
+
"video-stop",
|
|
1337
|
+
"video-status",
|
|
1338
|
+
"recording",
|
|
1339
|
+
"recording-start",
|
|
1340
|
+
"recording-stop",
|
|
1341
|
+
"recording-generate",
|
|
1342
|
+
"recordings",
|
|
1343
|
+
"test-run",
|
|
1344
|
+
"test-validate",
|
|
1345
|
+
"test-suite",
|
|
1346
|
+
"send",
|
|
1347
|
+
"send-message",
|
|
1348
|
+
"send-selection",
|
|
1349
|
+
"send-recording",
|
|
1350
|
+
"status",
|
|
1351
|
+
"version",
|
|
1352
|
+
"docs",
|
|
1353
|
+
"api",
|
|
1354
|
+
"stats"
|
|
1355
|
+
]);
|
|
1356
|
+
var COMMAND_ALIASES = {
|
|
1357
|
+
open: "navigate",
|
|
1358
|
+
goto: "navigate",
|
|
1359
|
+
go: "navigate",
|
|
1360
|
+
url: "navigate",
|
|
1361
|
+
load: "navigate",
|
|
1362
|
+
get: "tree",
|
|
1363
|
+
dom: "tree",
|
|
1364
|
+
page: "tree",
|
|
1365
|
+
input: "type",
|
|
1366
|
+
write: "type",
|
|
1367
|
+
enter: "key",
|
|
1368
|
+
press: "key",
|
|
1369
|
+
run: "eval",
|
|
1370
|
+
js: "eval",
|
|
1371
|
+
exec: "eval",
|
|
1372
|
+
shot: "screenshot",
|
|
1373
|
+
capture: "screenshot",
|
|
1374
|
+
ls: "tree",
|
|
1375
|
+
list: "tree",
|
|
1376
|
+
show: "tree",
|
|
1377
|
+
help: "--help"
|
|
1378
|
+
};
|
|
1379
|
+
function isSubcommand(arg) {
|
|
1380
|
+
if (!arg || arg.startsWith("-"))
|
|
1381
|
+
return false;
|
|
1382
|
+
if (/^\d+$/.test(arg))
|
|
1383
|
+
return false;
|
|
1384
|
+
return KNOWN_COMMANDS.has(arg);
|
|
1385
|
+
}
|
|
1386
|
+
function getSuggestion(cmd) {
|
|
1387
|
+
if (COMMAND_ALIASES[cmd]) {
|
|
1388
|
+
return COMMAND_ALIASES[cmd];
|
|
1389
|
+
}
|
|
1390
|
+
for (const known of KNOWN_COMMANDS) {
|
|
1391
|
+
if (known.startsWith(cmd) || cmd.startsWith(known.slice(0, 3))) {
|
|
1392
|
+
return known;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
return null;
|
|
1396
|
+
}
|
|
1397
|
+
function listSubcommands() {
|
|
1398
|
+
return `
|
|
1399
|
+
Subcommands (replace curl with simple commands):
|
|
1400
|
+
${bold("Inspect")}
|
|
1401
|
+
tree [selector] [-d depth] DOM tree with ref IDs
|
|
1402
|
+
query <selector> Find elements matching selector
|
|
1403
|
+
inspect <@ref|selector> Detailed element info
|
|
1404
|
+
inspectAll <selector> Deep inspect all matches
|
|
1405
|
+
find <text> Find elements by text content
|
|
1406
|
+
|
|
1407
|
+
${bold("Interact")}
|
|
1408
|
+
click <@ref|selector|"text"> Click an element
|
|
1409
|
+
type <@ref|selector> <text> Type text into element
|
|
1410
|
+
key <key> [--ctrl --shift] Press a key
|
|
1411
|
+
drag <@ref|selector> <dx> <dy> Drag element
|
|
1412
|
+
scroll [selector|dy] Scroll page or element
|
|
1413
|
+
call <@ref|selector> <method> Call element method/get property
|
|
1414
|
+
|
|
1415
|
+
${bold("Navigate")}
|
|
1416
|
+
navigate <url> Go to URL
|
|
1417
|
+
refresh [--soft] Reload page (hard by default)
|
|
1418
|
+
location Current URL and title
|
|
1419
|
+
|
|
1420
|
+
${bold("Observe")}
|
|
1421
|
+
events Get semantic events
|
|
1422
|
+
events-watch [preset] Start watching events
|
|
1423
|
+
events-unwatch Stop watching events
|
|
1424
|
+
console Get console output
|
|
1425
|
+
mutations-watch [preset] Start watching DOM changes
|
|
1426
|
+
mutations-unwatch Stop watching
|
|
1427
|
+
mutations-status Check mutation watcher
|
|
1428
|
+
|
|
1429
|
+
${bold("Evaluate")}
|
|
1430
|
+
eval <code> Run JavaScript in browser
|
|
1431
|
+
fetch <url> [prompt] Fetch and process URL
|
|
1432
|
+
|
|
1433
|
+
${bold("Capture")}
|
|
1434
|
+
screenshot [@ref|selector] Take screenshot (saves to /tmp)
|
|
1435
|
+
snapshot [context] Full page state capture
|
|
1436
|
+
highlight <@ref|selector> Highlight element
|
|
1437
|
+
unhighlight Remove highlights
|
|
1438
|
+
video-start [--maxDuration s] Start video recording
|
|
1439
|
+
video-stop Stop recording, get file path
|
|
1440
|
+
video-status Check recording state
|
|
1441
|
+
|
|
1442
|
+
${bold("Selection")}
|
|
1443
|
+
select-start Begin region selection
|
|
1444
|
+
select-result Get selection result
|
|
1445
|
+
select-cancel Cancel selection
|
|
1446
|
+
select-clear Clear selection
|
|
1447
|
+
|
|
1448
|
+
${bold("Windows")}
|
|
1449
|
+
windows List browser windows
|
|
1450
|
+
tabs-open <url> Open new tab
|
|
1451
|
+
tabs-close <windowId> Close tab
|
|
1452
|
+
tabs-focus <windowId> Focus tab
|
|
1453
|
+
|
|
1454
|
+
${bold("Recording")}
|
|
1455
|
+
recording start [name] Start recording (survives page navigations)
|
|
1456
|
+
recording stop Stop recording and save
|
|
1457
|
+
recording list List saved recordings
|
|
1458
|
+
recording replay <id|index> Replay a saved recording
|
|
1459
|
+
recording generate [name] Generate test from last recording
|
|
1460
|
+
|
|
1461
|
+
${bold("Send to Agent")}
|
|
1462
|
+
send <agent> <message> Send message to agent (auto-submits)
|
|
1463
|
+
send selection [agent] Send browser selection to agent
|
|
1464
|
+
send recording [agent] Send last recording to agent
|
|
1465
|
+
--no-submit Paste only, don't auto-submit
|
|
1466
|
+
|
|
1467
|
+
${bold("Testing")}
|
|
1468
|
+
test-run <json> [options] Run a test
|
|
1469
|
+
test-suite <dir|files...> Run all tests in dir (alphabetical)
|
|
1470
|
+
test-validate <json> Validate test format
|
|
1471
|
+
|
|
1472
|
+
Test options:
|
|
1473
|
+
--vars <json> Template variables: '{"APP_URL": "http://localhost:5050"}'
|
|
1474
|
+
Replaces \${VAR_NAME} in test files. Falls back to env vars.
|
|
1475
|
+
--timeoutMs <ms> Step timeout (default 5000)
|
|
1476
|
+
--allow-failures <n> Total failures before giving up (0=stop on first)
|
|
1477
|
+
--allow-failures-streak <n> Consecutive failures to bail (default 2)
|
|
1478
|
+
--step-delay <ms> Delay between steps (default 100)
|
|
1479
|
+
|
|
1480
|
+
${bold("Info")}
|
|
1481
|
+
status Server status
|
|
1482
|
+
version Server version
|
|
1483
|
+
docs API documentation
|
|
1484
|
+
api Full API reference
|
|
1485
|
+
stats Usage statistics
|
|
1486
|
+
|
|
1487
|
+
${bold("Options")}
|
|
1488
|
+
--window <id> Target specific window
|
|
1489
|
+
--port <n> Server port (default: 8700)
|
|
1490
|
+
|
|
1491
|
+
${bold("Examples")}
|
|
1492
|
+
hj tree # See the page
|
|
1493
|
+
hj tree -d 5 # Deeper tree
|
|
1494
|
+
hj click 42 # Click by ref
|
|
1495
|
+
hj click "#submit" # Click by selector
|
|
1496
|
+
hj type 10 Hello world # Type text
|
|
1497
|
+
hj key Enter # Press Enter
|
|
1498
|
+
hj key a --ctrl # Ctrl+A
|
|
1499
|
+
hj eval document.title # Get page title
|
|
1500
|
+
hj navigate https://example.com
|
|
1501
|
+
hj events # See what happened
|
|
1502
|
+
hj send claude "check this" # Message an agent
|
|
1503
|
+
hj send selection # Send selection to agent
|
|
1504
|
+
`;
|
|
1505
|
+
}
|
|
1506
|
+
function bold(s) {
|
|
1507
|
+
return `\x1B[1m${s}\x1B[0m`;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// bin/hj.mjs
|
|
1511
|
+
var args = process.argv.slice(2);
|
|
1512
|
+
if (!args.length || args.includes("--help") || args.includes("-h")) {
|
|
1513
|
+
const bold2 = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
1514
|
+
const dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
1515
|
+
console.log(`
|
|
1516
|
+
${bold2("hj")} - Haltija command-line interface
|
|
1517
|
+
|
|
1518
|
+
Usage: hj <command> [args...]
|
|
1519
|
+
${listSubcommands()}
|
|
1520
|
+
Run ${dim("hj --help")} for this help.
|
|
1521
|
+
Run ${dim("haltija --help")} for server/app options.
|
|
1522
|
+
`);
|
|
1523
|
+
process.exit(0);
|
|
1524
|
+
}
|
|
1525
|
+
var port = process.env.DEV_CHANNEL_PORT || "8700";
|
|
1526
|
+
var portIdx = args.indexOf("--port");
|
|
1527
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
1528
|
+
port = args[portIdx + 1];
|
|
1529
|
+
args.splice(portIdx, 2);
|
|
1530
|
+
}
|
|
1531
|
+
var subcommand = args[0];
|
|
1532
|
+
var subArgs = args.slice(1).filter((a) => a !== "--window" || true);
|
|
1533
|
+
if (!isSubcommand(subcommand)) {
|
|
1534
|
+
const suggestion = getSuggestion(subcommand);
|
|
1535
|
+
if (suggestion === "--help") {
|
|
1536
|
+
const topic = args[1];
|
|
1537
|
+
if (topic) {
|
|
1538
|
+
filterHelp(topic);
|
|
1539
|
+
} else {
|
|
1540
|
+
console.log(listSubcommands());
|
|
1541
|
+
}
|
|
1542
|
+
process.exit(0);
|
|
1543
|
+
}
|
|
1544
|
+
let msg = `Unknown command: '${subcommand}'`;
|
|
1545
|
+
if (suggestion) {
|
|
1546
|
+
msg += ` — did you mean '${suggestion}'?`;
|
|
1547
|
+
}
|
|
1548
|
+
console.error(msg);
|
|
1549
|
+
console.error(`
|
|
1550
|
+
Examples: hj tree, hj navigate <url>, hj click @42`);
|
|
1551
|
+
console.error(`Run 'hj' for docs.`);
|
|
1552
|
+
process.exit(1);
|
|
1553
|
+
} else {
|
|
1554
|
+
runSubcommand(subcommand, subArgs, port);
|
|
1555
|
+
}
|
|
1556
|
+
function filterHelp(topic) {
|
|
1557
|
+
const bold2 = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
1558
|
+
const dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
1559
|
+
const needle = topic.toLowerCase();
|
|
1560
|
+
const helpText = listSubcommands();
|
|
1561
|
+
const lines = helpText.split(`
|
|
1562
|
+
`);
|
|
1563
|
+
const matches = [];
|
|
1564
|
+
let currentCategory = "";
|
|
1565
|
+
for (const line of lines) {
|
|
1566
|
+
if (line.match(/^\s{2}\x1b\[1m/)) {
|
|
1567
|
+
currentCategory = line;
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
const stripped = line.replace(/\x1b\[[0-9;]*m/g, "").toLowerCase();
|
|
1571
|
+
if (stripped.trim() && stripped.includes(needle)) {
|
|
1572
|
+
matches.push({ category: currentCategory, line });
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (matches.length === 0) {
|
|
1576
|
+
console.log(`No commands matching '${topic}'.`);
|
|
1577
|
+
console.log(`Run ${dim("hj help")} to see all commands.`);
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
console.log(`
|
|
1581
|
+
Commands matching '${bold2(topic)}':
|
|
1582
|
+
`);
|
|
1583
|
+
let lastCategory = "";
|
|
1584
|
+
for (const m of matches) {
|
|
1585
|
+
if (m.category && m.category !== lastCategory) {
|
|
1586
|
+
console.log(m.category);
|
|
1587
|
+
lastCategory = m.category;
|
|
1588
|
+
}
|
|
1589
|
+
console.log(m.line);
|
|
1590
|
+
}
|
|
1591
|
+
const hintMatches = Object.entries(COMMAND_HINTS).filter(([cmd, hint]) => cmd.toLowerCase().includes(needle) || hint.toLowerCase().includes(needle));
|
|
1592
|
+
if (hintMatches.length > 0) {
|
|
1593
|
+
console.log(`
|
|
1594
|
+
${bold2("Hints")}`);
|
|
1595
|
+
for (const [cmd, hint] of hintMatches) {
|
|
1596
|
+
console.log(` ${bold2(cmd.padEnd(28))} ${dim(hint)}`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
console.log("");
|
|
1600
|
+
}
|