haltija 1.13.0-beta.2 → 1.13.0-beta.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/CHANGELOG.md +60 -0
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +14 -4
- package/bin/hj.mjs +49 -5
- package/bin/tosijs-dev.mjs +26 -3
- package/bin/version.mjs +1 -1
- package/dist/component.d.ts +1 -1
- package/dist/component.esm.js +14 -4
- package/dist/component.js +14 -4
- package/dist/hj.js +33 -6
- package/dist/index.js +46 -28
- package/dist/server.js +46 -28
- package/dist/test-in-browser.d.ts +62 -5
- package/dist/test.d.ts +1 -1
- package/dist/test.js +13 -1
- package/dist/version.d.ts +1 -1
- package/docs/CI-INTEGRATION.md +11 -7
- package/docs/TEST-IN-BROWSER.md +142 -0
- package/llms.txt +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.13.0-beta.3 (2026-10-08)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`hj doctor` prints an `observability:` line whenever a tab is connected** (#821, GitHub #50). It
|
|
8
|
+
says whether the tab that commands go to is visible and painting and, for widgets 1.12.6 or
|
|
9
|
+
newer, how long ago it last drew a frame. When
|
|
10
|
+
it isn't, the line says what that invalidates: geometry, screenshots and anything rAF-driven. A
|
|
11
|
+
backgrounded tab still answers, just wrongly, so this matters even when nothing fails. Also in
|
|
12
|
+
`--json` as `observability`.
|
|
13
|
+
|
|
14
|
+
- **`hj map` reads tosijs's shipped agent-surface contract** (#787, GitHub #16). It reads
|
|
15
|
+
`describe().version`, `exposure` and `writable` instead of inferring them, and was verified
|
|
16
|
+
against real tosijs 1.10.6.
|
|
17
|
+
- A read-only surface (a manifest without `write: true`) is reported as read-only. The map used
|
|
18
|
+
to tell agents to call `write()`, which the surface refuses.
|
|
19
|
+
- The callable actions are listed.
|
|
20
|
+
- An empty `'closed'` surface, the tosijs default, comes with a hint on how the app's author
|
|
21
|
+
enables it.
|
|
22
|
+
- A surface version haltija doesn't read produces a warning.
|
|
23
|
+
- The tier is no longer marked experimental, in the skill or in the `/map` API description.
|
|
24
|
+
|
|
25
|
+
- **`playwrightBridge(page)`** in `haltija/test`: runs `testInBrowser` probes in Firefox and WebKit
|
|
26
|
+
through a Playwright page you launch (virta #3088). Tested in all three engines. New
|
|
27
|
+
`docs/TEST-IN-BROWSER.md` covers it, the bridge contract, and which engine each route reaches.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **A bridge that wraps its `eval` reply is now named as the problem** (virta #2415).
|
|
32
|
+
`BrowserBridge.eval` was typed `Promise<any>` with no stated contract, and a bridge returning
|
|
33
|
+
`{ success: true, value }` made every `read()` fail with a bare "probe threw" although the probe
|
|
34
|
+
had run. The return shape is now typed and documented, and the error says the bridge returned no
|
|
35
|
+
`{ ok }` envelope and lists the keys it did return.
|
|
36
|
+
|
|
37
|
+
- Type change: `BrowserBridge.eval` now resolves to `unknown` rather than `any`. Bridges you
|
|
38
|
+
wrote still compile. Code that calls `.eval()` on a `BrowserBridge`-typed value and reads a
|
|
39
|
+
property off the result needs a cast.
|
|
40
|
+
|
|
41
|
+
- **`haltija --server` no longer leaves its server running when the launcher is stopped.** The
|
|
42
|
+
server is a child process, and nothing passed a signal on, so a process manager (or a closed
|
|
43
|
+
terminal) that stopped the launcher left the server alive and holding its ports. SIGTERM, SIGINT
|
|
44
|
+
and SIGHUP now stop both.
|
|
45
|
+
|
|
46
|
+
- **The docs no longer say `--headless` gives Firefox/WebKit coverage.** It launches Chromium and
|
|
47
|
+
has no engine flag. `--help`, the CI guide, the skill and `/llms.txt` all said to pick it for
|
|
48
|
+
multi-engine coverage.
|
|
49
|
+
|
|
50
|
+
- **`hj doctor` checks visibility on the tab commands actually go to**, not across all tabs. A
|
|
51
|
+
hidden target used to pass with a note as long as some other tab was visible, while every
|
|
52
|
+
command went to the hidden one. For a project with declared origins that is the tab its
|
|
53
|
+
commands are pinned to, whichever tab has focus.
|
|
54
|
+
|
|
55
|
+
- **Freeing a busy HTTPS port is now deliberate.** It used to probe the HTTPS port with plain HTTP,
|
|
56
|
+
which always failed, so the holder was refused as "a server we could not identify". That was the
|
|
57
|
+
right outcome only by accident, and the message misled (#1079).
|
|
58
|
+
- The HTTPS port is freed only when it was itself explicitly requested (`--https-port` /
|
|
59
|
+
`DEV_CHANNEL_HTTPS_PORT`). Being strict about the HTTP port says nothing about the HTTPS one,
|
|
60
|
+
and stopping a server's HTTPS listener stops its whole process, HTTP included.
|
|
61
|
+
- The holder is identified over TLS and asked to stop, under the same rules as the HTTP port.
|
|
62
|
+
|
|
3
63
|
## 1.13.0-beta.2 (2026-09-29)
|
|
4
64
|
|
|
5
65
|
### Changed
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.13.0-beta.
|
|
49
|
+
var VERSION = "1.13.0-beta.3";
|
|
50
50
|
|
|
51
51
|
// src/ws-url.ts
|
|
52
52
|
function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
|
|
@@ -1374,16 +1374,26 @@
|
|
|
1374
1374
|
const description = agent.describe();
|
|
1375
1375
|
const wiring = description?.wiring;
|
|
1376
1376
|
const shapeWarning = wiring === undefined ? `${globalName}.describe() returned no 'wiring' — this haltija understands the agent ` + `surface as { wiring: [{ tag, id, label, on }] }. The map below is passed through ` + `unchanged and may be missing structure; the schematic will be empty. Check the ` + `tosijs version, or use hj map with the DOM fallback (a page without tosiAgent).` : !Array.isArray(wiring) ? `${globalName}.describe().wiring is ${typeof wiring}, expected an array — passing it ` + `through unchanged, but the schematic cannot render it.` : undefined;
|
|
1377
|
+
const d = description;
|
|
1378
|
+
const surfaceVersion = d?.version ?? agent.version;
|
|
1379
|
+
const surfaceMajor = surfaceVersion && typeof surfaceVersion === "object" && typeof surfaceVersion.surface === "string" ? surfaceVersion.surface.split(".")[0] : null;
|
|
1380
|
+
const versionWarning = surfaceMajor !== null && surfaceMajor !== "1" ? `${globalName} reports agent surface ${surfaceVersion.surface}; this haltija reads surface ` + `1.x, so fields may be missing or misread. Update haltija.` : undefined;
|
|
1381
|
+
const warning = [shapeWarning, versionWarning].filter(Boolean).join(" ") || undefined;
|
|
1382
|
+
const closedHint = d?.exposure === "closed" && Array.isArray(wiring) && wiring.length === 0 ? `The app exposes no wiring: exposure is 'closed', the tosijs default. Its author enables ` + `it with expose: { roots } (add write: true to allow write()). Until then, find controls ` + `with hj tree and drive them with hj click / hj type.` : undefined;
|
|
1383
|
+
const actions = Array.isArray(d?.actions) ? d.actions : undefined;
|
|
1384
|
+
const writeAdvice = d?.writable === false ? `This surface is READ-ONLY (describe().writable is false): ${globalName}.write() will be ` + `refused, so act through actions or realistic input instead.` : d?.writable === true ? `${globalName}.write(path, value) for a ⟷ two-way binding.` : `${globalName}.write(path, value) for a ⟷ two-way binding (writability not reported ` + `by this surface; it may refuse).`;
|
|
1385
|
+
const callAdvice = actions && actions.length === 0 ? `It exposes no actions.` : `${globalName}.call(actionPath) for an action${actions ? ` (one of: ${actions.slice(0, 8).join(", ")}${actions.length > 8 ? ", …" : ""})` : ""}.`;
|
|
1377
1386
|
return {
|
|
1378
1387
|
url: location.href,
|
|
1379
1388
|
title: document.title,
|
|
1380
1389
|
source: "tosi-agent",
|
|
1381
1390
|
global: globalName,
|
|
1382
|
-
agentSurfaceVersion:
|
|
1383
|
-
...
|
|
1391
|
+
agentSurfaceVersion: surfaceVersion,
|
|
1392
|
+
...warning ? { warning } : {},
|
|
1393
|
+
...closedHint ? { hint: closedHint } : {},
|
|
1384
1394
|
...description,
|
|
1385
1395
|
act: {
|
|
1386
|
-
note: `Act through the paths, not synthesized input
|
|
1396
|
+
note: `Act through the paths, not synthesized input. ${writeAdvice} ${callAdvice}` + (actions && actions.length === 0 ? "" : ` Run one with: hj eval "${globalName}.call('some.action')"`),
|
|
1387
1397
|
legend: {
|
|
1388
1398
|
"⟷": "two-way binding — user-writable; writing the path updates the UI and app state",
|
|
1389
1399
|
"⟵": "bound to DOM — display only; it reflects the path, writing the DOM will not stick",
|
package/bin/hj.mjs
CHANGED
|
@@ -79,7 +79,7 @@ function describeOrigins(windows) {
|
|
|
79
79
|
return { line: yellow(msg), problem: msg }
|
|
80
80
|
}
|
|
81
81
|
const list = declared.origins.join(', ')
|
|
82
|
-
const routing = routeByDeclaredOrigin(declared.origins, windows || [], null)
|
|
82
|
+
const routing = routeByDeclaredOrigin(declared.origins, windows || [], (windows || []).find((w) => w.focused)?.id ?? null)
|
|
83
83
|
if (routing.kind === 'matched') {
|
|
84
84
|
return { line: `${list} ${dim(`(${declared.source}) → window ${routing.windowId}`)}`, problem: null }
|
|
85
85
|
}
|
|
@@ -331,6 +331,11 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
331
331
|
// this command exists to remove.
|
|
332
332
|
const unchecked = []
|
|
333
333
|
let status = null
|
|
334
|
+
// First-class answer to "can I believe what this tab tells me?" (#821, GitHub #50). Everything
|
|
335
|
+
// else doctor checks is about reaching a tab; this is about whether its answers are trustworthy.
|
|
336
|
+
// A hidden or non-compositing tab still answers — geometry, screenshots and anything rAF-driven
|
|
337
|
+
// just come back wrong — so it is printed on every run, not only when something fails.
|
|
338
|
+
let observability = null
|
|
334
339
|
|
|
335
340
|
try {
|
|
336
341
|
const resp = await fetch(`http://localhost:${port}/status`, {
|
|
@@ -365,6 +370,22 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
365
370
|
// Reads `active` via the shared predicate: /status and /windows disagreed on polarity, and
|
|
366
371
|
// keying on one endpoint's field name is what made this diverge from the origin router.
|
|
367
372
|
const hidden = tabs.filter((w) => !isVisible(w))
|
|
373
|
+
// The tab commands go to, resolved the way a command resolves it. Judging visibility over ALL
|
|
374
|
+
// tabs passed a lane whose target was hidden as long as some OTHER tab was visible — every
|
|
375
|
+
// command still went to the hidden one.
|
|
376
|
+
//
|
|
377
|
+
// A project with declared origins has its commands PINNED to the matching tab, wherever focus
|
|
378
|
+
// is. The first version of this looked only at focus, so for exactly the projects that
|
|
379
|
+
// declared their tabs it described another project's tab: a false pass when ours was hidden
|
|
380
|
+
// behind their visible one, a false failure the other way round (1.13.0-beta.3 review).
|
|
381
|
+
const focusedTab = tabs.find((w) => w.focused) ?? null
|
|
382
|
+
const declaredOrigins = findProjectOrigins(process.cwd(), process.env)
|
|
383
|
+
const routed = declaredOrigins?.origins.length
|
|
384
|
+
? routeByDeclaredOrigin(declaredOrigins.origins, tabs, focusedTab?.id ?? null)
|
|
385
|
+
: null
|
|
386
|
+
const pinned = routed?.kind === 'matched' ? tabs.find((w) => w.id === routed.windowId) ?? null : null
|
|
387
|
+
const target = pinned ?? focusedTab ?? tabs.find((w) => isVisible(w)) ?? tabs[0] ?? null
|
|
388
|
+
const targetLabel = target ? `${target.title && target.title !== '(untitled)' ? `"${target.title}" ` : ''}${target.url || target.id}` : ''
|
|
368
389
|
// A tab that reported NEITHER field hasn't told us anything; `isVisible` had to assume. Say so
|
|
369
390
|
// rather than counting the assumption as a passed check.
|
|
370
391
|
const silent = tabs.filter((w) => !visibilityKnown(w))
|
|
@@ -378,14 +399,16 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
378
399
|
`Upgrade the server to make this checkable.`,
|
|
379
400
|
)
|
|
380
401
|
}
|
|
381
|
-
if (ready &&
|
|
402
|
+
if (ready && target && !isVisible(target)) {
|
|
382
403
|
problems.push(
|
|
383
|
-
`
|
|
384
|
-
`
|
|
404
|
+
`the tab commands go to (${targetLabel}) reports HIDDEN — it still answers, but geometry, ` +
|
|
405
|
+
`screenshots and anything rAF-driven WILL be wrong (timers and rendering are throttled). ` +
|
|
406
|
+
`Bring it to the front, or target a visible tab with --window <id>.`,
|
|
385
407
|
)
|
|
386
408
|
} else if (hidden.length) {
|
|
387
409
|
notes.push(`${hidden.length} of ${tabs.length} tab(s) are hidden; commands targeting them may return stale results`)
|
|
388
410
|
}
|
|
411
|
+
if (ready && target) observability = { target: target.id, label: targetLabel, visible: isVisible(target), painting: null, paintAgeMs: null }
|
|
389
412
|
if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
|
|
390
413
|
notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`)
|
|
391
414
|
}
|
|
@@ -413,7 +436,8 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
413
436
|
// socket that never answers `/eval` (a widget mid-teardown, or a test harness holding an
|
|
414
437
|
// open WebSocket) made `hj doctor` block for the server's full browser timeout.
|
|
415
438
|
const cancel = AbortSignal.timeout(3000)
|
|
416
|
-
|
|
439
|
+
// Asked of the TARGET tab. Untargeted, it measured whichever tab had focus.
|
|
440
|
+
const r = await fetch(`http://localhost:${port}/eval${target ? `?window=${encodeURIComponent(target.id)}` : ''}`, {
|
|
417
441
|
method: 'POST',
|
|
418
442
|
headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Haltija-Token': token } : {}) },
|
|
419
443
|
body: JSON.stringify({ code: probe }),
|
|
@@ -435,6 +459,10 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
435
459
|
} catch {
|
|
436
460
|
// Fall through to the unchecked branch — never a pass.
|
|
437
461
|
}
|
|
462
|
+
if (observability && raf !== null) {
|
|
463
|
+
observability.painting = !!raf.fired
|
|
464
|
+
observability.paintAgeMs = raf.measured ? raf.ms : null
|
|
465
|
+
}
|
|
438
466
|
if (raf === null) {
|
|
439
467
|
unchecked.push(
|
|
440
468
|
`could not run the requestAnimationFrame probe — whether this tab actually paints is ` +
|
|
@@ -484,6 +512,7 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
484
512
|
ready: status ? (typeof status.ready === 'boolean' ? status.ready : (status.windows?.length ?? 0) > 0) : false,
|
|
485
513
|
tabs: status?.windows?.length ?? 0,
|
|
486
514
|
problems, notes,
|
|
515
|
+
observability,
|
|
487
516
|
origins: (() => {
|
|
488
517
|
const d = findProjectOrigins(process.cwd(), process.env)
|
|
489
518
|
return d ? { declared: d.origins, source: d.source } : null
|
|
@@ -501,6 +530,7 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
501
530
|
console.log(`${bold('server:')} haltija ${status.serverVersion || '?'}${status.desktopApp ? dim(' (desktop app)') : ''}, ${tabCount} tab${tabCount === 1 ? '' : 's'}`)
|
|
502
531
|
}
|
|
503
532
|
if (status) console.log(`${bold('origins:')} ${describeOrigins(status.windows || []).line}`)
|
|
533
|
+
if (observability) console.log(`${bold('observability:')} ${describeObservability(observability)}`)
|
|
504
534
|
for (const n of notes) console.log(`${yellow('!')} ${n}`)
|
|
505
535
|
// `?` — its own glyph, because the whole point is that this is neither a pass nor a failure.
|
|
506
536
|
for (const u of unchecked) console.log(`${dim('?')} ${u}`)
|
|
@@ -517,6 +547,20 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
517
547
|
return ok
|
|
518
548
|
}
|
|
519
549
|
|
|
550
|
+
/**
|
|
551
|
+
* The `observability:` line: whether the tab commands go to will tell the truth (#821). Says what a
|
|
552
|
+
* bad state invalidates rather than only naming the state — the recorded failures were agents who
|
|
553
|
+
* knew a tab was "hidden" and still trusted its geometry.
|
|
554
|
+
*/
|
|
555
|
+
function describeObservability(o) {
|
|
556
|
+
const where = dim(`(${o.label})`)
|
|
557
|
+
if (!o.visible) return `${red('✗ hidden')} — geometry, screenshots and rAF-driven rendering will be wrong ${where}`
|
|
558
|
+
if (o.painting === false) return `${red('✗ not painting')} — visible but not compositing; missing elements prove nothing ${where}`
|
|
559
|
+
if (o.painting === null) return `${dim('? visible, painting unknown')} ${where}`
|
|
560
|
+
const age = o.paintAgeMs === null ? '' : dim(` (last frame ${(o.paintAgeMs / 1000).toFixed(1)}s ago)`)
|
|
561
|
+
return `${green('✓ visible and painting')}${age} ${where}`
|
|
562
|
+
}
|
|
563
|
+
|
|
520
564
|
/**
|
|
521
565
|
* Resolve a named haltija instance to its port by reading
|
|
522
566
|
* ~/.haltija/servers/<name>.json. Returns null if the file is missing,
|
package/bin/tosijs-dev.mjs
CHANGED
|
@@ -54,7 +54,9 @@ Options:
|
|
|
54
54
|
--snapshots-dir <path> Save snapshots to disk (for CI artifacts)
|
|
55
55
|
--docs-dir <path> Directory with custom docs (*.md files)
|
|
56
56
|
--port <n> Set HTTP port (default: 8700)
|
|
57
|
-
--https-port <n> Set HTTPS port (default: 8701 for the server on 8700, else ephemeral)
|
|
57
|
+
--https-port <n> Set HTTPS port (default: 8701 for the server on 8700, else ephemeral).
|
|
58
|
+
Like --port, naming a port CLAIMS it: a haltija server already holding
|
|
59
|
+
it is asked to stop, which ends its HTTP side too. Pick a free port.
|
|
58
60
|
--token <value> Require X-Haltija-Token header on REST and ?token= on WebSocket
|
|
59
61
|
(default: off; sets HALTIJA_TOKEN)
|
|
60
62
|
--name <foo> Register this server as <foo> in ~/.haltija/servers/ so
|
|
@@ -119,7 +121,7 @@ Choosing a CI engine ${dim('(both need one external browser, but a DIFFERENT one
|
|
|
119
121
|
${bold('Playwright')} Chromium — needs the 'playwright' package:
|
|
120
122
|
npm i playwright && npx playwright install chromium
|
|
121
123
|
haltija --headless # single-engine; or --private --headless for isolation
|
|
122
|
-
${dim("
|
|
124
|
+
${dim("Chromium only. For Firefox/WebKit, launch Playwright yourself: docs/TEST-IN-BROWSER.md.")}
|
|
123
125
|
|
|
124
126
|
Examples:
|
|
125
127
|
haltija # Desktop app (or server fallback)
|
|
@@ -878,7 +880,7 @@ const startHeadlessBrowser = async (port) => {
|
|
|
878
880
|
console.error('[tosijs-dev] Or skip Playwright entirely and use the ELECTRON engine instead:')
|
|
879
881
|
console.error('[tosijs-dev] haltija --ci (Electron, waits for ready, sandbox off)')
|
|
880
882
|
console.error('[tosijs-dev] haltija --private --app (Electron + isolated ephemeral instance)')
|
|
881
|
-
console.error('[tosijs-dev]
|
|
883
|
+
console.error('[tosijs-dev] (--headless is Chromium only. For Firefox/WebKit see docs/TEST-IN-BROWSER.md.)')
|
|
882
884
|
} else {
|
|
883
885
|
console.error('[tosijs-dev] Failed to start headless browser:', err.message)
|
|
884
886
|
}
|
|
@@ -886,6 +888,25 @@ const startHeadlessBrowser = async (port) => {
|
|
|
886
888
|
}
|
|
887
889
|
}
|
|
888
890
|
|
|
891
|
+
/**
|
|
892
|
+
* The server is a CHILD of this launcher, and it must not outlive it.
|
|
893
|
+
*
|
|
894
|
+
* It used to: nothing forwarded a signal on the --server path, so `kill <launcher>` (a process
|
|
895
|
+
* manager, a test's afterAll, a closed terminal tab) left the server running with ppid 1, still
|
|
896
|
+
* holding its ports. A shared server has no idle timeout, so it stayed for good. Found when the
|
|
897
|
+
* HTTPS tests moved to this launcher (#1080) and each run of the suite left two servers behind.
|
|
898
|
+
*
|
|
899
|
+
* The 'exit' hook covers every path that ends in process.exit(), including the headless ones.
|
|
900
|
+
* A signal with no handler ends the process WITHOUT firing 'exit', so the non-headless path
|
|
901
|
+
* installs handlers; headless already has its own, which close the browser first.
|
|
902
|
+
*/
|
|
903
|
+
const superviseServer = (child) => {
|
|
904
|
+
process.on('exit', () => { try { child.kill('SIGTERM') } catch {} })
|
|
905
|
+
if (!headlessMode) {
|
|
906
|
+
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.on(sig, () => process.exit(0))
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
889
910
|
// Try bun first, fall back to node
|
|
890
911
|
const tryBun = () => {
|
|
891
912
|
const bun = spawn('bun', ['run', serverPath], {
|
|
@@ -901,6 +922,7 @@ const tryBun = () => {
|
|
|
901
922
|
bun.on('exit', code => {
|
|
902
923
|
process.exit(code || 0)
|
|
903
924
|
})
|
|
925
|
+
superviseServer(bun)
|
|
904
926
|
|
|
905
927
|
// Start headless browser after server is ready
|
|
906
928
|
if (headlessMode) {
|
|
@@ -940,6 +962,7 @@ const tryNode = () => {
|
|
|
940
962
|
node.on('exit', code => {
|
|
941
963
|
process.exit(code || 0)
|
|
942
964
|
})
|
|
965
|
+
superviseServer(node)
|
|
943
966
|
|
|
944
967
|
// Start headless browser after server is ready
|
|
945
968
|
if (headlessMode) {
|
package/bin/version.mjs
CHANGED
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.13.0-beta.
|
|
23
|
+
export declare const VERSION = "1.13.0-beta.3";
|
|
24
24
|
export declare class DevChannel extends HTMLElement {
|
|
25
25
|
static get tagName(): string;
|
|
26
26
|
static elementCreator(): () => DevChannel;
|
package/dist/component.esm.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/version.ts
|
|
2
|
-
var VERSION = "1.13.0-beta.
|
|
2
|
+
var VERSION = "1.13.0-beta.3";
|
|
3
3
|
|
|
4
4
|
// src/ws-url.ts
|
|
5
5
|
function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
|
|
@@ -1327,16 +1327,26 @@ function buildAffordanceMap(opts = {}) {
|
|
|
1327
1327
|
const description = agent.describe();
|
|
1328
1328
|
const wiring = description?.wiring;
|
|
1329
1329
|
const shapeWarning = wiring === undefined ? `${globalName}.describe() returned no 'wiring' — this haltija understands the agent ` + `surface as { wiring: [{ tag, id, label, on }] }. The map below is passed through ` + `unchanged and may be missing structure; the schematic will be empty. Check the ` + `tosijs version, or use hj map with the DOM fallback (a page without tosiAgent).` : !Array.isArray(wiring) ? `${globalName}.describe().wiring is ${typeof wiring}, expected an array — passing it ` + `through unchanged, but the schematic cannot render it.` : undefined;
|
|
1330
|
+
const d = description;
|
|
1331
|
+
const surfaceVersion = d?.version ?? agent.version;
|
|
1332
|
+
const surfaceMajor = surfaceVersion && typeof surfaceVersion === "object" && typeof surfaceVersion.surface === "string" ? surfaceVersion.surface.split(".")[0] : null;
|
|
1333
|
+
const versionWarning = surfaceMajor !== null && surfaceMajor !== "1" ? `${globalName} reports agent surface ${surfaceVersion.surface}; this haltija reads surface ` + `1.x, so fields may be missing or misread. Update haltija.` : undefined;
|
|
1334
|
+
const warning = [shapeWarning, versionWarning].filter(Boolean).join(" ") || undefined;
|
|
1335
|
+
const closedHint = d?.exposure === "closed" && Array.isArray(wiring) && wiring.length === 0 ? `The app exposes no wiring: exposure is 'closed', the tosijs default. Its author enables ` + `it with expose: { roots } (add write: true to allow write()). Until then, find controls ` + `with hj tree and drive them with hj click / hj type.` : undefined;
|
|
1336
|
+
const actions = Array.isArray(d?.actions) ? d.actions : undefined;
|
|
1337
|
+
const writeAdvice = d?.writable === false ? `This surface is READ-ONLY (describe().writable is false): ${globalName}.write() will be ` + `refused, so act through actions or realistic input instead.` : d?.writable === true ? `${globalName}.write(path, value) for a ⟷ two-way binding.` : `${globalName}.write(path, value) for a ⟷ two-way binding (writability not reported ` + `by this surface; it may refuse).`;
|
|
1338
|
+
const callAdvice = actions && actions.length === 0 ? `It exposes no actions.` : `${globalName}.call(actionPath) for an action${actions ? ` (one of: ${actions.slice(0, 8).join(", ")}${actions.length > 8 ? ", …" : ""})` : ""}.`;
|
|
1330
1339
|
return {
|
|
1331
1340
|
url: location.href,
|
|
1332
1341
|
title: document.title,
|
|
1333
1342
|
source: "tosi-agent",
|
|
1334
1343
|
global: globalName,
|
|
1335
|
-
agentSurfaceVersion:
|
|
1336
|
-
...
|
|
1344
|
+
agentSurfaceVersion: surfaceVersion,
|
|
1345
|
+
...warning ? { warning } : {},
|
|
1346
|
+
...closedHint ? { hint: closedHint } : {},
|
|
1337
1347
|
...description,
|
|
1338
1348
|
act: {
|
|
1339
|
-
note: `Act through the paths, not synthesized input
|
|
1349
|
+
note: `Act through the paths, not synthesized input. ${writeAdvice} ${callAdvice}` + (actions && actions.length === 0 ? "" : ` Run one with: hj eval "${globalName}.call('some.action')"`),
|
|
1340
1350
|
legend: {
|
|
1341
1351
|
"⟷": "two-way binding — user-writable; writing the path updates the UI and app state",
|
|
1342
1352
|
"⟵": "bound to DOM — display only; it reflects the path, writing the DOM will not stick",
|
package/dist/component.js
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.13.0-beta.
|
|
49
|
+
var VERSION = "1.13.0-beta.3";
|
|
50
50
|
|
|
51
51
|
// src/ws-url.ts
|
|
52
52
|
function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
|
|
@@ -1374,16 +1374,26 @@
|
|
|
1374
1374
|
const description = agent.describe();
|
|
1375
1375
|
const wiring = description?.wiring;
|
|
1376
1376
|
const shapeWarning = wiring === undefined ? `${globalName}.describe() returned no 'wiring' — this haltija understands the agent ` + `surface as { wiring: [{ tag, id, label, on }] }. The map below is passed through ` + `unchanged and may be missing structure; the schematic will be empty. Check the ` + `tosijs version, or use hj map with the DOM fallback (a page without tosiAgent).` : !Array.isArray(wiring) ? `${globalName}.describe().wiring is ${typeof wiring}, expected an array — passing it ` + `through unchanged, but the schematic cannot render it.` : undefined;
|
|
1377
|
+
const d = description;
|
|
1378
|
+
const surfaceVersion = d?.version ?? agent.version;
|
|
1379
|
+
const surfaceMajor = surfaceVersion && typeof surfaceVersion === "object" && typeof surfaceVersion.surface === "string" ? surfaceVersion.surface.split(".")[0] : null;
|
|
1380
|
+
const versionWarning = surfaceMajor !== null && surfaceMajor !== "1" ? `${globalName} reports agent surface ${surfaceVersion.surface}; this haltija reads surface ` + `1.x, so fields may be missing or misread. Update haltija.` : undefined;
|
|
1381
|
+
const warning = [shapeWarning, versionWarning].filter(Boolean).join(" ") || undefined;
|
|
1382
|
+
const closedHint = d?.exposure === "closed" && Array.isArray(wiring) && wiring.length === 0 ? `The app exposes no wiring: exposure is 'closed', the tosijs default. Its author enables ` + `it with expose: { roots } (add write: true to allow write()). Until then, find controls ` + `with hj tree and drive them with hj click / hj type.` : undefined;
|
|
1383
|
+
const actions = Array.isArray(d?.actions) ? d.actions : undefined;
|
|
1384
|
+
const writeAdvice = d?.writable === false ? `This surface is READ-ONLY (describe().writable is false): ${globalName}.write() will be ` + `refused, so act through actions or realistic input instead.` : d?.writable === true ? `${globalName}.write(path, value) for a ⟷ two-way binding.` : `${globalName}.write(path, value) for a ⟷ two-way binding (writability not reported ` + `by this surface; it may refuse).`;
|
|
1385
|
+
const callAdvice = actions && actions.length === 0 ? `It exposes no actions.` : `${globalName}.call(actionPath) for an action${actions ? ` (one of: ${actions.slice(0, 8).join(", ")}${actions.length > 8 ? ", …" : ""})` : ""}.`;
|
|
1377
1386
|
return {
|
|
1378
1387
|
url: location.href,
|
|
1379
1388
|
title: document.title,
|
|
1380
1389
|
source: "tosi-agent",
|
|
1381
1390
|
global: globalName,
|
|
1382
|
-
agentSurfaceVersion:
|
|
1383
|
-
...
|
|
1391
|
+
agentSurfaceVersion: surfaceVersion,
|
|
1392
|
+
...warning ? { warning } : {},
|
|
1393
|
+
...closedHint ? { hint: closedHint } : {},
|
|
1384
1394
|
...description,
|
|
1385
1395
|
act: {
|
|
1386
|
-
note: `Act through the paths, not synthesized input
|
|
1396
|
+
note: `Act through the paths, not synthesized input. ${writeAdvice} ${callAdvice}` + (actions && actions.length === 0 ? "" : ` Run one with: hj eval "${globalName}.call('some.action')"`),
|
|
1387
1397
|
legend: {
|
|
1388
1398
|
"⟷": "two-way binding — user-writable; writing the path updates the UI and app state",
|
|
1389
1399
|
"⟵": "bound to DOM — display only; it reflects the path, writing the DOM will not stick",
|
package/dist/hj.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// haltija-cli:do-not-edit v1.13.0-beta.
|
|
2
|
+
// haltija-cli:do-not-edit v1.13.0-beta.3
|
|
3
3
|
var __esm = (fn, res, err) => () => {
|
|
4
4
|
if (fn)
|
|
5
5
|
try {
|
|
@@ -785,7 +785,7 @@ function substituteGeneratedVars(text, seed) {
|
|
|
785
785
|
}
|
|
786
786
|
|
|
787
787
|
// bin/version.mjs
|
|
788
|
-
var HJ_VERSION = "1.13.0-beta.
|
|
788
|
+
var HJ_VERSION = "1.13.0-beta.3";
|
|
789
789
|
|
|
790
790
|
// bin/semver.mjs
|
|
791
791
|
function parseVersion(v) {
|
|
@@ -2608,7 +2608,7 @@ function describeOrigins(windows) {
|
|
|
2608
2608
|
return { line: yellow2(msg), problem: msg };
|
|
2609
2609
|
}
|
|
2610
2610
|
const list = declared.origins.join(", ");
|
|
2611
|
-
const routing = routeByDeclaredOrigin(declared.origins, windows || [], null);
|
|
2611
|
+
const routing = routeByDeclaredOrigin(declared.origins, windows || [], (windows || []).find((w) => w.focused)?.id ?? null);
|
|
2612
2612
|
if (routing.kind === "matched") {
|
|
2613
2613
|
return { line: `${list} ${dim3(`(${declared.source}) → window ${routing.windowId}`)}`, problem: null };
|
|
2614
2614
|
}
|
|
@@ -2771,6 +2771,7 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
2771
2771
|
const notes = [];
|
|
2772
2772
|
const unchecked = [];
|
|
2773
2773
|
let status = null;
|
|
2774
|
+
let observability = null;
|
|
2774
2775
|
try {
|
|
2775
2776
|
const resp = await fetch(`http://localhost:${port}/status`, {
|
|
2776
2777
|
headers: token ? { "X-Haltija-Token": token } : {},
|
|
@@ -2791,15 +2792,23 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
2791
2792
|
problems.push(`the server on port ${port} is up but has NO connected browser tab — nothing to drive. ` + `Open a tab in the desktop app, or inject the widget into a page. ` + `("server is up" is not "server is drivable" — that's what this check exists for.)`);
|
|
2792
2793
|
}
|
|
2793
2794
|
const hidden = tabs.filter((w) => !isVisible2(w));
|
|
2795
|
+
const focusedTab = tabs.find((w) => w.focused) ?? null;
|
|
2796
|
+
const declaredOrigins = findProjectOrigins(process.cwd(), process.env);
|
|
2797
|
+
const routed = declaredOrigins?.origins.length ? routeByDeclaredOrigin(declaredOrigins.origins, tabs, focusedTab?.id ?? null) : null;
|
|
2798
|
+
const pinned = routed?.kind === "matched" ? tabs.find((w) => w.id === routed.windowId) ?? null : null;
|
|
2799
|
+
const target = pinned ?? focusedTab ?? tabs.find((w) => isVisible2(w)) ?? tabs[0] ?? null;
|
|
2800
|
+
const targetLabel = target ? `${target.title && target.title !== "(untitled)" ? `"${target.title}" ` : ""}${target.url || target.id}` : "";
|
|
2794
2801
|
const silent = tabs.filter((w) => !visibilityKnown(w));
|
|
2795
2802
|
if (ready && silent.length) {
|
|
2796
2803
|
unchecked.push(`${silent.length} of ${tabs.length} tab(s) did not report visibility${status.serverVersion ? ` (server ${status.serverVersion} is too old to send it)` : ""} — this check ASSUMED they are on screen and cannot confirm it. ` + `A backgrounded tab returns plausible-but-wrong results (rAF/timers throttled), so if ` + `something looks stale, that assumption is the first thing to doubt. ` + `Upgrade the server to make this checkable.`);
|
|
2797
2804
|
}
|
|
2798
|
-
if (ready &&
|
|
2799
|
-
problems.push(`
|
|
2805
|
+
if (ready && target && !isVisible2(target)) {
|
|
2806
|
+
problems.push(`the tab commands go to (${targetLabel}) reports HIDDEN — it still answers, but geometry, ` + `screenshots and anything rAF-driven WILL be wrong (timers and rendering are throttled). ` + `Bring it to the front, or target a visible tab with --window <id>.`);
|
|
2800
2807
|
} else if (hidden.length) {
|
|
2801
2808
|
notes.push(`${hidden.length} of ${tabs.length} tab(s) are hidden; commands targeting them may return stale results`);
|
|
2802
2809
|
}
|
|
2810
|
+
if (ready && target)
|
|
2811
|
+
observability = { target: target.id, label: targetLabel, visible: isVisible2(target), painting: null, paintAgeMs: null };
|
|
2803
2812
|
if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
|
|
2804
2813
|
notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`);
|
|
2805
2814
|
}
|
|
@@ -2808,7 +2817,7 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
2808
2817
|
let raf = null;
|
|
2809
2818
|
try {
|
|
2810
2819
|
const cancel = AbortSignal.timeout(3000);
|
|
2811
|
-
const r = await fetch(`http://localhost:${port}/eval`, {
|
|
2820
|
+
const r = await fetch(`http://localhost:${port}/eval${target ? `?window=${encodeURIComponent(target.id)}` : ""}`, {
|
|
2812
2821
|
method: "POST",
|
|
2813
2822
|
headers: { "Content-Type": "application/json", ...token ? { "X-Haltija-Token": token } : {} },
|
|
2814
2823
|
body: JSON.stringify({ code: probe }),
|
|
@@ -2823,6 +2832,10 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
2823
2832
|
}
|
|
2824
2833
|
}
|
|
2825
2834
|
} catch {}
|
|
2835
|
+
if (observability && raf !== null) {
|
|
2836
|
+
observability.painting = !!raf.fired;
|
|
2837
|
+
observability.paintAgeMs = raf.measured ? raf.ms : null;
|
|
2838
|
+
}
|
|
2826
2839
|
if (raf === null) {
|
|
2827
2840
|
unchecked.push(`could not run the requestAnimationFrame probe — whether this tab actually paints is ` + `UNKNOWN. If elements seem missing, suspect a non-compositing tab before the page.`);
|
|
2828
2841
|
} else if (!raf.fired) {
|
|
@@ -2849,6 +2862,7 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
2849
2862
|
tabs: status?.windows?.length ?? 0,
|
|
2850
2863
|
problems,
|
|
2851
2864
|
notes,
|
|
2865
|
+
observability,
|
|
2852
2866
|
origins: (() => {
|
|
2853
2867
|
const d = findProjectOrigins(process.cwd(), process.env);
|
|
2854
2868
|
return d ? { declared: d.origins, source: d.source } : null;
|
|
@@ -2864,6 +2878,8 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
2864
2878
|
}
|
|
2865
2879
|
if (status)
|
|
2866
2880
|
console.log(`${bold2("origins:")} ${describeOrigins(status.windows || []).line}`);
|
|
2881
|
+
if (observability)
|
|
2882
|
+
console.log(`${bold2("observability:")} ${describeObservability(observability)}`);
|
|
2867
2883
|
for (const n of notes)
|
|
2868
2884
|
console.log(`${yellow2("!")} ${n}`);
|
|
2869
2885
|
for (const u of unchecked)
|
|
@@ -2875,6 +2891,17 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
|
|
|
2875
2891
|
}
|
|
2876
2892
|
return ok;
|
|
2877
2893
|
}
|
|
2894
|
+
function describeObservability(o) {
|
|
2895
|
+
const where = dim3(`(${o.label})`);
|
|
2896
|
+
if (!o.visible)
|
|
2897
|
+
return `${red2("✗ hidden")} — geometry, screenshots and rAF-driven rendering will be wrong ${where}`;
|
|
2898
|
+
if (o.painting === false)
|
|
2899
|
+
return `${red2("✗ not painting")} — visible but not compositing; missing elements prove nothing ${where}`;
|
|
2900
|
+
if (o.painting === null)
|
|
2901
|
+
return `${dim3("? visible, painting unknown")} ${where}`;
|
|
2902
|
+
const age = o.paintAgeMs === null ? "" : dim3(` (last frame ${(o.paintAgeMs / 1000).toFixed(1)}s ago)`);
|
|
2903
|
+
return `${green2("✓ visible and painting")}${age} ${where}`;
|
|
2904
|
+
}
|
|
2878
2905
|
function lookupNamedInstance(name) {
|
|
2879
2906
|
const path = join3(REGISTRY_DIR, `${name}.json`);
|
|
2880
2907
|
if (!existsSync3(path))
|
package/dist/index.js
CHANGED
|
@@ -647,7 +647,7 @@ var injectorCode = `
|
|
|
647
647
|
`;
|
|
648
648
|
|
|
649
649
|
// src/version.ts
|
|
650
|
-
var VERSION = "1.13.0-beta.
|
|
650
|
+
var VERSION = "1.13.0-beta.3";
|
|
651
651
|
|
|
652
652
|
// src/embedded-assets.ts
|
|
653
653
|
var APP_MD = `# Haltija App
|
|
@@ -1369,10 +1369,11 @@ Works with standard forms and most framework components (React, Vue, etc).
|
|
|
1369
1369
|
|
|
1370
1370
|
Returns a map of the page's affordances. Two tiers, and the difference matters:
|
|
1371
1371
|
|
|
1372
|
-
**Native (\`source: "tosi-agent"\`)
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1372
|
+
**Native (\`source: "tosi-agent"\`).** haltija reads the contract tosijs ships: the surface's
|
|
1373
|
+
\`version\`, \`exposure\` and \`writable\`. A read-only surface is reported as read-only, an empty
|
|
1374
|
+
\`'closed'\` surface comes with a hint for the app's author, and a surface version haltija does not
|
|
1375
|
+
read produces a \`warning\` and \`agentSurfaceVersion\` rather than a blank map passed off as a
|
|
1376
|
+
success. When the page
|
|
1376
1377
|
exposes an agent surface at
|
|
1377
1378
|
\`globalThis.tosiAgent\` (a tosijs app calling \`enableAgentInterface()\`), the map is the app's OWN
|
|
1378
1379
|
wiring records. That carries what the DOM cannot: which state path each control is bound to and in
|
|
@@ -3492,9 +3493,9 @@ Two first-party ways to run (plus embedding, below):
|
|
|
3492
3493
|
(Chromium \u2014 the same engine the Haltija desktop app uses). Electron is fetched via
|
|
3493
3494
|
\`npx\` if not already present; **no \`playwright\` needed.** This is the default CI path.
|
|
3494
3495
|
- \`haltija --headless\` drives **Playwright Chromium**, which requires the \`playwright\`
|
|
3495
|
-
package (\`npm i playwright && npx playwright install chromium\`).
|
|
3496
|
-
|
|
3497
|
-
|
|
3496
|
+
package (\`npm i playwright && npx playwright install chromium\`). It is Chromium only,
|
|
3497
|
+
with no engine flag; for Firefox/WebKit launch Playwright yourself (see
|
|
3498
|
+
docs/TEST-IN-BROWSER.md). \`--private\` is an *isolation* modifier (ephemeral port), and
|
|
3498
3499
|
pairs with either engine: \`--private --app\` (Electron) or \`--private --headless\`
|
|
3499
3500
|
(Playwright). See the CI guide below.
|
|
3500
3501
|
|
|
@@ -3623,7 +3624,7 @@ var COMPONENT_JS = `(() => {
|
|
|
3623
3624
|
});
|
|
3624
3625
|
|
|
3625
3626
|
// src/version.ts
|
|
3626
|
-
var VERSION = "1.13.0-beta.
|
|
3627
|
+
var VERSION = "1.13.0-beta.3";
|
|
3627
3628
|
|
|
3628
3629
|
// src/ws-url.ts
|
|
3629
3630
|
function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
|
|
@@ -4951,16 +4952,26 @@ var COMPONENT_JS = `(() => {
|
|
|
4951
4952
|
const description = agent.describe();
|
|
4952
4953
|
const wiring = description?.wiring;
|
|
4953
4954
|
const shapeWarning = wiring === undefined ? \`\${globalName}.describe() returned no 'wiring' \u2014 this haltija understands the agent \` + \`surface as { wiring: [{ tag, id, label, on }] }. The map below is passed through \` + \`unchanged and may be missing structure; the schematic will be empty. Check the \` + \`tosijs version, or use hj map with the DOM fallback (a page without tosiAgent).\` : !Array.isArray(wiring) ? \`\${globalName}.describe().wiring is \${typeof wiring}, expected an array \u2014 passing it \` + \`through unchanged, but the schematic cannot render it.\` : undefined;
|
|
4955
|
+
const d = description;
|
|
4956
|
+
const surfaceVersion = d?.version ?? agent.version;
|
|
4957
|
+
const surfaceMajor = surfaceVersion && typeof surfaceVersion === "object" && typeof surfaceVersion.surface === "string" ? surfaceVersion.surface.split(".")[0] : null;
|
|
4958
|
+
const versionWarning = surfaceMajor !== null && surfaceMajor !== "1" ? \`\${globalName} reports agent surface \${surfaceVersion.surface}; this haltija reads surface \` + \`1.x, so fields may be missing or misread. Update haltija.\` : undefined;
|
|
4959
|
+
const warning = [shapeWarning, versionWarning].filter(Boolean).join(" ") || undefined;
|
|
4960
|
+
const closedHint = d?.exposure === "closed" && Array.isArray(wiring) && wiring.length === 0 ? \`The app exposes no wiring: exposure is 'closed', the tosijs default. Its author enables \` + \`it with expose: { roots } (add write: true to allow write()). Until then, find controls \` + \`with hj tree and drive them with hj click / hj type.\` : undefined;
|
|
4961
|
+
const actions = Array.isArray(d?.actions) ? d.actions : undefined;
|
|
4962
|
+
const writeAdvice = d?.writable === false ? \`This surface is READ-ONLY (describe().writable is false): \${globalName}.write() will be \` + \`refused, so act through actions or realistic input instead.\` : d?.writable === true ? \`\${globalName}.write(path, value) for a \u27F7 two-way binding.\` : \`\${globalName}.write(path, value) for a \u27F7 two-way binding (writability not reported \` + \`by this surface; it may refuse).\`;
|
|
4963
|
+
const callAdvice = actions && actions.length === 0 ? \`It exposes no actions.\` : \`\${globalName}.call(actionPath) for an action\${actions ? \` (one of: \${actions.slice(0, 8).join(", ")}\${actions.length > 8 ? ", \u2026" : ""})\` : ""}.\`;
|
|
4954
4964
|
return {
|
|
4955
4965
|
url: location.href,
|
|
4956
4966
|
title: document.title,
|
|
4957
4967
|
source: "tosi-agent",
|
|
4958
4968
|
global: globalName,
|
|
4959
|
-
agentSurfaceVersion:
|
|
4960
|
-
...
|
|
4969
|
+
agentSurfaceVersion: surfaceVersion,
|
|
4970
|
+
...warning ? { warning } : {},
|
|
4971
|
+
...closedHint ? { hint: closedHint } : {},
|
|
4961
4972
|
...description,
|
|
4962
4973
|
act: {
|
|
4963
|
-
note: \`Act through the paths, not synthesized input
|
|
4974
|
+
note: \`Act through the paths, not synthesized input. \${writeAdvice} \${callAdvice}\` + (actions && actions.length === 0 ? "" : \` Run one with: hj eval "\${globalName}.call('some.action')"\`),
|
|
4964
4975
|
legend: {
|
|
4965
4976
|
"\u27F7": "two-way binding \u2014 user-writable; writing the path updates the UI and app state",
|
|
4966
4977
|
"\u27F5": "bound to DOM \u2014 display only; it reflects the path, writing the DOM will not stick",
|
|
@@ -15068,10 +15079,11 @@ var map = endpoint({
|
|
|
15068
15079
|
summary: "Affordance map \u2014 what can be interacted with, and what it is wired to",
|
|
15069
15080
|
description: `Returns a map of the page's affordances. Two tiers, and the difference matters:
|
|
15070
15081
|
|
|
15071
|
-
**Native (\`source: "tosi-agent"\`)
|
|
15072
|
-
|
|
15073
|
-
|
|
15074
|
-
|
|
15082
|
+
**Native (\`source: "tosi-agent"\`).** haltija reads the contract tosijs ships: the surface's
|
|
15083
|
+
\`version\`, \`exposure\` and \`writable\`. A read-only surface is reported as read-only, an empty
|
|
15084
|
+
\`'closed'\` surface comes with a hint for the app's author, and a surface version haltija does not
|
|
15085
|
+
read produces a \`warning\` and \`agentSurfaceVersion\` rather than a blank map passed off as a
|
|
15086
|
+
success. When the page
|
|
15075
15087
|
exposes an agent surface at
|
|
15076
15088
|
\`globalThis.tosiAgent\` (a tosijs app calling \`enableAgentInterface()\`), the map is the app's OWN
|
|
15077
15089
|
wiring records. That carries what the DOM cannot: which state path each control is bound to and in
|
|
@@ -22567,12 +22579,12 @@ var serverConfig = {
|
|
|
22567
22579
|
}
|
|
22568
22580
|
}
|
|
22569
22581
|
};
|
|
22570
|
-
async function freePort(port) {
|
|
22582
|
+
async function freePort(port, scheme = "http") {
|
|
22571
22583
|
if (process.env.HALTIJA_NO_RETIRE === "1")
|
|
22572
22584
|
return false;
|
|
22573
22585
|
if (listenerPidsOnPort(port).length === 0)
|
|
22574
22586
|
return false;
|
|
22575
|
-
const probe = await probePort(port);
|
|
22587
|
+
const probe = await probePort(port, scheme);
|
|
22576
22588
|
if (planFreePort(probe) === "decline") {
|
|
22577
22589
|
recordMachineAction({
|
|
22578
22590
|
kind: "declined",
|
|
@@ -22580,7 +22592,7 @@ async function freePort(port) {
|
|
|
22580
22592
|
});
|
|
22581
22593
|
return false;
|
|
22582
22594
|
}
|
|
22583
|
-
if (await requestShutdown(port)) {
|
|
22595
|
+
if (await requestShutdown(port, 3000, scheme)) {
|
|
22584
22596
|
recordMachineAction({
|
|
22585
22597
|
kind: "server-stopped",
|
|
22586
22598
|
detail: `stopped the haltija server on :${port} because this server was explicitly asked to bind that port`
|
|
@@ -22609,21 +22621,23 @@ async function freePort(port) {
|
|
|
22609
22621
|
Bun.sleepSync(100);
|
|
22610
22622
|
return killed;
|
|
22611
22623
|
}
|
|
22612
|
-
async function requestShutdown(port, timeoutMs = 3000) {
|
|
22624
|
+
async function requestShutdown(port, timeoutMs = 3000, scheme = "http") {
|
|
22613
22625
|
const authHeaders = REQUIRED_TOKEN ? { "X-Haltija-Token": REQUIRED_TOKEN } : {};
|
|
22614
22626
|
try {
|
|
22615
|
-
await fetch(
|
|
22627
|
+
await fetch(`${scheme}://localhost:${port}/shutdown`, {
|
|
22616
22628
|
method: "POST",
|
|
22617
22629
|
headers: authHeaders,
|
|
22618
|
-
signal: AbortSignal.timeout(1500)
|
|
22630
|
+
signal: AbortSignal.timeout(1500),
|
|
22631
|
+
...selfSigned(scheme)
|
|
22619
22632
|
});
|
|
22620
22633
|
} catch {}
|
|
22621
22634
|
const deadline = Date.now() + timeoutMs;
|
|
22622
22635
|
while (Date.now() < deadline) {
|
|
22623
22636
|
try {
|
|
22624
|
-
await fetch(
|
|
22637
|
+
await fetch(`${scheme}://localhost:${port}/status`, {
|
|
22625
22638
|
headers: authHeaders,
|
|
22626
|
-
signal: AbortSignal.timeout(500)
|
|
22639
|
+
signal: AbortSignal.timeout(500),
|
|
22640
|
+
...selfSigned(scheme)
|
|
22627
22641
|
});
|
|
22628
22642
|
} catch {
|
|
22629
22643
|
return true;
|
|
@@ -22632,11 +22646,15 @@ async function requestShutdown(port, timeoutMs = 3000) {
|
|
|
22632
22646
|
}
|
|
22633
22647
|
return false;
|
|
22634
22648
|
}
|
|
22635
|
-
|
|
22649
|
+
function selfSigned(scheme) {
|
|
22650
|
+
return scheme === "https" ? { tls: { rejectUnauthorized: false } } : {};
|
|
22651
|
+
}
|
|
22652
|
+
async function probePort(port, scheme = "http") {
|
|
22636
22653
|
const unknown = { port, version: null, desktopApp: null, pid: null };
|
|
22637
22654
|
try {
|
|
22638
|
-
const resp = await fetch(
|
|
22655
|
+
const resp = await fetch(`${scheme}://localhost:${port}/status`, {
|
|
22639
22656
|
signal: AbortSignal.timeout(1000),
|
|
22657
|
+
...selfSigned(scheme),
|
|
22640
22658
|
headers: REQUIRED_TOKEN ? { "X-Haltija-Token": REQUIRED_TOKEN } : {}
|
|
22641
22659
|
});
|
|
22642
22660
|
if (!resp.ok)
|
|
@@ -22759,7 +22777,7 @@ if (USE_HTTPS) {
|
|
|
22759
22777
|
httpsServer = bindHttps();
|
|
22760
22778
|
} catch (err) {
|
|
22761
22779
|
if (certFailure(err)) {} else if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
|
|
22762
|
-
if (
|
|
22780
|
+
if (HTTPS_PORT_EXPLICIT !== null && await freePort(wantedHttpsPort, "https")) {
|
|
22763
22781
|
httpsServer = bindHttps();
|
|
22764
22782
|
} else {
|
|
22765
22783
|
for (let attempt = 0;attempt < 5 && !httpsServer; attempt++) {
|
|
@@ -23054,7 +23072,7 @@ function httpsLoaderHint() {
|
|
|
23054
23072
|
blocks http://localhost from https, so they need the HTTPS transport.`;
|
|
23055
23073
|
}
|
|
23056
23074
|
return ` On https pages load https://localhost:${HTTPS_PORT}/component.js?autoInject=true&serverUrl=wss://localhost:${HTTPS_PORT}/ws/browser
|
|
23057
|
-
(this server's HTTPS port is ephemeral; pass --https-port for a stable one).`;
|
|
23075
|
+
(this server's HTTPS port is ephemeral; pass --https-port <a free port> for a stable one).`;
|
|
23058
23076
|
}
|
|
23059
23077
|
console.log(`
|
|
23060
23078
|
To connect: Visit the server URL and drag the bookmarklet to your toolbar.
|
package/dist/server.js
CHANGED
|
@@ -647,7 +647,7 @@ var injectorCode = `
|
|
|
647
647
|
`;
|
|
648
648
|
|
|
649
649
|
// src/version.ts
|
|
650
|
-
var VERSION = "1.13.0-beta.
|
|
650
|
+
var VERSION = "1.13.0-beta.3";
|
|
651
651
|
|
|
652
652
|
// src/embedded-assets.ts
|
|
653
653
|
var APP_MD = `# Haltija App
|
|
@@ -1369,10 +1369,11 @@ Works with standard forms and most framework components (React, Vue, etc).
|
|
|
1369
1369
|
|
|
1370
1370
|
Returns a map of the page's affordances. Two tiers, and the difference matters:
|
|
1371
1371
|
|
|
1372
|
-
**Native (\`source: "tosi-agent"\`)
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1372
|
+
**Native (\`source: "tosi-agent"\`).** haltija reads the contract tosijs ships: the surface's
|
|
1373
|
+
\`version\`, \`exposure\` and \`writable\`. A read-only surface is reported as read-only, an empty
|
|
1374
|
+
\`'closed'\` surface comes with a hint for the app's author, and a surface version haltija does not
|
|
1375
|
+
read produces a \`warning\` and \`agentSurfaceVersion\` rather than a blank map passed off as a
|
|
1376
|
+
success. When the page
|
|
1376
1377
|
exposes an agent surface at
|
|
1377
1378
|
\`globalThis.tosiAgent\` (a tosijs app calling \`enableAgentInterface()\`), the map is the app's OWN
|
|
1378
1379
|
wiring records. That carries what the DOM cannot: which state path each control is bound to and in
|
|
@@ -3492,9 +3493,9 @@ Two first-party ways to run (plus embedding, below):
|
|
|
3492
3493
|
(Chromium \u2014 the same engine the Haltija desktop app uses). Electron is fetched via
|
|
3493
3494
|
\`npx\` if not already present; **no \`playwright\` needed.** This is the default CI path.
|
|
3494
3495
|
- \`haltija --headless\` drives **Playwright Chromium**, which requires the \`playwright\`
|
|
3495
|
-
package (\`npm i playwright && npx playwright install chromium\`).
|
|
3496
|
-
|
|
3497
|
-
|
|
3496
|
+
package (\`npm i playwright && npx playwright install chromium\`). It is Chromium only,
|
|
3497
|
+
with no engine flag; for Firefox/WebKit launch Playwright yourself (see
|
|
3498
|
+
docs/TEST-IN-BROWSER.md). \`--private\` is an *isolation* modifier (ephemeral port), and
|
|
3498
3499
|
pairs with either engine: \`--private --app\` (Electron) or \`--private --headless\`
|
|
3499
3500
|
(Playwright). See the CI guide below.
|
|
3500
3501
|
|
|
@@ -3623,7 +3624,7 @@ var COMPONENT_JS = `(() => {
|
|
|
3623
3624
|
});
|
|
3624
3625
|
|
|
3625
3626
|
// src/version.ts
|
|
3626
|
-
var VERSION = "1.13.0-beta.
|
|
3627
|
+
var VERSION = "1.13.0-beta.3";
|
|
3627
3628
|
|
|
3628
3629
|
// src/ws-url.ts
|
|
3629
3630
|
function httpBaseFromWsUrl(wsUrl2, fallback = "http://localhost:8700") {
|
|
@@ -4951,16 +4952,26 @@ var COMPONENT_JS = `(() => {
|
|
|
4951
4952
|
const description = agent.describe();
|
|
4952
4953
|
const wiring = description?.wiring;
|
|
4953
4954
|
const shapeWarning = wiring === undefined ? \`\${globalName}.describe() returned no 'wiring' \u2014 this haltija understands the agent \` + \`surface as { wiring: [{ tag, id, label, on }] }. The map below is passed through \` + \`unchanged and may be missing structure; the schematic will be empty. Check the \` + \`tosijs version, or use hj map with the DOM fallback (a page without tosiAgent).\` : !Array.isArray(wiring) ? \`\${globalName}.describe().wiring is \${typeof wiring}, expected an array \u2014 passing it \` + \`through unchanged, but the schematic cannot render it.\` : undefined;
|
|
4955
|
+
const d = description;
|
|
4956
|
+
const surfaceVersion = d?.version ?? agent.version;
|
|
4957
|
+
const surfaceMajor = surfaceVersion && typeof surfaceVersion === "object" && typeof surfaceVersion.surface === "string" ? surfaceVersion.surface.split(".")[0] : null;
|
|
4958
|
+
const versionWarning = surfaceMajor !== null && surfaceMajor !== "1" ? \`\${globalName} reports agent surface \${surfaceVersion.surface}; this haltija reads surface \` + \`1.x, so fields may be missing or misread. Update haltija.\` : undefined;
|
|
4959
|
+
const warning = [shapeWarning, versionWarning].filter(Boolean).join(" ") || undefined;
|
|
4960
|
+
const closedHint = d?.exposure === "closed" && Array.isArray(wiring) && wiring.length === 0 ? \`The app exposes no wiring: exposure is 'closed', the tosijs default. Its author enables \` + \`it with expose: { roots } (add write: true to allow write()). Until then, find controls \` + \`with hj tree and drive them with hj click / hj type.\` : undefined;
|
|
4961
|
+
const actions = Array.isArray(d?.actions) ? d.actions : undefined;
|
|
4962
|
+
const writeAdvice = d?.writable === false ? \`This surface is READ-ONLY (describe().writable is false): \${globalName}.write() will be \` + \`refused, so act through actions or realistic input instead.\` : d?.writable === true ? \`\${globalName}.write(path, value) for a \u27F7 two-way binding.\` : \`\${globalName}.write(path, value) for a \u27F7 two-way binding (writability not reported \` + \`by this surface; it may refuse).\`;
|
|
4963
|
+
const callAdvice = actions && actions.length === 0 ? \`It exposes no actions.\` : \`\${globalName}.call(actionPath) for an action\${actions ? \` (one of: \${actions.slice(0, 8).join(", ")}\${actions.length > 8 ? ", \u2026" : ""})\` : ""}.\`;
|
|
4954
4964
|
return {
|
|
4955
4965
|
url: location.href,
|
|
4956
4966
|
title: document.title,
|
|
4957
4967
|
source: "tosi-agent",
|
|
4958
4968
|
global: globalName,
|
|
4959
|
-
agentSurfaceVersion:
|
|
4960
|
-
...
|
|
4969
|
+
agentSurfaceVersion: surfaceVersion,
|
|
4970
|
+
...warning ? { warning } : {},
|
|
4971
|
+
...closedHint ? { hint: closedHint } : {},
|
|
4961
4972
|
...description,
|
|
4962
4973
|
act: {
|
|
4963
|
-
note: \`Act through the paths, not synthesized input
|
|
4974
|
+
note: \`Act through the paths, not synthesized input. \${writeAdvice} \${callAdvice}\` + (actions && actions.length === 0 ? "" : \` Run one with: hj eval "\${globalName}.call('some.action')"\`),
|
|
4964
4975
|
legend: {
|
|
4965
4976
|
"\u27F7": "two-way binding \u2014 user-writable; writing the path updates the UI and app state",
|
|
4966
4977
|
"\u27F5": "bound to DOM \u2014 display only; it reflects the path, writing the DOM will not stick",
|
|
@@ -15068,10 +15079,11 @@ var map = endpoint({
|
|
|
15068
15079
|
summary: "Affordance map \u2014 what can be interacted with, and what it is wired to",
|
|
15069
15080
|
description: `Returns a map of the page's affordances. Two tiers, and the difference matters:
|
|
15070
15081
|
|
|
15071
|
-
**Native (\`source: "tosi-agent"\`)
|
|
15072
|
-
|
|
15073
|
-
|
|
15074
|
-
|
|
15082
|
+
**Native (\`source: "tosi-agent"\`).** haltija reads the contract tosijs ships: the surface's
|
|
15083
|
+
\`version\`, \`exposure\` and \`writable\`. A read-only surface is reported as read-only, an empty
|
|
15084
|
+
\`'closed'\` surface comes with a hint for the app's author, and a surface version haltija does not
|
|
15085
|
+
read produces a \`warning\` and \`agentSurfaceVersion\` rather than a blank map passed off as a
|
|
15086
|
+
success. When the page
|
|
15075
15087
|
exposes an agent surface at
|
|
15076
15088
|
\`globalThis.tosiAgent\` (a tosijs app calling \`enableAgentInterface()\`), the map is the app's OWN
|
|
15077
15089
|
wiring records. That carries what the DOM cannot: which state path each control is bound to and in
|
|
@@ -22567,12 +22579,12 @@ var serverConfig = {
|
|
|
22567
22579
|
}
|
|
22568
22580
|
}
|
|
22569
22581
|
};
|
|
22570
|
-
async function freePort(port) {
|
|
22582
|
+
async function freePort(port, scheme = "http") {
|
|
22571
22583
|
if (process.env.HALTIJA_NO_RETIRE === "1")
|
|
22572
22584
|
return false;
|
|
22573
22585
|
if (listenerPidsOnPort(port).length === 0)
|
|
22574
22586
|
return false;
|
|
22575
|
-
const probe = await probePort(port);
|
|
22587
|
+
const probe = await probePort(port, scheme);
|
|
22576
22588
|
if (planFreePort(probe) === "decline") {
|
|
22577
22589
|
recordMachineAction({
|
|
22578
22590
|
kind: "declined",
|
|
@@ -22580,7 +22592,7 @@ async function freePort(port) {
|
|
|
22580
22592
|
});
|
|
22581
22593
|
return false;
|
|
22582
22594
|
}
|
|
22583
|
-
if (await requestShutdown(port)) {
|
|
22595
|
+
if (await requestShutdown(port, 3000, scheme)) {
|
|
22584
22596
|
recordMachineAction({
|
|
22585
22597
|
kind: "server-stopped",
|
|
22586
22598
|
detail: `stopped the haltija server on :${port} because this server was explicitly asked to bind that port`
|
|
@@ -22609,21 +22621,23 @@ async function freePort(port) {
|
|
|
22609
22621
|
Bun.sleepSync(100);
|
|
22610
22622
|
return killed;
|
|
22611
22623
|
}
|
|
22612
|
-
async function requestShutdown(port, timeoutMs = 3000) {
|
|
22624
|
+
async function requestShutdown(port, timeoutMs = 3000, scheme = "http") {
|
|
22613
22625
|
const authHeaders = REQUIRED_TOKEN ? { "X-Haltija-Token": REQUIRED_TOKEN } : {};
|
|
22614
22626
|
try {
|
|
22615
|
-
await fetch(
|
|
22627
|
+
await fetch(`${scheme}://localhost:${port}/shutdown`, {
|
|
22616
22628
|
method: "POST",
|
|
22617
22629
|
headers: authHeaders,
|
|
22618
|
-
signal: AbortSignal.timeout(1500)
|
|
22630
|
+
signal: AbortSignal.timeout(1500),
|
|
22631
|
+
...selfSigned(scheme)
|
|
22619
22632
|
});
|
|
22620
22633
|
} catch {}
|
|
22621
22634
|
const deadline = Date.now() + timeoutMs;
|
|
22622
22635
|
while (Date.now() < deadline) {
|
|
22623
22636
|
try {
|
|
22624
|
-
await fetch(
|
|
22637
|
+
await fetch(`${scheme}://localhost:${port}/status`, {
|
|
22625
22638
|
headers: authHeaders,
|
|
22626
|
-
signal: AbortSignal.timeout(500)
|
|
22639
|
+
signal: AbortSignal.timeout(500),
|
|
22640
|
+
...selfSigned(scheme)
|
|
22627
22641
|
});
|
|
22628
22642
|
} catch {
|
|
22629
22643
|
return true;
|
|
@@ -22632,11 +22646,15 @@ async function requestShutdown(port, timeoutMs = 3000) {
|
|
|
22632
22646
|
}
|
|
22633
22647
|
return false;
|
|
22634
22648
|
}
|
|
22635
|
-
|
|
22649
|
+
function selfSigned(scheme) {
|
|
22650
|
+
return scheme === "https" ? { tls: { rejectUnauthorized: false } } : {};
|
|
22651
|
+
}
|
|
22652
|
+
async function probePort(port, scheme = "http") {
|
|
22636
22653
|
const unknown = { port, version: null, desktopApp: null, pid: null };
|
|
22637
22654
|
try {
|
|
22638
|
-
const resp = await fetch(
|
|
22655
|
+
const resp = await fetch(`${scheme}://localhost:${port}/status`, {
|
|
22639
22656
|
signal: AbortSignal.timeout(1000),
|
|
22657
|
+
...selfSigned(scheme),
|
|
22640
22658
|
headers: REQUIRED_TOKEN ? { "X-Haltija-Token": REQUIRED_TOKEN } : {}
|
|
22641
22659
|
});
|
|
22642
22660
|
if (!resp.ok)
|
|
@@ -22759,7 +22777,7 @@ if (USE_HTTPS) {
|
|
|
22759
22777
|
httpsServer = bindHttps();
|
|
22760
22778
|
} catch (err) {
|
|
22761
22779
|
if (certFailure(err)) {} else if (err && typeof err === "object" && "code" in err && err.code === "EADDRINUSE") {
|
|
22762
|
-
if (
|
|
22780
|
+
if (HTTPS_PORT_EXPLICIT !== null && await freePort(wantedHttpsPort, "https")) {
|
|
22763
22781
|
httpsServer = bindHttps();
|
|
22764
22782
|
} else {
|
|
22765
22783
|
for (let attempt = 0;attempt < 5 && !httpsServer; attempt++) {
|
|
@@ -23054,7 +23072,7 @@ function httpsLoaderHint() {
|
|
|
23054
23072
|
blocks http://localhost from https, so they need the HTTPS transport.`;
|
|
23055
23073
|
}
|
|
23056
23074
|
return ` On https pages load https://localhost:${HTTPS_PORT}/component.js?autoInject=true&serverUrl=wss://localhost:${HTTPS_PORT}/ws/browser
|
|
23057
|
-
(this server's HTTPS port is ephemeral; pass --https-port for a stable one).`;
|
|
23075
|
+
(this server's HTTPS port is ephemeral; pass --https-port <a free port> for a stable one).`;
|
|
23058
23076
|
}
|
|
23059
23077
|
console.log(`
|
|
23060
23078
|
To connect: Visit the server URL and drag the bookmarklet to your toolbar.
|
|
@@ -53,13 +53,70 @@
|
|
|
53
53
|
* how it earned this. So the point is scrolled into view first and the coordinate re-based, rather
|
|
54
54
|
* than making every caller remember.
|
|
55
55
|
*/
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* What `bridge.eval` may resolve to. Getting this wrong was the first outside adopter's first
|
|
58
|
+
* debugging cycle (tosijs-editor, virta #2415): the type was `Promise<any>`, the natural guess is a
|
|
59
|
+
* REST-style wrapper, and a wrapper made every probe fail while the probe itself ran perfectly.
|
|
60
|
+
*
|
|
61
|
+
* - the VALUE the code evaluated to (a returned Promise already resolved) — the normal case;
|
|
62
|
+
* - or `{ data: value }`, which is what haltija's own `/eval` answers;
|
|
63
|
+
* - or `{ success: false, error? }` when the code threw in the page.
|
|
64
|
+
*
|
|
65
|
+
* Nothing else. In particular NOT `{ success: true, value }` or `{ result }`.
|
|
66
|
+
*/
|
|
67
|
+
export type BridgeEvalResult = unknown | {
|
|
68
|
+
data: unknown;
|
|
69
|
+
} | {
|
|
70
|
+
success: false;
|
|
71
|
+
error?: string;
|
|
72
|
+
};
|
|
73
|
+
/** What `click` / `type` / `press` may resolve to: anything, or `{ success: false }` to fail. */
|
|
74
|
+
export type BridgeActionResult = unknown | {
|
|
75
|
+
success: false;
|
|
76
|
+
error?: string;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* How this talks to a browser: four methods. Structural, so any haltija client satisfies it, and so
|
|
80
|
+
* does anything else that can run a string of JavaScript in a page — see `playwrightBridge` for
|
|
81
|
+
* Firefox and WebKit.
|
|
82
|
+
*
|
|
83
|
+
* A method may also reject; the rejection reaches the test unchanged.
|
|
84
|
+
*/
|
|
57
85
|
export interface BrowserBridge {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
86
|
+
/** Run `code` (an expression, possibly an async IIFE) in the page. See `BridgeEvalResult`. */
|
|
87
|
+
eval(code: string): Promise<BridgeEvalResult>;
|
|
88
|
+
click(selector: string, options?: any): Promise<BridgeActionResult>;
|
|
89
|
+
type(selector: string, text: string): Promise<BridgeActionResult>;
|
|
90
|
+
press(key: string, modifiers?: any): Promise<BridgeActionResult>;
|
|
62
91
|
}
|
|
92
|
+
/** The part of a Playwright `Page` that `playwrightBridge` uses. Structural: no import needed. */
|
|
93
|
+
export interface PlaywrightPageLike {
|
|
94
|
+
evaluate(expression: string): Promise<unknown>;
|
|
95
|
+
locator(selector: string): {
|
|
96
|
+
click(): Promise<unknown>;
|
|
97
|
+
pressSequentially(text: string): Promise<unknown>;
|
|
98
|
+
};
|
|
99
|
+
keyboard: {
|
|
100
|
+
press(key: string): Promise<unknown>;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* A bridge over a Playwright page, for the engines haltija does not launch itself (virta #3088).
|
|
105
|
+
*
|
|
106
|
+
* `haltija --headless` is Chromium only, and for some questions Chromium is the engine that proves
|
|
107
|
+
* nothing: tosijs-editor needed WebKit's text shaping, where a Chromium run is all zeros by
|
|
108
|
+
* construction. They wrote this adapter by hand; three more adopters were about to.
|
|
109
|
+
*
|
|
110
|
+
* const browser = await webkit.launch()
|
|
111
|
+
* const page = createBrowserPage(playwrightBridge(await browser.newPage()))
|
|
112
|
+
*
|
|
113
|
+
* No haltija server or widget is involved on this path, and that has consequences worth knowing:
|
|
114
|
+
* `click` and `type` are Playwright's (real input, Playwright's actionability rules), not haltija's
|
|
115
|
+
* synthetic sequences, and selectors are Playwright's, so haltija's `:text()` extensions do not
|
|
116
|
+
* apply (Playwright has its own `:text()`). `read`, `clickAt` and `dragFrom` behave the same on
|
|
117
|
+
* every bridge, because they are plain JavaScript evaluated in the page.
|
|
118
|
+
*/
|
|
119
|
+
export declare function playwrightBridge(page: PlaywrightPageLike): BrowserBridge;
|
|
63
120
|
/**
|
|
64
121
|
* Serialize an expression for the page.
|
|
65
122
|
*
|
package/dist/test.d.ts
CHANGED
|
@@ -196,4 +196,4 @@ export declare const hj: HaltijaTestClient;
|
|
|
196
196
|
/** Create a test client for a custom server URL */
|
|
197
197
|
export declare function createTestClient(serverUrl: string): HaltijaTestClient;
|
|
198
198
|
export type { DevChannelTest, TestRunResult, SuiteRunResult };
|
|
199
|
-
export { createBrowserPage, serializeProbe, BrowserProbeError, assertBrowserTierRan, markBrowserTestRan, browserTestsRan, resetBrowserTestCount, type BrowserPage, type BrowserBridge, } from './test-in-browser';
|
|
199
|
+
export { createBrowserPage, playwrightBridge, serializeProbe, BrowserProbeError, assertBrowserTierRan, markBrowserTestRan, browserTestsRan, resetBrowserTestCount, type BrowserPage, type BrowserBridge, type BridgeEvalResult, type BridgeActionResult, type PlaywrightPageLike, } from './test-in-browser';
|
package/dist/test.js
CHANGED
|
@@ -594,6 +594,14 @@ function inferSuggestion(step, pageContext) {
|
|
|
594
594
|
}
|
|
595
595
|
|
|
596
596
|
// src/test-in-browser.ts
|
|
597
|
+
function playwrightBridge(page) {
|
|
598
|
+
return {
|
|
599
|
+
eval: (code) => page.evaluate(code),
|
|
600
|
+
click: (selector) => page.locator(selector).click(),
|
|
601
|
+
type: (selector, text) => page.locator(selector).pressSequentially(text),
|
|
602
|
+
press: (key) => page.keyboard.press(key)
|
|
603
|
+
};
|
|
604
|
+
}
|
|
597
605
|
function serializeProbe(fn, args = []) {
|
|
598
606
|
return `(${fn.toString()})(...${JSON.stringify(args)})`;
|
|
599
607
|
}
|
|
@@ -651,8 +659,11 @@ function createBrowserPage(bridge) {
|
|
|
651
659
|
if (!out || typeof out !== "object") {
|
|
652
660
|
throw new BrowserProbeError("probe returned no result \u2014 is a browser connected?", probe);
|
|
653
661
|
}
|
|
662
|
+
if (!("ok" in out)) {
|
|
663
|
+
throw new BrowserProbeError(`bridge.eval returned an object without the probe's { ok } envelope (keys: ${Object.keys(out).join(", ") || "none"}). ` + "It must return the VALUE the code evaluated to, optionally as { data: value }, not a wrapper of its own.", probe);
|
|
664
|
+
}
|
|
654
665
|
if (!out.ok)
|
|
655
|
-
throw new BrowserProbeError(out.error || "probe threw", probe);
|
|
666
|
+
throw new BrowserProbeError(out.error || "probe threw, with no message", probe);
|
|
656
667
|
return out.value;
|
|
657
668
|
},
|
|
658
669
|
async click(selector) {
|
|
@@ -1015,6 +1026,7 @@ export {
|
|
|
1015
1026
|
createTestClient,
|
|
1016
1027
|
hj,
|
|
1017
1028
|
markBrowserTestRan,
|
|
1029
|
+
playwrightBridge,
|
|
1018
1030
|
resetBrowserTestCount,
|
|
1019
1031
|
resolveTestServerUrl,
|
|
1020
1032
|
serializeProbe,
|
package/dist/version.d.ts
CHANGED
package/docs/CI-INTEGRATION.md
CHANGED
|
@@ -104,11 +104,11 @@ picking by the words "for CI" alone leads people to the wrong one:
|
|
|
104
104
|
| --- | --- | --- | --- |
|
|
105
105
|
| The default CI path | `--ci` | **Electron** (Chromium) | Electron (auto-fetched via `npx`) — **no Playwright** |
|
|
106
106
|
| Isolated CI instance, own ephemeral port | `--private --app` | **Electron** (Chromium) | Electron (as above) |
|
|
107
|
-
|
|
|
107
|
+
| A lighter single-engine run | `--headless` | **Playwright** Chromium (Chromium only) | the `playwright` package (`npm i playwright && npx playwright install`) |
|
|
108
108
|
|
|
109
109
|
Neither engine is bundled in the npm package, so *something* is downloaded either way — the choice
|
|
110
|
-
is **Electron vs. Playwright**, not "deps vs. no deps".
|
|
111
|
-
|
|
110
|
+
is **Electron vs. Playwright**, not "deps vs. no deps". Both are Chromium: `--headless` does
|
|
111
|
+
not give you Firefox or WebKit (see Option 3). For a straight
|
|
112
112
|
Chromium CI lane, `--ci` (Electron) needs no separate `playwright` install. `--private` is an
|
|
113
113
|
*isolation* modifier — it pairs with either engine (`--private --app` = Electron, `--private
|
|
114
114
|
--headless` = Playwright).
|
|
@@ -133,7 +133,7 @@ xvfb-run --auto-servernum bunx haltija@latest --app &
|
|
|
133
133
|
|
|
134
134
|
Same as `--ci` but doesn't wait for ready state. You'll need to poll `/status` yourself.
|
|
135
135
|
|
|
136
|
-
### Option 3: Playwright headless (
|
|
136
|
+
### Option 3: Playwright headless (Chromium)
|
|
137
137
|
|
|
138
138
|
```bash
|
|
139
139
|
npm install playwright && npx playwright install chromium # required — not bundled
|
|
@@ -142,9 +142,13 @@ bunx haltija@latest --headless &
|
|
|
142
142
|
|
|
143
143
|
Uses **Playwright's** Chromium instead of Electron, so it needs the `playwright` package installed
|
|
144
144
|
(the command above; without it, `--headless` exits with a "Playwright not installed" error that
|
|
145
|
-
points you back to `--ci`). Choose this when you want a lighter
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
points you back to `--ci`). Choose this when you want a lighter run than Electron. For a plain
|
|
146
|
+
Chromium lane with no extra install, prefer Option 1 (`--ci`).
|
|
147
|
+
|
|
148
|
+
**`--headless` is Chromium only; it has no engine flag.** Earlier versions of this page said to pick
|
|
149
|
+
it for Firefox/WebKit coverage, which it has never provided. For those engines, launch Playwright
|
|
150
|
+
yourself: host-side tests reach the page through `playwrightBridge`
|
|
151
|
+
([TEST-IN-BROWSER.md](TEST-IN-BROWSER.md)), and no haltija server is involved.
|
|
148
152
|
|
|
149
153
|
### Option 4: Server-only mode (lighter weight)
|
|
150
154
|
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Browser tests with the host's `expect` (`testInBrowser`)
|
|
2
|
+
|
|
3
|
+
Tests whose **assertions run on the host** (an ordinary `bun:test` or vitest file, with its real
|
|
4
|
+
`expect`) and whose **probes run in a real browser**. A probe is the small function you pass to
|
|
5
|
+
`read()`: it is the only thing sent to the page, and everything else in the test is an ordinary
|
|
6
|
+
closure. The package is ESM only.
|
|
7
|
+
|
|
8
|
+
The API is `createBrowserPage(bridge)`, where a *bridge* is whatever can run JavaScript in a page:
|
|
9
|
+
a haltija client, or a Playwright page.
|
|
10
|
+
|
|
11
|
+
## Before the first example runs
|
|
12
|
+
|
|
13
|
+
`npm i -D haltija`. A haltija client needs three things: a haltija server, a browser connected to it
|
|
14
|
+
(a page with haltija's widget in it), and that browser on the page under test.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm i -D playwright && npx playwright install chromium
|
|
18
|
+
bunx haltija --private --headless # prints HALTIJA_PRIVATE_READY {"port": …}
|
|
19
|
+
HALTIJA_PORT=<that port> bun test
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Always name your server** (`HALTIJA_PORT`, `HALTIJA_URL`, or `createTestClient(url)`). With none
|
|
23
|
+
of them, `hj` targets the machine's shared `localhost:8700`. There, `click`, `type`, `press` and
|
|
24
|
+
`navigate` refuse to run, but `read`, `clickAt` and `dragFrom` do run, against whatever tab is
|
|
25
|
+
focused, which may be someone else's.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { test, expect, beforeAll, afterAll } from 'bun:test'
|
|
29
|
+
import { hj, createBrowserPage, markBrowserTestRan, assertBrowserTierRan } from 'haltija/test'
|
|
30
|
+
|
|
31
|
+
const page = createBrowserPage(hj) // hj reads HALTIJA_PORT; or createTestClient('http://localhost:9123')
|
|
32
|
+
|
|
33
|
+
beforeAll(() => hj.navigate('http://localhost:3000/slider'))
|
|
34
|
+
|
|
35
|
+
test('the handle drags', async () => {
|
|
36
|
+
markBrowserTestRan()
|
|
37
|
+
// The app's own drag handle, 40px to the right. Start 5px inside its corner.
|
|
38
|
+
const box = await page.read(() => document.querySelector('#handle')!.getBoundingClientRect().toJSON())
|
|
39
|
+
await page.dragFrom(box.x + 5, box.y + 5, 40, 0)
|
|
40
|
+
expect(await page.read(() => document.querySelector('#readout')!.textContent)).toBe('40')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterAll(() => assertBrowserTierRan())
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`markBrowserTestRan()` counts a test, and `assertBrowserTierRan(minimum = 1)` throws if fewer than
|
|
47
|
+
`minimum` were counted. A suite that silently ran nothing has no failures, so without this it
|
|
48
|
+
passes.
|
|
49
|
+
|
|
50
|
+
`read(fn, args)` re-parses `fn` in the page, so it cannot see the test's variables. `args` is an
|
|
51
|
+
array spread into `fn`'s parameters: `page.read((a, b) => a + b, [2, 3])`. Return JSON-serializable
|
|
52
|
+
values. (Using `document` in a test file needs the `dom` lib in your tsconfig.)
|
|
53
|
+
|
|
54
|
+
`page` has `read`, `click`, `type`, `press`, and the coordinate actions `clickAt(x, y)` and
|
|
55
|
+
`dragFrom(x, y, dx, dy, steps?)` for UI with no selectable element. Coordinates are
|
|
56
|
+
viewport-relative, as `getBoundingClientRect()` reports them; a point below the fold is scrolled
|
|
57
|
+
into view first. The coordinate actions dispatch synthetic pointer and mouse events, on every
|
|
58
|
+
bridge. They drive handlers your code attached; they do not move a native control such as
|
|
59
|
+
`<input type=range>`.
|
|
60
|
+
|
|
61
|
+
## When it fails
|
|
62
|
+
|
|
63
|
+
| Cause | What you get |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| the probe threw in the page | `BrowserProbeError` (exported) with the page's message; `.probe` holds the source that was sent |
|
|
66
|
+
| `clickAt` / `dragFrom` hit nothing | `BrowserProbeError`: "nothing at (x, y) even after scrolling it into view", or "(x, y) is outside the document" |
|
|
67
|
+
| no server | the `fetch` connection error |
|
|
68
|
+
| server up, no browser connected | an `Error` carrying the server's message |
|
|
69
|
+
| no tests counted | `assertBrowserTierRan` throws |
|
|
70
|
+
|
|
71
|
+
## Which engine?
|
|
72
|
+
|
|
73
|
+
| Bridge | Engines | What `click` / `type` are |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| a haltija client (`hj`, `createTestClient(url)`) | whatever browser has the widget: the desktop app, `haltija --headless` (**Chromium only**), or any tab you injected it into | haltija's realistic synthetic sequences; haltija selectors, including `:text()` |
|
|
76
|
+
| `playwrightBridge(page)` | Chromium, Firefox, **WebKit** | Playwright's real input and Playwright's selectors |
|
|
77
|
+
|
|
78
|
+
`press` differs more than the others. Through a haltija client it is a single synthetic `keydown`
|
|
79
|
+
dispatched at `body`: no `keyup`, and not aimed at the focused element. Through Playwright it is a
|
|
80
|
+
real key press.
|
|
81
|
+
|
|
82
|
+
`read`, `clickAt` and `dragFrom` run the same JavaScript in the page on both. Return values can
|
|
83
|
+
differ at the edges: a haltija client returns JSON, while Playwright's own serialization also
|
|
84
|
+
carries `undefined`, `NaN` and `Date`.
|
|
85
|
+
|
|
86
|
+
### Firefox and WebKit: `playwrightBridge`
|
|
87
|
+
|
|
88
|
+
`haltija --headless` launches Chromium and has no engine flag. For another engine, launch it with
|
|
89
|
+
Playwright yourself and hand the page to the bridge. No haltija server is involved.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm i -D playwright && npx playwright install webkit
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { test, expect, afterAll } from 'bun:test' // an ordinary test file; @playwright/test is not needed
|
|
97
|
+
import { webkit } from 'playwright'
|
|
98
|
+
import { createBrowserPage, playwrightBridge } from 'haltija/test'
|
|
99
|
+
|
|
100
|
+
const browser = await webkit.launch()
|
|
101
|
+
const pw = await browser.newPage()
|
|
102
|
+
await pw.goto('http://localhost:3000')
|
|
103
|
+
const page = createBrowserPage(playwrightBridge(pw))
|
|
104
|
+
|
|
105
|
+
test('the heading renders', async () => {
|
|
106
|
+
expect(await page.read(() => document.querySelector('h1')!.getBoundingClientRect().width)).toBeGreaterThan(0)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
afterAll(() => browser.close())
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`clickAt` and `dragFrom` are still synthetic events here. For real mouse input, use `pw.mouse`
|
|
113
|
+
directly; the Playwright page is yours.
|
|
114
|
+
|
|
115
|
+
## Writing your own bridge
|
|
116
|
+
|
|
117
|
+
A bridge is four methods:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
interface BrowserBridge {
|
|
121
|
+
eval(code: string): Promise<BridgeEvalResult>
|
|
122
|
+
click(selector: string): Promise<BridgeActionResult>
|
|
123
|
+
type(selector: string, text: string): Promise<BridgeActionResult>
|
|
124
|
+
press(key: string): Promise<BridgeActionResult>
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**`eval` must resolve to the value the code evaluated to** (with a returned Promise already
|
|
129
|
+
resolved). Two other shapes are accepted: `{ data: value }`, and `{ success: false, error }` when
|
|
130
|
+
the code threw. Do not invent a wrapper such as `{ success: true, value }`: the probe will have run
|
|
131
|
+
correctly and every `read()` will fail, with an error saying the bridge returned no `{ ok }`
|
|
132
|
+
envelope.
|
|
133
|
+
|
|
134
|
+
`click`, `type` and `press` may resolve to anything; `{ success: false, error }` fails the step. Any
|
|
135
|
+
method may reject instead.
|
|
136
|
+
|
|
137
|
+
## One measuring trap
|
|
138
|
+
|
|
139
|
+
Measure rendered boxes only (`getBoundingClientRect().width > 0`). Collapsed whitespace and custom
|
|
140
|
+
elements that have not upgraded report a zero rect, and a zero rect compared with an earlier
|
|
141
|
+
non-zero one reads as a large layout shift. tosijs-editor measured a 1058px shift this way, in two
|
|
142
|
+
engines, that did not exist.
|
package/llms.txt
CHANGED
|
@@ -61,9 +61,9 @@ Two first-party ways to run (plus embedding, below):
|
|
|
61
61
|
(Chromium — the same engine the Haltija desktop app uses). Electron is fetched via
|
|
62
62
|
`npx` if not already present; **no `playwright` needed.** This is the default CI path.
|
|
63
63
|
- `haltija --headless` drives **Playwright Chromium**, which requires the `playwright`
|
|
64
|
-
package (`npm i playwright && npx playwright install chromium`).
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
package (`npm i playwright && npx playwright install chromium`). It is Chromium only,
|
|
65
|
+
with no engine flag; for Firefox/WebKit launch Playwright yourself (see
|
|
66
|
+
docs/TEST-IN-BROWSER.md). `--private` is an *isolation* modifier (ephemeral port), and
|
|
67
67
|
pairs with either engine: `--private --app` (Electron) or `--private --headless`
|
|
68
68
|
(Playwright). See the CI guide below.
|
|
69
69
|
|