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/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
+ }