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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.13.0-beta.2",
3
+ "version": "1.13.0-beta.3",
4
4
  "private": true,
5
5
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
6
6
  "homepage": "https://github.com/tonioloewald/haltija",
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.13.0-beta.2";
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: agent.version ?? description?.version,
1383
- ...shapeWarning ? { warning: shapeWarning } : {},
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: ${globalName}.write(path, value) for a ` + `⟷ two-way binding, ${globalName}.call(actionPath) for an action. ` + `Run them with: hj eval "${globalName}.write('some.path', 'value')"`,
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 && hidden.length === tabs.length) {
402
+ if (ready && target && !isVisible(target)) {
382
403
  problems.push(
383
- `every connected tab reports HIDDEN — results from a backgrounded tab can be ` +
384
- `plausible-but-wrong (rAF/timers throttled). Bring one to the front.`,
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
- const r = await fetch(`http://localhost:${port}/eval`, {
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,
@@ -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("Pick this for MULTI-ENGINE coverage (Firefox/WebKit) — not just because it says \"CI\".")}
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] Reach for --headless (Playwright) when you need Firefox/WebKit coverage.')
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
@@ -3,4 +3,4 @@
3
3
  * ⚠️ To change the version, update package.json and run: bun run build
4
4
  */
5
5
 
6
- export const HJ_VERSION = '1.13.0-beta.2'
6
+ export const HJ_VERSION = '1.13.0-beta.3'
@@ -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.2";
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;
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "1.13.0-beta.2";
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: agent.version ?? description?.version,
1336
- ...shapeWarning ? { warning: shapeWarning } : {},
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: ${globalName}.write(path, value) for a ` + `⟷ two-way binding, ${globalName}.call(actionPath) for an action. ` + `Run them with: hj eval "${globalName}.write('some.path', 'value')"`,
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.2";
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: agent.version ?? description?.version,
1383
- ...shapeWarning ? { warning: shapeWarning } : {},
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: ${globalName}.write(path, value) for a ` + `⟷ two-way binding, ${globalName}.call(actionPath) for an action. ` + `Run them with: hj eval "${globalName}.write('some.path', 'value')"`,
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
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.2";
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 && hidden.length === tabs.length) {
2799
- problems.push(`every connected tab reports HIDDEN — results from a backgrounded tab can be ` + `plausible-but-wrong (rAF/timers throttled). Bring one to the front.`);
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.2";
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"\`) \u2014 EXPERIMENTAL.** haltija detects this tier by duck-typing one
1373
- method and consumes a shape tosijs has not committed to (no version/capability marker yet \u2014 see
1374
- tosijs#23). When the shape isn't what haltija expects it returns a \`warning\` and \`agentSurfaceVersion\`
1375
- rather than passing a blank map off as a success, but treat the tier as best-effort. When the page
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\`). Reach for it when you
3496
- want multi-engine coverage (Firefox/WebKit) or a lighter single-engine run \u2014 not just
3497
- because it says "for CI". \`--private\` is an *isolation* modifier (ephemeral port), and
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.2";
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: agent.version ?? description?.version,
4960
- ...shapeWarning ? { warning: shapeWarning } : {},
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: \${globalName}.write(path, value) for a \` + \`\u27F7 two-way binding, \${globalName}.call(actionPath) for an action. \` + \`Run them with: hj eval "\${globalName}.write('some.path', 'value')"\`,
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"\`) \u2014 EXPERIMENTAL.** haltija detects this tier by duck-typing one
15072
- method and consumes a shape tosijs has not committed to (no version/capability marker yet \u2014 see
15073
- tosijs#23). When the shape isn't what haltija expects it returns a \`warning\` and \`agentSurfaceVersion\`
15074
- rather than passing a blank map off as a success, but treat the tier as best-effort. When the page
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(`http://localhost:${port}/shutdown`, {
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(`http://localhost:${port}/status`, {
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
- async function probePort(port) {
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(`http://localhost:${port}/status`, {
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 (PORT_IS_STRICT && await freePort(wantedHttpsPort)) {
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.2";
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"\`) \u2014 EXPERIMENTAL.** haltija detects this tier by duck-typing one
1373
- method and consumes a shape tosijs has not committed to (no version/capability marker yet \u2014 see
1374
- tosijs#23). When the shape isn't what haltija expects it returns a \`warning\` and \`agentSurfaceVersion\`
1375
- rather than passing a blank map off as a success, but treat the tier as best-effort. When the page
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\`). Reach for it when you
3496
- want multi-engine coverage (Firefox/WebKit) or a lighter single-engine run \u2014 not just
3497
- because it says "for CI". \`--private\` is an *isolation* modifier (ephemeral port), and
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.2";
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: agent.version ?? description?.version,
4960
- ...shapeWarning ? { warning: shapeWarning } : {},
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: \${globalName}.write(path, value) for a \` + \`\u27F7 two-way binding, \${globalName}.call(actionPath) for an action. \` + \`Run them with: hj eval "\${globalName}.write('some.path', 'value')"\`,
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"\`) \u2014 EXPERIMENTAL.** haltija detects this tier by duck-typing one
15072
- method and consumes a shape tosijs has not committed to (no version/capability marker yet \u2014 see
15073
- tosijs#23). When the shape isn't what haltija expects it returns a \`warning\` and \`agentSurfaceVersion\`
15074
- rather than passing a blank map off as a success, but treat the tier as best-effort. When the page
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(`http://localhost:${port}/shutdown`, {
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(`http://localhost:${port}/status`, {
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
- async function probePort(port) {
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(`http://localhost:${port}/status`, {
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 (PORT_IS_STRICT && await freePort(wantedHttpsPort)) {
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
- /** How this talks to a browser. Structural, so any haltija client satisfies it. */
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
- eval(code: string): Promise<any>;
59
- click(selector: string, options?: any): Promise<any>;
60
- type(selector: string, text: string): Promise<any>;
61
- press(key: string, modifiers?: any): Promise<any>;
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
@@ -8,4 +8,4 @@
8
8
  * ⚠️ AUTO-GENERATED FROM package.json - DO NOT EDIT THIS FILE
9
9
  * ⚠️ To change the version, update package.json and run: bun run build
10
10
  */
11
- export declare const VERSION = "1.13.0-beta.2";
11
+ export declare const VERSION = "1.13.0-beta.3";
@@ -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
- | Multi-engine coverage (Firefox/WebKit) or a lighter single-engine run | `--headless` | **Playwright** Chromium | the `playwright` package (`npm i playwright && npx playwright install`) |
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". The one genuine reason to reach for
111
- `--headless`/Playwright is engines Electron can't give you (Firefox, WebKit). For a straight
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 (multi-engine)
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 single-engine run or, the real
146
- reason, **multi-engine coverage** — Playwright can also drive Firefox and WebKit, which Electron
147
- cannot. For a plain Chromium lane with no extra install, prefer Option 1 (`--ci`).
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`). Reach for it when you
65
- want multi-engine coverage (Firefox/WebKit) or a lighter single-engine run — not just
66
- because it says "for CI". `--private` is an *isolation* modifier (ephemeral port), and
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija",
3
- "version": "1.13.0-beta.2",
3
+ "version": "1.13.0-beta.3",
4
4
  "description": "Browser control for AI agents - query DOM, click, type, run JS, watch mutations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",