haltija 1.11.0 → 1.11.2
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/CHANGELOG.md +83 -0
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +126 -11
- package/bin/cli-subcommand.mjs +23 -10
- package/bin/hints.mjs +27 -0
- package/bin/hj.mjs +54 -0
- package/bin/project-origins.mjs +77 -0
- package/bin/version.mjs +1 -1
- package/dist/api-schema.d.ts +3 -0
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +126 -11
- package/dist/component.js +126 -11
- package/dist/hj.js +154 -19
- package/dist/index.js +133 -15
- package/dist/project-origins.d.ts +67 -0
- package/dist/server.js +133 -15
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.11.2
|
|
4
|
+
|
|
5
|
+
Patch. Four field reports from tosijs-3d and tosijs-ui, all confirmed.
|
|
6
|
+
|
|
7
|
+
### Fixed: `--canvas` couldn't reach a canvas in a shadow root ([#15](https://github.com/tonioloewald/haltija/issues/15))
|
|
8
|
+
|
|
9
|
+
Which is where every component-based renderer puts it — so the exact-pixels escape hatch failed on
|
|
10
|
+
exactly the pages where pixels are the only thing worth looking at. Canvas resolution now pierces
|
|
11
|
+
shadow DOM and accepts every shape someone would reasonably write:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
hj screenshot --canvas "tosi-b3d canvas" # descendant, crossing the boundary
|
|
15
|
+
hj screenshot --canvas "tosi-b3d >>> canvas" # explicit piercing form
|
|
16
|
+
hj screenshot --canvas "canvas" # found inside shadow roots too
|
|
17
|
+
hj screenshot --canvas # no selector: the largest canvas on the page
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
A genuine miss now lists the canvases that *do* exist, with working selectors. The **schematic**
|
|
21
|
+
embeds shadow-root canvases as well — previously it silently showed none on these pages.
|
|
22
|
+
|
|
23
|
+
### Fixed: advisory hints were printed to stdout ([#14](https://github.com/tonioloewald/haltija/issues/14))
|
|
24
|
+
|
|
25
|
+
A dim hint line was appended to **stdout** after JSON output, so `JSON.parse(await $\`hj windows\`)`
|
|
26
|
+
threw — and an adopter's readiness probe fell into an open catch and silently did nothing. All
|
|
27
|
+
advisory text is on stderr now; stdout is the data channel.
|
|
28
|
+
|
|
29
|
+
Two related fixes from the same report:
|
|
30
|
+
|
|
31
|
+
- **`hj <cmd> --help` now describes that command** instead of falling through to global help, which
|
|
32
|
+
read exactly like "unknown command" (a reporter concluded `doctor` and `map` didn't exist in their
|
|
33
|
+
build). haltija's own error messages recommend this form, so the remedy we printed was broken.
|
|
34
|
+
- **The standalone `hj` bundle now carries its hints.** They were read from a sibling `hints.json`,
|
|
35
|
+
which doesn't exist next to `~/.local/bin/hj` — so two distributions reporting the same version
|
|
36
|
+
produced different output. Hints are compiled in, like the version and semver helpers.
|
|
37
|
+
|
|
38
|
+
### Fixed: contrast false positives on text-less ancestors ([#13](https://github.com/tonioloewald/haltija/issues/13))
|
|
39
|
+
|
|
40
|
+
A container propagates `color`/`background` but has no font size, so `large` is *unknowable* —
|
|
41
|
+
defaulting it to false held it to 4.5:1 and manufactured failures for text that passes as large on
|
|
42
|
+
the child that actually renders it. About half the findings on a typical page. Only elements with
|
|
43
|
+
their own direct text are graded now.
|
|
44
|
+
|
|
45
|
+
## 1.11.1
|
|
46
|
+
|
|
47
|
+
Patch, per the rule that a minor bump waits for a cleared backlog and a nine-lens review.
|
|
48
|
+
|
|
49
|
+
### Fixed: contrast audit noise
|
|
50
|
+
|
|
51
|
+
Two sources of false findings, fixed before anyone acts on a long list of them:
|
|
52
|
+
|
|
53
|
+
- **Containers with no text of their own were flagged.** A `<div>`/`<form>` inherits a colour but
|
|
54
|
+
displays nothing, so a "failure" there is noise. Only elements that actually render text, a label
|
|
55
|
+
or a value get a verdict now.
|
|
56
|
+
- **Text over a `background-image`** (gradient, photo) was judged against whatever background-*color*
|
|
57
|
+
sat beneath it — which can be wrong in either direction. Those are now reported as
|
|
58
|
+
`colors.uncertain` rather than asserted as pass or fail.
|
|
59
|
+
|
|
60
|
+
An audit people learn to skim is worth nothing, so the bar is: only claim what can be justified.
|
|
61
|
+
|
|
62
|
+
### Per-tab routing, by declaration ([#1](https://github.com/tonioloewald/haltija/issues/1), [#2](https://github.com/tonioloewald/haltija/issues/2))
|
|
63
|
+
|
|
64
|
+
cwd routing gets a command to the right *server*; which **tab** answers then fell back to focus, so
|
|
65
|
+
two projects on a shared server could drive each other's pages. Ranking tabs by "this origin looks
|
|
66
|
+
like your project" was rejected twice — there's no reliable origin→directory map, and a
|
|
67
|
+
usually-right guess reintroduces the silent misroute cwd routing exists to prevent.
|
|
68
|
+
|
|
69
|
+
So the project declares it. A `.haltija.json` at the project root:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{ "origins": ["https://localhost:8030", "http://localhost:3000"] }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
…and `hj`, run anywhere inside that project, pins commands to a connected tab on one of those
|
|
76
|
+
origins regardless of focus. **Entirely opt-in** (no file = unchanged behaviour) and it never
|
|
77
|
+
guesses: if you declared origins and no connected tab matches, `hj` says so loudly instead of
|
|
78
|
+
quietly driving another project's page — and refuses outright under `--strict`. `HALTIJA_ORIGINS`
|
|
79
|
+
overrides for one-off shells and CI.
|
|
80
|
+
|
|
81
|
+
### `hj screenshot --schematic`
|
|
82
|
+
|
|
83
|
+
Ask for the schematic even when real capture *is* available — it's cheaper, deterministic, and
|
|
84
|
+
carries the contrast audit. Canvases are still embedded as real pixels.
|
|
85
|
+
|
|
3
86
|
## 1.11.0
|
|
4
87
|
|
|
5
88
|
### Errors now tell you what to do
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.11.
|
|
49
|
+
var VERSION = "1.11.2";
|
|
50
50
|
|
|
51
51
|
// src/text-selector.ts
|
|
52
52
|
var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
|
|
@@ -1294,9 +1294,90 @@
|
|
|
1294
1294
|
...buildDomAffordances(opts.maxNodes)
|
|
1295
1295
|
};
|
|
1296
1296
|
}
|
|
1297
|
+
function findCanvasesDeep(root = document) {
|
|
1298
|
+
const found = [];
|
|
1299
|
+
const visit = (node) => {
|
|
1300
|
+
for (const el of Array.from(node.querySelectorAll("*"))) {
|
|
1301
|
+
if (el.tagName === "CANVAS")
|
|
1302
|
+
found.push(el);
|
|
1303
|
+
const sr = el.shadowRoot;
|
|
1304
|
+
if (sr)
|
|
1305
|
+
visit(sr);
|
|
1306
|
+
}
|
|
1307
|
+
};
|
|
1308
|
+
visit(root);
|
|
1309
|
+
return found;
|
|
1310
|
+
}
|
|
1311
|
+
function resolveCanvasDeep(selector) {
|
|
1312
|
+
const all = findCanvasesDeep();
|
|
1313
|
+
if (!selector || !String(selector).trim()) {
|
|
1314
|
+
if (!all.length)
|
|
1315
|
+
return { canvas: null };
|
|
1316
|
+
const largest = all.slice().sort((a, b) => b.width * b.height - a.width * a.height)[0];
|
|
1317
|
+
return {
|
|
1318
|
+
canvas: largest,
|
|
1319
|
+
note: all.length > 1 ? `page has ${all.length} canvases; captured the largest (${largest.width}×${largest.height}). Pass a selector to choose another.` : undefined
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
const sel = String(selector).trim();
|
|
1323
|
+
if (sel.includes(">>>")) {
|
|
1324
|
+
const parts = sel.split(">>>").map((p) => p.trim()).filter(Boolean);
|
|
1325
|
+
let scope = document;
|
|
1326
|
+
let el = null;
|
|
1327
|
+
for (const part of parts) {
|
|
1328
|
+
if (!scope)
|
|
1329
|
+
return { canvas: null };
|
|
1330
|
+
el = scope.querySelector(part);
|
|
1331
|
+
if (!el)
|
|
1332
|
+
return { canvas: null };
|
|
1333
|
+
scope = el.shadowRoot || el;
|
|
1334
|
+
}
|
|
1335
|
+
return { canvas: el && el.tagName === "CANVAS" ? el : scope?.querySelector?.("canvas") || null };
|
|
1336
|
+
}
|
|
1337
|
+
const light = resolveSelector(sel);
|
|
1338
|
+
if (light && light.tagName === "CANVAS")
|
|
1339
|
+
return { canvas: light };
|
|
1340
|
+
if (light) {
|
|
1341
|
+
const inHost = light.shadowRoot?.querySelector("canvas");
|
|
1342
|
+
if (inHost)
|
|
1343
|
+
return { canvas: inHost };
|
|
1344
|
+
}
|
|
1345
|
+
const tokens = sel.split(/\s+/).filter(Boolean);
|
|
1346
|
+
if (tokens.length > 1) {
|
|
1347
|
+
const descend = (scope, i) => {
|
|
1348
|
+
if (i >= tokens.length)
|
|
1349
|
+
return null;
|
|
1350
|
+
const rest = tokens.slice(i).join(" ");
|
|
1351
|
+
const direct = scope.querySelector(rest);
|
|
1352
|
+
if (direct && direct.tagName === "CANVAS")
|
|
1353
|
+
return direct;
|
|
1354
|
+
for (const cand of Array.from(scope.querySelectorAll(tokens[i]))) {
|
|
1355
|
+
const sr = cand.shadowRoot;
|
|
1356
|
+
if (!sr)
|
|
1357
|
+
continue;
|
|
1358
|
+
const hit = descend(sr, i + 1);
|
|
1359
|
+
if (hit)
|
|
1360
|
+
return hit;
|
|
1361
|
+
}
|
|
1362
|
+
return null;
|
|
1363
|
+
};
|
|
1364
|
+
const crossed = descend(document, 0);
|
|
1365
|
+
if (crossed)
|
|
1366
|
+
return { canvas: crossed };
|
|
1367
|
+
}
|
|
1368
|
+
for (const el of Array.from(document.querySelectorAll("*"))) {
|
|
1369
|
+
const sr = el.shadowRoot;
|
|
1370
|
+
if (!sr)
|
|
1371
|
+
continue;
|
|
1372
|
+
const hit = sr.querySelector(sel);
|
|
1373
|
+
if (hit && hit.tagName === "CANVAS")
|
|
1374
|
+
return { canvas: hit };
|
|
1375
|
+
}
|
|
1376
|
+
return { canvas: null };
|
|
1377
|
+
}
|
|
1297
1378
|
function collectCanvasThumbnails(maxEdge = 320) {
|
|
1298
1379
|
const out = [];
|
|
1299
|
-
for (const el of
|
|
1380
|
+
for (const el of findCanvasesDeep()) {
|
|
1300
1381
|
const c = el;
|
|
1301
1382
|
if (!c.width || !c.height)
|
|
1302
1383
|
continue;
|
|
@@ -1472,8 +1553,12 @@
|
|
|
1472
1553
|
let node = el;
|
|
1473
1554
|
let acc = { r: 255, g: 255, b: 255 };
|
|
1474
1555
|
const stack = [];
|
|
1556
|
+
let imaged = false;
|
|
1475
1557
|
while (node) {
|
|
1476
|
-
const
|
|
1558
|
+
const cs = getComputedStyle(node);
|
|
1559
|
+
if (cs.backgroundImage && cs.backgroundImage !== "none")
|
|
1560
|
+
imaged = true;
|
|
1561
|
+
const c = parseCssColor(cs.backgroundColor);
|
|
1477
1562
|
if (c && c.a > 0) {
|
|
1478
1563
|
stack.push(c);
|
|
1479
1564
|
if (c.a === 1)
|
|
@@ -1483,7 +1568,7 @@
|
|
|
1483
1568
|
}
|
|
1484
1569
|
for (let i = stack.length - 1;i >= 0; i--)
|
|
1485
1570
|
acc = over(stack[i], acc);
|
|
1486
|
-
return acc;
|
|
1571
|
+
return { ...acc, imaged };
|
|
1487
1572
|
}
|
|
1488
1573
|
var relLuminance = (c) => {
|
|
1489
1574
|
const ch = (v) => {
|
|
@@ -1516,7 +1601,8 @@
|
|
|
1516
1601
|
border,
|
|
1517
1602
|
contrast: Math.round(ratio * 10) / 10,
|
|
1518
1603
|
passes: ratio >= (large ? 3 : 4.5),
|
|
1519
|
-
large
|
|
1604
|
+
large,
|
|
1605
|
+
...bg.imaged ? { uncertain: true } : {}
|
|
1520
1606
|
};
|
|
1521
1607
|
}
|
|
1522
1608
|
function elementNotFoundMessage(target) {
|
|
@@ -1560,8 +1646,11 @@
|
|
|
1560
1646
|
try {
|
|
1561
1647
|
const c = probeColors(el);
|
|
1562
1648
|
node.colors = c;
|
|
1563
|
-
|
|
1649
|
+
const hasOwnText = Array.from(el.childNodes).some((n) => n.nodeType === 3 && (n.textContent || "").trim().length > 0);
|
|
1650
|
+
const hasReadableText = hasOwnText || !!(node.label || node.value);
|
|
1651
|
+
if (!c.passes && hasReadableText && !c.uncertain) {
|
|
1564
1652
|
node.contrastFail = `${c.contrast}:1 (needs ${c.large ? 3 : 4.5}:1)`;
|
|
1653
|
+
}
|
|
1565
1654
|
} catch {}
|
|
1566
1655
|
return node;
|
|
1567
1656
|
};
|
|
@@ -6261,10 +6350,34 @@ ${elementSummary}${moreText}`;
|
|
|
6261
6350
|
targetSelector = element.id ? `#${element.id}` : element.getAttribute("data-testid") ? `[data-testid="${element.getAttribute("data-testid")}"]` : undefined;
|
|
6262
6351
|
}
|
|
6263
6352
|
}
|
|
6264
|
-
if (payload2?.
|
|
6265
|
-
const
|
|
6353
|
+
if (payload2?.schematic) {
|
|
6354
|
+
const map = buildAffordanceMap({});
|
|
6355
|
+
const canvases = collectCanvasThumbnails();
|
|
6356
|
+
const { svg, width, height } = renderMapSchematic(map, canvases, "SCHEMATIC — requested (not a screenshot)");
|
|
6357
|
+
const image = await rasterizeSchematic(svg, width, height, payload2?.scale || 2);
|
|
6358
|
+
this.respond(msg2.id, true, {
|
|
6359
|
+
image,
|
|
6360
|
+
viewport,
|
|
6361
|
+
format: "png",
|
|
6362
|
+
width,
|
|
6363
|
+
height,
|
|
6364
|
+
source: "schematic",
|
|
6365
|
+
requested: true,
|
|
6366
|
+
canvasesRendered: canvases.filter((c) => c.image).length,
|
|
6367
|
+
map
|
|
6368
|
+
});
|
|
6369
|
+
return;
|
|
6370
|
+
}
|
|
6371
|
+
if (payload2?.canvas !== undefined) {
|
|
6372
|
+
const resolved = resolveCanvasDeep(payload2.canvas);
|
|
6373
|
+
const el = resolved.canvas;
|
|
6266
6374
|
if (!el) {
|
|
6267
|
-
|
|
6375
|
+
const all = findCanvasesDeep();
|
|
6376
|
+
const inventory = all.length ? `Found ${all.length} canvas element(s): ` + all.map((c) => {
|
|
6377
|
+
const host = c.getRootNode()?.host;
|
|
6378
|
+
return `${host ? host.tagName.toLowerCase() + " >>> " : ""}canvas${c.id ? "#" + c.id : ""} (${c.width}×${c.height})`;
|
|
6379
|
+
}).join(", ") + `. Try one of those, or omit the selector to capture the largest.` : `No <canvas> exists on this page (shadow roots included). \`hj map\` shows what is here.`;
|
|
6380
|
+
this.respond(msg2.id, false, null, `Canvas not found: ${payload2.canvas || "(largest)"}. ${inventory}`);
|
|
6268
6381
|
return;
|
|
6269
6382
|
}
|
|
6270
6383
|
if (typeof el.toDataURL !== "function") {
|
|
@@ -6323,9 +6436,11 @@ ${elementSummary}${moreText}`;
|
|
|
6323
6436
|
height: targetH,
|
|
6324
6437
|
source: "canvas",
|
|
6325
6438
|
canvas: {
|
|
6326
|
-
selector: payload2.canvas,
|
|
6439
|
+
selector: payload2.canvas || "(largest)",
|
|
6327
6440
|
intrinsic: { width: el.width, height: el.height },
|
|
6328
|
-
displayed: { width: el.clientWidth, height: el.clientHeight }
|
|
6441
|
+
displayed: { width: el.clientWidth, height: el.clientHeight },
|
|
6442
|
+
inShadowRoot: el.getRootNode()?.host ? el.getRootNode().host.tagName.toLowerCase() : undefined,
|
|
6443
|
+
...resolved.note ? { note: resolved.note } : {}
|
|
6329
6444
|
},
|
|
6330
6445
|
...warning ? { warning } : {}
|
|
6331
6446
|
});
|
package/bin/cli-subcommand.mjs
CHANGED
|
@@ -31,10 +31,12 @@ import { differsBeyondPatch } from './semver.mjs'
|
|
|
31
31
|
|
|
32
32
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
33
33
|
|
|
34
|
-
// Command hints
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
// Command hints — generated from api-schema.ts during build and IMPORTED, not read from disk.
|
|
35
|
+
// Reading a sibling hints.json works for the npm package but not for dist/hj.js installed as a lone
|
|
36
|
+
// file in ~/.local/bin, so the standalone CLI silently had no hints while claiming the same version
|
|
37
|
+
// (issue #14). An import is inlined by the bundler, so both distributions behave identically.
|
|
38
|
+
export { COMMAND_HINTS } from './hints.mjs'
|
|
39
|
+
import { COMMAND_HINTS as COMMAND_HINTS_LOCAL } from './hints.mjs'
|
|
38
40
|
|
|
39
41
|
let warnedAboutSkew = false
|
|
40
42
|
|
|
@@ -155,9 +157,17 @@ export const ARG_MAPS = {
|
|
|
155
157
|
if (a === '--no-chyron') { body.chyron = false; continue }
|
|
156
158
|
// Read a <canvas>'s own pixels (WebGL/2D) instead of capturing the screen: exact pixels, no
|
|
157
159
|
// screen-share grant, works off-screen. The route for 3D scenes / render-to-texture UI.
|
|
158
|
-
if (a === '--canvas') {
|
|
160
|
+
if (a === '--canvas') {
|
|
161
|
+
// Bare `--canvas` (no selector) captures the largest canvas on the page — which is
|
|
162
|
+
// unambiguous when there's one interesting canvas, the common case.
|
|
163
|
+
const next = args[i + 1]
|
|
164
|
+
body.canvas = next && !next.startsWith('-') ? args[++i] : ''
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
159
167
|
// Hard-fail instead of returning a labelled schematic when pixels aren't capturable.
|
|
160
168
|
if (a === '--no-fallback') { body.fallback = false; continue }
|
|
169
|
+
// Prefer the schematic outright: cheaper, deterministic, and it carries the contrast audit.
|
|
170
|
+
if (a === '--schematic') { body.schematic = true; continue }
|
|
161
171
|
if (!a.startsWith('-')) { positional.push(a) }
|
|
162
172
|
}
|
|
163
173
|
return { ...body, ...parseTargetArgs(positional) }
|
|
@@ -742,7 +752,7 @@ export const KNOWN_FLAGS = {
|
|
|
742
752
|
inspect: ['--full-styles', '--styles', '--matched-rules', '--rules', '--ancestors'],
|
|
743
753
|
inspectAll: ['--full-styles', '--styles', '--matched-rules', '--rules', '--ancestors'],
|
|
744
754
|
key: ['--ctrl', '-c', '--shift', '-s', '--alt', '-a', '--meta', '-m'],
|
|
745
|
-
screenshot: ['--data-url', '--format', '--quality', '--scale', '--maxWidth', '--max-width', '--maxHeight', '--max-height', '--delay', '--no-chyron', '--canvas', '--no-fallback'],
|
|
755
|
+
screenshot: ['--data-url', '--format', '--quality', '--scale', '--maxWidth', '--max-width', '--maxHeight', '--max-height', '--delay', '--no-chyron', '--canvas', '--no-fallback', '--schematic'],
|
|
746
756
|
'video-start': ['--maxDuration', '--max-duration'],
|
|
747
757
|
refresh: ['--soft'],
|
|
748
758
|
'test-run': ['--vars', '--seed', '--timeoutMs', '--allow-failures', '--allow-failures-streak', '--step-delay'],
|
|
@@ -995,7 +1005,7 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
995
1005
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
996
1006
|
console.log(bold(json.data.path))
|
|
997
1007
|
const meta = [json.data.width && json.data.height ? `${json.data.width}×${json.data.height}` : null, json.data.format, json.data.source].filter(Boolean).join(', ')
|
|
998
|
-
if (meta) console.
|
|
1008
|
+
if (meta) console.error(dim(meta))
|
|
999
1009
|
} else if (!jsonOutput && (subcommand === 'network' || subcommand === 'network-watch') && (json.entries || json.data?.entries || json.summary || json.data?.summary)) {
|
|
1000
1010
|
console.log(formatNetwork(json))
|
|
1001
1011
|
} else if (!jsonOutput && subcommand === 'network-stats') {
|
|
@@ -1005,7 +1015,7 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
1005
1015
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
1006
1016
|
console.log(bold(json.data.path))
|
|
1007
1017
|
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(', ')
|
|
1008
|
-
if (meta) console.
|
|
1018
|
+
if (meta) console.error(dim(meta))
|
|
1009
1019
|
} else if (!jsonOutput && UNWRAP_DATA_SUBCOMMANDS.has(subcommand)) {
|
|
1010
1020
|
// Print the inner DevResponse.data unwrapped so agents (and humans) can
|
|
1011
1021
|
// read it directly. Strings go to stdout as-is — no JSON escaping of
|
|
@@ -1048,10 +1058,13 @@ async function doRequest(url, method, body, context = {}) {
|
|
|
1048
1058
|
// Skip for commands whose stdout is meant to be piped/consumed verbatim
|
|
1049
1059
|
// — agents shouldn't have to strip a trailing hint line.
|
|
1050
1060
|
if (resp.ok && !jsonOutput && !UNWRAP_DATA_SUBCOMMANDS.has(subcommand)) {
|
|
1051
|
-
const hint =
|
|
1061
|
+
const hint = COMMAND_HINTS_LOCAL[subcommand]
|
|
1052
1062
|
if (hint) {
|
|
1053
1063
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
1054
|
-
|
|
1064
|
+
// stderr, NOT stdout. A hint appended to stdout turns parseable JSON into garbage —
|
|
1065
|
+
// an adopter's `JSON.parse(await $`hj windows`)` threw, their catch fell open, and the
|
|
1066
|
+
// readiness probe silently did nothing (issue #14). Advisory text belongs on stderr.
|
|
1067
|
+
console.error(dim(`hj ${subcommand} : ${hint}`))
|
|
1055
1068
|
}
|
|
1056
1069
|
}
|
|
1057
1070
|
|
package/bin/hints.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** ⚠️ AUTO-GENERATED FROM src/api-schema.ts — DO NOT EDIT. Run: bun run build */
|
|
2
|
+
export const COMMAND_HINTS = {
|
|
3
|
+
"tree": "-d 3 (shallow), -i (interactive only), --visible, --compact | see: inspect, query, click",
|
|
4
|
+
"query": "@ref or \"selector\", --all | see: tree, inspect",
|
|
5
|
+
"inspect": "@ref or \"selector\", --styles, --rules, --ancestors | see: tree, query",
|
|
6
|
+
"click": "@ref or \"selector\", :text(Button), --diff | see: tree, wait, type",
|
|
7
|
+
"type": "@ref, --clear, --humanlike false (fast) | see: click, key",
|
|
8
|
+
"key": "<key> --ctrl --shift --alt --meta, --repeat 3 | see: type, click",
|
|
9
|
+
"drag": "@ref or \"selector\" <deltaX> <deltaY>, --duration 500 | see: click, scroll",
|
|
10
|
+
"highlight": "@ref or \"selector\", --label \"text\", --color #f00, --duration 3000 | see: unhighlight, screenshot",
|
|
11
|
+
"scroll": "@ref or \"selector\" or <deltaY>, --duration 500 | see: click, wait",
|
|
12
|
+
"wait": "\"selector\", --text \"content\", --timeout 5000 | see: click, navigate",
|
|
13
|
+
"events": "events-watch first | see: recording, console, mutations-watch",
|
|
14
|
+
"eval": "\"code\" (returns result) | see: console, snapshot",
|
|
15
|
+
"call": "@ref or \"selector\" <method>, --args [...] | see: eval, inspect",
|
|
16
|
+
"screenshot": "[selector], --format webp, --scale 0.5, --maxWidth 800 | see: highlight, snapshot",
|
|
17
|
+
"windows": "--json | see: tabs-open, tabs-close, tabs-focus, status",
|
|
18
|
+
"map": "--json | see: tree, query, inspect",
|
|
19
|
+
"tabs-open": "[url] | see: tabs-focus, tabs-close, windows",
|
|
20
|
+
"tabs-close": "<window-id> | see: windows, tabs-focus, tabs-open",
|
|
21
|
+
"tabs-focus": "<window-id> | see: windows, tabs-close, tabs-open",
|
|
22
|
+
"recording": "start, stop, list, replay <id|index> | see: test-run, events",
|
|
23
|
+
"video-start": "--maxDuration 120 | see: video-stop, video-status, screenshot",
|
|
24
|
+
"video-stop": "| see: video-start, video-status",
|
|
25
|
+
"video-status": "| see: video-start, video-stop",
|
|
26
|
+
"status": "--json | see: windows, stats, console"
|
|
27
|
+
}
|
package/bin/hj.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { runSubcommand, isSubcommand, getSuggestion, listSubcommands, COMMAND_HINTS } from './cli-subcommand.mjs'
|
|
14
14
|
import { extractWindowTarget } from './arg-utils.mjs'
|
|
15
|
+
import { findProjectOrigins, routeByDeclaredOrigin } from './project-origins.mjs'
|
|
15
16
|
import { HJ_VERSION } from './version.mjs'
|
|
16
17
|
import { differsBeyondPatch } from './semver.mjs'
|
|
17
18
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
@@ -350,6 +351,15 @@ function resolveByCwd(cwd, instances) {
|
|
|
350
351
|
return candidates[0]
|
|
351
352
|
}
|
|
352
353
|
|
|
354
|
+
// `hj <cmd> --help` shows help for THAT command. It used to fall into the global help below, which
|
|
355
|
+
// reads exactly like "unknown command" — a reporter reasonably concluded `doctor` and `map` didn't
|
|
356
|
+
// exist in their build (issue #14). Worse, haltija's own error messages tell you to run
|
|
357
|
+
// `hj <cmd> --help`, so the remedy we print was broken: a printed remedy is a testable claim.
|
|
358
|
+
if ((args.includes('--help') || args.includes('-h')) && args[0] && !args[0].startsWith('-')) {
|
|
359
|
+
filterHelp(args[0])
|
|
360
|
+
process.exit(0)
|
|
361
|
+
}
|
|
362
|
+
|
|
353
363
|
if (!args.length || args.includes('--help') || args.includes('-h')) {
|
|
354
364
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
355
365
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
@@ -609,6 +619,50 @@ if (subcommand === 'shutdown' || subcommand === 'quit') {
|
|
|
609
619
|
}
|
|
610
620
|
}
|
|
611
621
|
|
|
622
|
+
// Declared-origin routing (issues #1/#2). cwd routing found the right SERVER; if this project has
|
|
623
|
+
// declared which origins are its pages, pin the command to the matching TAB instead of letting focus
|
|
624
|
+
// decide. Purely opt-in — no `.haltija.json` (or HALTIJA_ORIGINS) means nothing changes.
|
|
625
|
+
//
|
|
626
|
+
// Skipped when the caller already pinned a window (they own the choice), and for the diagnostic
|
|
627
|
+
// commands, which must describe the world rather than act on one tab.
|
|
628
|
+
const DIAGNOSTIC = new Set(['where', 'servers', 'ls', 'doctor', 'shutdown', 'quit', 'status', 'windows', 'version'])
|
|
629
|
+
if (!windowTarget && !DIAGNOSTIC.has(subcommand) && isSubcommand(subcommand)) {
|
|
630
|
+
const declared = findProjectOrigins(process.cwd(), process.env)
|
|
631
|
+
if (declared && declared.origins.length) {
|
|
632
|
+
try {
|
|
633
|
+
const token = process.env.HALTIJA_TOKEN
|
|
634
|
+
const resp = await fetch(`http://localhost:${port}/windows`, {
|
|
635
|
+
headers: token ? { 'X-Haltija-Token': token } : {},
|
|
636
|
+
signal: AbortSignal.timeout(2500),
|
|
637
|
+
})
|
|
638
|
+
if (resp.ok) {
|
|
639
|
+
const { windows: tabs = [], focused } = await resp.json()
|
|
640
|
+
const routed = routeByDeclaredOrigin(declared.origins, tabs, focused)
|
|
641
|
+
if (routed.kind === 'matched') {
|
|
642
|
+
subArgs = [...subArgs, '--window', routed.windowId]
|
|
643
|
+
} else if (routed.kind === 'no-match' && tabs.length) {
|
|
644
|
+
// NEVER fall through silently: this project said which pages are its own, and none is
|
|
645
|
+
// connected. Driving whatever happens to be focused is the exact bug the declaration was
|
|
646
|
+
// added to prevent.
|
|
647
|
+
const saw = routed.sawOrigins.length ? routed.sawOrigins.join(', ') : '(none with a readable origin)'
|
|
648
|
+
const msg =
|
|
649
|
+
`declared origins ${declared.origins.join(', ')} (from ${declared.source}) match no connected tab. ` +
|
|
650
|
+
`Connected: ${saw}.`
|
|
651
|
+
if (STRICT) {
|
|
652
|
+
console.error(`hj: ERROR (strict) — ${msg}`)
|
|
653
|
+
console.error(`hj: open one of your declared origins, fix .haltija.json, or pass --window <id> to choose explicitly.`)
|
|
654
|
+
process.exit(1)
|
|
655
|
+
}
|
|
656
|
+
console.error(`hj: warning — ${msg}`)
|
|
657
|
+
console.error(`hj: proceeding against the FOCUSED tab, which may be another project's page. Pass --window <id> to be sure.`)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
} catch {
|
|
661
|
+
// Routing is an enhancement; never let a probe failure block the command.
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
612
666
|
if (!isSubcommand(subcommand)) {
|
|
613
667
|
const suggestion = getSuggestion(subcommand)
|
|
614
668
|
if (suggestion === '--help') {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** ⚠️ AUTO-GENERATED FROM src/project-origins.ts — DO NOT EDIT. Run: bun run build */
|
|
2
|
+
// src/project-origins.ts
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
4
|
+
import { dirname, join, parse as parsePath } from "path";
|
|
5
|
+
function normalizeOrigin(value) {
|
|
6
|
+
const v = String(value || "").trim();
|
|
7
|
+
if (!v)
|
|
8
|
+
return null;
|
|
9
|
+
const parse = (candidate) => {
|
|
10
|
+
try {
|
|
11
|
+
const origin = new URL(candidate).origin;
|
|
12
|
+
return origin && origin !== "null" ? origin : null;
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
return parse(v) ?? parse(`http://${v}`);
|
|
18
|
+
}
|
|
19
|
+
function findProjectOrigins(cwd, env = process.env) {
|
|
20
|
+
const fromEnv = env.HALTIJA_ORIGINS;
|
|
21
|
+
if (fromEnv && fromEnv.trim()) {
|
|
22
|
+
const origins = fromEnv.split(",").map(normalizeOrigin).filter((o) => !!o);
|
|
23
|
+
if (origins.length)
|
|
24
|
+
return { origins, source: "HALTIJA_ORIGINS env" };
|
|
25
|
+
}
|
|
26
|
+
let dir = cwd;
|
|
27
|
+
const { root } = parsePath(cwd);
|
|
28
|
+
for (let depth = 0;depth < 64; depth++) {
|
|
29
|
+
const file = join(dir, ".haltija.json");
|
|
30
|
+
if (existsSync(file)) {
|
|
31
|
+
try {
|
|
32
|
+
const parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
33
|
+
const raw = Array.isArray(parsed?.origins) ? parsed.origins : [];
|
|
34
|
+
const origins = raw.map(normalizeOrigin).filter((o) => !!o);
|
|
35
|
+
return { origins, source: file };
|
|
36
|
+
} catch {
|
|
37
|
+
return { origins: [], source: `${file} (unreadable or invalid JSON)` };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (dir === root)
|
|
41
|
+
break;
|
|
42
|
+
const parent = dirname(dir);
|
|
43
|
+
if (parent === dir)
|
|
44
|
+
break;
|
|
45
|
+
dir = parent;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function routeByDeclaredOrigin(declared, tabs, focusedWindowId) {
|
|
50
|
+
if (!declared.length)
|
|
51
|
+
return { kind: "no-declaration" };
|
|
52
|
+
const wanted = new Set(declared);
|
|
53
|
+
const topLevel = tabs.filter((t) => (t.windowType || "tab") === "tab");
|
|
54
|
+
const matches = topLevel.filter((t) => {
|
|
55
|
+
const o = normalizeOrigin(t.url || "");
|
|
56
|
+
return o !== null && wanted.has(o);
|
|
57
|
+
});
|
|
58
|
+
if (!matches.length) {
|
|
59
|
+
const sawOrigins = [...new Set(topLevel.map((t) => normalizeOrigin(t.url || "")).filter((o) => !!o))];
|
|
60
|
+
return { kind: "no-match", declared, sawOrigins };
|
|
61
|
+
}
|
|
62
|
+
const visible = matches.filter((t) => t.active !== false);
|
|
63
|
+
const pool = visible.length ? visible : matches;
|
|
64
|
+
const focused = pool.find((t) => t.id === focusedWindowId);
|
|
65
|
+
const chosen = focused || pool[0];
|
|
66
|
+
return {
|
|
67
|
+
kind: "matched",
|
|
68
|
+
windowId: chosen.id,
|
|
69
|
+
origin: normalizeOrigin(chosen.url || ""),
|
|
70
|
+
candidates: matches.length
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export {
|
|
74
|
+
routeByDeclaredOrigin,
|
|
75
|
+
normalizeOrigin,
|
|
76
|
+
findProjectOrigins
|
|
77
|
+
};
|
package/bin/version.mjs
CHANGED
package/dist/api-schema.d.ts
CHANGED
|
@@ -244,6 +244,7 @@ export declare const screenshot: EndpointDef<{
|
|
|
244
244
|
maxHeight?: number | undefined;
|
|
245
245
|
chyron?: boolean | undefined;
|
|
246
246
|
file?: boolean | undefined;
|
|
247
|
+
schematic?: boolean | undefined;
|
|
247
248
|
fallback?: boolean | undefined;
|
|
248
249
|
}>;
|
|
249
250
|
export declare const select: EndpointDef<{
|
|
@@ -546,6 +547,7 @@ export declare const endpoints: {
|
|
|
546
547
|
maxHeight?: number | undefined;
|
|
547
548
|
chyron?: boolean | undefined;
|
|
548
549
|
file?: boolean | undefined;
|
|
550
|
+
schematic?: boolean | undefined;
|
|
549
551
|
fallback?: boolean | undefined;
|
|
550
552
|
}>;
|
|
551
553
|
readonly select: EndpointDef<{
|
|
@@ -816,6 +818,7 @@ export declare const ALL_ENDPOINTS: (EndpointDef<{
|
|
|
816
818
|
maxHeight?: number | undefined;
|
|
817
819
|
chyron?: boolean | undefined;
|
|
818
820
|
file?: boolean | undefined;
|
|
821
|
+
schematic?: boolean | undefined;
|
|
819
822
|
fallback?: boolean | undefined;
|
|
820
823
|
}> | EndpointDef<{
|
|
821
824
|
window?: string | undefined;
|
package/dist/component.d.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* - Option+Tab toggles visibility (but active state always shows briefly)
|
|
21
21
|
* - Localhost only by default
|
|
22
22
|
*/
|
|
23
|
-
export declare const VERSION = "1.11.
|
|
23
|
+
export declare const VERSION = "1.11.2";
|
|
24
24
|
export declare class DevChannel extends HTMLElement {
|
|
25
25
|
static get tagName(): string;
|
|
26
26
|
static elementCreator(): () => DevChannel;
|