haltija 1.12.0 → 1.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,182 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.2
4
+
5
+ Two fixes, both from an agent driving real apps. Each is a case where haltija reported success — or
6
+ green health — while quietly producing something you couldn't trust, which is the same thread the
7
+ 1.12.x line has been pulling on throughout.
8
+
9
+ **[#26](https://github.com/tonioloewald/haltija/issues/26) remains open and is NOT a 1.12.0
10
+ regression.** The reporter's own control run settled it: the known-bad version survived the original
11
+ failing surface through seven hard navigations plus an HMR rebuild. A tab becoming permanently
12
+ undrivable was really observed, so the issue stays open as a standing record — but neither of us can
13
+ currently reproduce it on any version, and nothing here claims to fix it.
14
+
15
+
16
+ ### `--private` instances get their own Electron profile — [#31](https://github.com/tonioloewald/haltija/issues/31)
17
+
18
+ Private mode isolated ports, the registry, retirement and teardown — but **not the Electron
19
+ profile**, so every instance ran with the same `--user-data-dir`. Chromium's single-instance locking
20
+ means the second one to launch can't take the profile lock and falls back to caches it cannot
21
+ persist, losing the HTTP cache and the V8 code cache. A large app bundle is then fully re-parsed on
22
+ every navigation: **roughly 10x slower page boots**.
23
+
24
+ The damage isn't the slowness, it's that it **lies in the one workflow private mode exists for**.
25
+ Comparing two versions side by side, the failure followed **launch order, not version** — 22.1s vs
26
+ 2.04s, the same version passing or failing depending only on which started first, and each fine
27
+ alone. The reporter nearly wrote up a version regression that did not exist. Every health signal
28
+ read green throughout, including `hj doctor` and a measured 120fps rAF cadence, because nothing was
29
+ broken — it was just slow.
30
+
31
+ Each private instance now gets `<tmpdir>/haltija-private-<pid>` as its `userData` and `sessionData`,
32
+ set before `app.whenReady()` and before anything reads `preferences.json`.
33
+
34
+ Two related leaks closed at the same time, both cases of "private" having meant *private ports*
35
+ rather than *touches nothing of yours*:
36
+
37
+ - **A private run no longer writes `~/.haltija/last-quit`.** That marker tells `hj`'s auto-launch the
38
+ user deliberately quit, so an automated run ending was suppressing auto-launch for the interactive
39
+ app a developer was using.
40
+ - **Stale private scratch is swept** at the start of the next private run — profiles and the
41
+ port-files the launcher writes (175 had accumulated on one machine). Swept at startup rather than
42
+ on exit because Chromium flushes its caches *after* `will-quit`, so deleting the profile there
43
+ just gets it recreated. The sweep keys on whether the owning pid is still alive and **never
44
+ touches a live peer** — `EPERM` from `kill(pid, 0)` counts as alive, since the process exists and
45
+ merely belongs to someone else. That decision is a tested function (`src/private-state.ts`), not a
46
+ loop in the launcher, because deleting a running instance's profile would be far worse than the
47
+ litter it tidies.
48
+
49
+ ### The test-suite runner gains `drag`, and a `wait` can no longer pass without waiting — [#30](https://github.com/tonioloewald/haltija/issues/30)
50
+
51
+ **`drag` is now a step action.** `hj drag` and `POST /drag` had shipped for releases; the runner's
52
+ dispatcher simply had no case, so a perfectly reasonable suite failed with `Unsupported step action:
53
+ drag`. Sliders, resize handles and drag-reorder lists are exactly the interactions you cannot cover
54
+ another way — a synthetic keydown on a slider thumb is not a faithful substitute.
55
+
56
+ The routine now lives in `src/drag.ts` and both `/drag` and the runner call it. Dragging is not one
57
+ message to the widget (scroll into view → measure → mouseenter/over/move → mousedown → N
58
+ interpolated mousemoves → mouseup), and a second copy in the runner's switch would have been the
59
+ fifth instance this cycle of one idea with two implementations.
60
+
61
+ **A `wait` step with nothing to wait for was reported as PASSING.** `{"action": "wait",
62
+ "forElement": "tbody tr", "timeout": 10000}` fell out of the runner's chain to `break`, and since a
63
+ step passes by default it looked green — so a guard that had never waited for anything let every
64
+ assertion after it race the page. Two fixes: `forElement` is now accepted as an alias of `selector`
65
+ (the name `/wait` uses, and the name **our own SKILL.md example used**, which means the documented
66
+ example never waited), and a `wait` carrying none of `duration`/`ms`/`selector`/`forElement`/
67
+ `forWindow`/`url` is now an **error**.
68
+
69
+ This is the same defect as the CLI's `hj wait --hidden`, fixed in 1.12.0 — that fix landed in the
70
+ CLI and never reached the runner.
71
+
72
+ **`hj test validate` now rejects illegal steps before the suite runs**, with a "did you mean":
73
+
74
+ ```
75
+ step 0: unknown step action "drg" — did you mean "drag"?. Legal actions: navigate, click, …
76
+ step 1: wait step has nothing to wait for — give it `duration` (ms), `selector` (or `forElement`) …
77
+ ```
78
+
79
+ Validation previously checked only that selectors resolved, so `{"action": "drag"}` validated clean
80
+ and then died in CI.
81
+
82
+ **And the list is published and enforced.** There was nowhere to look up the legal actions: `hj api`
83
+ documents the HTTP endpoints, which reads as though the same verbs work as steps. The canonical list
84
+ is now `TEST_STEP_ACTIONS` in `src/test-actions.ts`, printed in `SKILL.md`, `CLAUDE.md` and
85
+ `docs/CI-INTEGRATION.md` — and a test asserts it matches the runner's `switch (step.action)` in
86
+ **both** directions. Writing that guard immediately found `screenshot` documented in `SKILL.md` as a
87
+ step action when the runner has never had one.
88
+
89
+ ## 1.12.1
90
+
91
+ A patch of fixes reported by an agent driving a real React + web-components admin app against
92
+ 1.12.0 — the kind of surface no fixture reproduces. Four of the five below are cases where haltija
93
+ answered confidently and wrongly, which is the same thread 1.12.0 was pulling on.
94
+
95
+ **Still open: [#26](https://github.com/tonioloewald/haltija/issues/26)** — tabs reportedly
96
+ disconnect permanently on webpack-dev-server (CRA) origins in 1.12.0, and rc.5 is unaffected. It is
97
+ **not fixed here**, because I could not reproduce it: a real webpack-dev-server (v5 client, `hot` +
98
+ `liveReload`) survived on both 1.12.0 and the rc.5 widget, the opposite of the reporter's A/B. The
99
+ re-injection fix below may cover it and may not. If you drive a CRA dev server, test before you rely
100
+ on this release, and please add to that issue.
101
+
102
+
103
+ ### Text selectors pick the element you could actually click — [#27](https://github.com/tonioloewald/haltija/issues/27)
104
+
105
+ Two ways the same selector chose the wrong element, both found driving a real admin app:
106
+
107
+ - **A hidden duplicate that came first won.** `click` took the first match in DOM order and left the
108
+ visibility gate to complain afterwards, so a `display:none` copy made `hj click ':text(Save
109
+ Changes)'` fail with "zero-size bounding rect" while the visible copy sat right there — and
110
+ `hj find`, which filters before choosing, returned the right one. One selector, two answers.
111
+ - **An off-canvas element was clicked silently.** The `position:absolute; left:-9999px` skip-link
112
+ idiom has a perfectly normal box (measured: 99x35 at x=-9999), so every size and style check
113
+ passed it. `click` actuated an element no human can see and reported **success** — a script then
114
+ asserts against a state it never produced. This is the worse of the two: it fails confidently.
115
+
116
+ Resolution now filters *before* choosing, and `find` and `click` share one predicate so they cannot
117
+ disagree. An off-canvas element that is the ONLY match fails loudly (`positioned off-canvas`) rather
118
+ than being clicked invisibly.
119
+
120
+ Off-canvas is measured in **page** coordinates, so content merely **below the fold** is unaffected —
121
+ visible still means *rendered*, not *on screen*. That is also why `elementFromPoint` isn't used
122
+ here despite catching this case: it rejects everything scrolled out of view, which would fail
123
+ legitimate content in a small headless viewport.
124
+
125
+ ### `hj doctor` probes requestAnimationFrame — [#28](https://github.com/tonioloewald/haltija/issues/28)
126
+
127
+ **A tab can report `visibilityState: "visible"` and still not be compositing.** Occluded windows,
128
+ offscreen windows and a sleeping display all do it. Nothing rAF-driven then renders — React's
129
+ scheduler, tosijs `queueRender`, animations, virtual scrollers — while geometry probes keep
130
+ returning real numbers, so the absence of an element stops being evidence of anything.
131
+
132
+ That doesn't merely hide information, it manufactures a plausible wrong answer. The reporter found
133
+ four routes "not mounting" on hard navigation, had a coherent mechanism (the router gates its first
134
+ mount on rAF), reproduced it four times, and nearly filed it as an application bug. Opening a second
135
+ tab fixed all four.
136
+
137
+ `hj doctor` now measures it directly and fails with `requestAnimationFrame DID NOT FIRE within 2s`.
138
+ If the probe itself can't run, that is reported as **unchecked** — never as a pass — and the probe
139
+ is bounded at 3s so a pre-flight can't hang on a socket that never answers.
140
+
141
+ ### `hj tabs` — the array is `windows`, and a `tabs` alias now exists ([#29](https://github.com/tonioloewald/haltija/issues/29))
142
+
143
+ `d['tabs']` KeyError'd because the payload key is `windows` — accurate, since the list holds popups
144
+ and iframes too, but the command is `hj tabs`, so the obvious guess failed and it looked like the
145
+ caller's bug. `tabs` is now sent as an alias of the same array. The hint no longer mixes the two
146
+ vocabularies in one sentence ("Multiple **tabs** connected. Use `?window=<id>`"), which is where the
147
+ wrong idea came from.
148
+
149
+ Popups being indistinguishable from user-opened tabs, the other half of that report, is fixed by the
150
+ popup work below: they carry `windowType: "popup"` and never take focus.
151
+
152
+ ### Popups are popups again (desktop app) — [#25](https://github.com/tonioloewald/haltija/issues/25)
153
+
154
+ A page calling `window.open(url, name, 'width=...')` now gets a genuine popup: `window.open()`
155
+ returns a real `WindowProxy`, the child has `window.opener`, and `opener.postMessage(...)` reaches
156
+ the parent.
157
+
158
+ Previously the desktop app decided by **guessing from the URL** — allow if it contained `oauth`,
159
+ `signin`, `login`, `accounts.google.com` or `/__/auth/`; deny everything else and re-open it as a
160
+ tab, which severs the opener in both directions. `window.open()` returned `null` and the child had
161
+ no `opener`. That is the shape of every OAuth popup flow: the SDK keeps the returned window to poll
162
+ `.closed` and to `.close()`, and the callback page delivers its credential via `opener.postMessage`.
163
+ With neither, a user can complete a sign-in in a window that cannot report back and the app just
164
+ waits — arguably worse than a clean block.
165
+
166
+ The heuristic failed both ways: an innocent `/login-help` page became a popup, while the common SDK
167
+ pattern of opening `about:blank` and *then* navigating matched nothing and was denied — the very
168
+ case the list existed to catch. The decision now keys on Electron's `disposition`, which is what the
169
+ page actually asked for, so there is nothing to guess.
170
+
171
+ Two things come free, because the window model already handled popups correctly: the popup registers
172
+ as `windowType: "popup"` (tellable from a user-opened tab) and it **does not steal focus**, so
173
+ untargeted commands keep going to the tab you were driving.
174
+
175
+ Ordinary `<a target="_blank">` links still open as tabs in the app's tab strip — that behaviour is
176
+ deliberate and unchanged. **Known residual:** a featureless `window.open(url, '_blank')` is
177
+ indistinguishable from a `target="_blank"` link at this layer (both report `foreground-tab`), so it
178
+ still becomes a tab and still returns `null`.
179
+
3
180
  ## 1.12.0 — trustworthy by default
4
181
 
5
182
  **Trustworthy by default.** A minor, gated on the nine-lens pre-release review: every finding it
@@ -44,6 +44,44 @@ const IS_PRIVATE = process.env.HALTIJA_PRIVATE === '1'
44
44
  // consumer (e.g. a dev-server test lane) can drive this instance.
45
45
  const CALLER_PORT_FILE = IS_PRIVATE ? (process.env.HALTIJA_PORT_FILE || null) : null
46
46
 
47
+ // A PRIVATE INSTANCE GETS ITS OWN ELECTRON PROFILE.
48
+ //
49
+ // Private mode isolated the ports, the registry, retirement and teardown — but not the profile, so
50
+ // every instance ran with the same `--user-data-dir`. Chromium single-instance locking means the
51
+ // second one to launch cannot take the profile lock and falls back to caches it cannot persist,
52
+ // losing the HTTP cache and the V8 code cache. A large app bundle is then fully re-parsed on every
53
+ // navigation: measured at roughly 10x slower page boots (issue #31).
54
+ //
55
+ // The damage is not just slowness, it is a LIE in the one workflow private mode exists for. Running
56
+ // two versions side by side, the failure followed LAUNCH ORDER rather than version — 22.1s vs 2.04s,
57
+ // and the same version passed or failed depending only on which started first. The reporter nearly
58
+ // wrote up a version regression that did not exist. Every health signal read green throughout,
59
+ // including `hj doctor` and a 120fps rAF cadence, because nothing was broken; it was just slow.
60
+ //
61
+ // Keyed on pid, not port: the private ports are ephemeral and not known yet at this point, and pid
62
+ // is unique across concurrent runs by construction. Set BEFORE `app.whenReady()` and before
63
+ // anything reads `app.getPath('userData')` (preferences.json does), or the isolation is partial —
64
+ // which is how this bug existed at all.
65
+ let PRIVATE_USER_DATA = null
66
+ if (IS_PRIVATE) {
67
+ PRIVATE_USER_DATA = path.join(os.tmpdir(), `haltija-private-${process.pid}`)
68
+ try {
69
+ fs.mkdirSync(PRIVATE_USER_DATA, { recursive: true })
70
+ app.setPath('userData', PRIVATE_USER_DATA)
71
+ // Also keep the disk cache with it, so nothing is left in the shared profile.
72
+ app.setPath('sessionData', PRIVATE_USER_DATA)
73
+ } catch (err) {
74
+ // Falling back to the shared profile is slow and pollutes it, but it still WORKS — so say so
75
+ // loudly rather than refusing to start a run over a temp-directory failure.
76
+ console.error(
77
+ `[Haltija Desktop] Could not create a private profile at ${PRIVATE_USER_DATA}: ${err.message}. ` +
78
+ `Falling back to the shared profile — concurrent private instances will be slow and are ` +
79
+ `NOT safe to compare against each other (see issue #31).`,
80
+ )
81
+ PRIVATE_USER_DATA = null
82
+ }
83
+ }
84
+
47
85
  // Haltija server config
48
86
  let HALTIJA_PORT = IS_PRIVATE ? 0 : parseInt(process.env.HALTIJA_PORT || '8700')
49
87
  let HALTIJA_SERVER = `http://localhost:${HALTIJA_PORT}`
@@ -690,28 +728,47 @@ function setupWebContentsInjection(wc) {
690
728
 
691
729
  console.log('[Haltija Desktop] Monitoring webContents:', wc.id, wc.getType())
692
730
 
693
- // Intercept window.open() calls - redirect to tabs instead of new windows
694
- // Exception: allow auth popups which need to close and callback
695
- wc.setWindowOpenHandler(({ url, frameName, features }) => {
696
- console.log('[Haltija Desktop] Intercepted window.open:', url)
697
-
698
- // Allow OAuth/auth popups - they need popup behavior to work
699
- const isAuthPopup =
700
- url.includes('accounts.google.com') ||
701
- url.includes('/__/auth/') ||
702
- url.includes('/emulator/auth') ||
703
- url.includes('firebaseapp.com/__/auth') ||
704
- url.includes('oauth') ||
705
- url.includes('signin') ||
706
- url.includes('login') ||
707
- frameName === 'firebaseAuth'
708
-
709
- if (isAuthPopup) {
710
- console.log('[Haltija Desktop] Allowing auth popup:', url)
731
+ // Intercept window.open(): a genuine POPUP stays a popup; an ordinary new-window link becomes a
732
+ // tab in our own tab strip.
733
+ //
734
+ // This used to decide by GUESSING FROM THE URL — allow if it contained `oauth`, `signin`,
735
+ // `login`, `accounts.google.com`, `/__/auth/`. Everything else was denied and re-opened as a tab,
736
+ // which severs the opener relationship in both directions: `window.open()` returns **null** and
737
+ // the child has no `window.opener`. That is the shape of every OAuth popup flow (issue #25):
738
+ // an SDK keeps the returned WindowProxy to poll `.closed` and to `.close()`, and the callback
739
+ // page delivers its credential via `opener.postMessage(...)`. With neither, the user can complete
740
+ // a sign-in in a window that cannot report back — worse than a clean block, because the app just
741
+ // waits.
742
+ //
743
+ // The heuristic failed in both directions: an innocent `/login-help` page became a popup, while
744
+ // the common SDK pattern of opening `about:blank` and *then* navigating matched nothing and was
745
+ // denied — the case the list was written to catch.
746
+ //
747
+ // `disposition` is what the page actually asked for, so there is nothing to guess (verified
748
+ // against Electron):
749
+ // window.open(url, name, 'width=420,height=320') -> 'new-window' features: "width=..."
750
+ // window.open(url, '_blank') -> 'foreground-tab' features: ""
751
+ // <a target="_blank"> -> 'foreground-tab' features: ""
752
+ //
753
+ // Allowing a real popup also fixes two things for free, because the window model already handles
754
+ // popups correctly: it registers as `windowType: "popup"` (so scripts can tell it from a
755
+ // user-opened tab) and it does NOT steal focus, since only real tabs become the untargeted
756
+ // command target.
757
+ //
758
+ // KNOWN RESIDUAL: a featureless `window.open(url, '_blank')` is indistinguishable from a
759
+ // `target="_blank"` link at this layer — both report 'foreground-tab' — so it still becomes a tab
760
+ // and still returns null. Preserving the tab UX for ordinary links is worth that; if an SDK turns
761
+ // up that opens featureless popups and needs the opener, this is the line to revisit.
762
+ wc.setWindowOpenHandler((details) => {
763
+ const { url, disposition } = details
764
+ console.log('[Haltija Desktop] Intercepted window.open:', url, `(disposition: ${disposition})`)
765
+
766
+ if (disposition === 'new-window') {
767
+ console.log('[Haltija Desktop] Genuine popup — preserving the opener relationship:', url)
711
768
  return { action: 'allow' }
712
769
  }
713
770
 
714
- // Regular links: open as new tab instead of window
771
+ // Ordinary new-window link: open as a tab instead.
715
772
  if (mainWindow && mainWindow.webContents) {
716
773
  mainWindow.webContents.send('open-url-in-tab', url)
717
774
  }
@@ -1675,11 +1732,24 @@ if (!gotTheLock) {
1675
1732
  }
1676
1733
  // Drop a marker so hj's auto-launch knows the user explicitly quit.
1677
1734
  // Cleared next time the user manually starts Haltija.
1678
- try {
1679
- const dir = path.join(os.homedir(), '.haltija')
1680
- fs.mkdirSync(dir, { recursive: true })
1681
- fs.writeFileSync(path.join(dir, 'last-quit'), String(Date.now()))
1682
- } catch {}
1735
+ //
1736
+ // NOT in private mode: `~/.haltija/last-quit` is SHARED state, and an automated run ending is
1737
+ // not the user quitting anything. Writing it suppressed auto-launch for the interactive app the
1738
+ // developer is actually using — the same class as the profile sharing above (#31), where
1739
+ // "private" turned out to mean "private ports" rather than "touches nothing of yours".
1740
+ if (!IS_PRIVATE) {
1741
+ try {
1742
+ const dir = path.join(os.homedir(), '.haltija')
1743
+ fs.mkdirSync(dir, { recursive: true })
1744
+ fs.writeFileSync(path.join(dir, 'last-quit'), String(Date.now()))
1745
+ } catch {}
1746
+ }
1747
+
1748
+ // NOTE: the private profile is deliberately NOT deleted here. Chromium flushes its caches
1749
+ // AFTER 'will-quit', so removing the directory at this point just gets it recreated — measured:
1750
+ // both dirs survived a clean shutdown. Stale profiles are swept at the START of the next
1751
+ // private run instead (see sweepStalePrivateState in bin/tosijs-dev.mjs), keyed on whether the
1752
+ // owning pid is still alive, which is immune to shutdown ordering.
1683
1753
  })
1684
1754
 
1685
1755
  // Handle certificate errors (for self-signed certs in dev)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
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.12.0";
49
+ var VERSION = "1.12.2";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -624,6 +624,19 @@
624
624
  const candidates = queryAllDeep(parsed.baseSelector).filter((el) => !NON_RENDERED_TEXT.has(el.tagName) && elementTextMatches(el, parsed));
625
625
  return candidates.filter((el) => !candidates.some((other) => other !== el && containsDeep(el, other)));
626
626
  }
627
+ function isOffCanvas(el) {
628
+ const r = el.getBoundingClientRect();
629
+ return r.right + window.scrollX <= 0 || r.bottom + window.scrollY <= 0;
630
+ }
631
+ function isActionable(el) {
632
+ const cs = getComputedStyle(el);
633
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0")
634
+ return false;
635
+ const r = el.getBoundingClientRect();
636
+ if (r.width <= 0 || r.height <= 0)
637
+ return false;
638
+ return !isOffCanvas(el);
639
+ }
627
640
  function resolveSelector(selector) {
628
641
  if (!TEXT_PSEUDO_RE.test(selector)) {
629
642
  return document.querySelector(selector) || queryAllDeep(selector)[0] || null;
@@ -631,7 +644,8 @@
631
644
  const parsed = parseTextSelector(selector);
632
645
  if (!parsed)
633
646
  return document.querySelector(selector);
634
- return textMatchesInDocument(parsed)[0] || null;
647
+ const matches = textMatchesInDocument(parsed);
648
+ return matches.find(isActionable) || matches[0] || null;
635
649
  }
636
650
  function resolveSelectorAll(selector) {
637
651
  if (!TEXT_PSEUDO_RE.test(selector)) {
@@ -5027,6 +5041,9 @@ ${elementSummary}${moreText}`;
5027
5041
  }
5028
5042
  this.render();
5029
5043
  }
5044
+ get isDefunct() {
5045
+ return this.killed;
5046
+ }
5030
5047
  kill() {
5031
5048
  this.killed = true;
5032
5049
  hideHighlight();
@@ -7555,6 +7572,9 @@ ${elementSummary}${moreText}`;
7555
7572
  };
7556
7573
  }
7557
7574
  getHiddenReason(el) {
7575
+ if (isOffCanvas(el)) {
7576
+ return "positioned off-canvas (e.g. left:-9999px) — rendered but not reachable by a user";
7577
+ }
7558
7578
  const rect = el.getBoundingClientRect();
7559
7579
  if (rect.width === 0 && rect.height === 0) {
7560
7580
  return "zero-size bounding rect (element not rendered or in hidden container)";
@@ -8711,6 +8731,7 @@ ${elementSummary}${moreText}`;
8711
8731
  currentTagName = TAG_NAME;
8712
8732
  window.__haltija_resolveSelector = resolveSelector;
8713
8733
  window.__haltija_resolveSelectorAll = resolveSelectorAll;
8734
+ window.__haltija_isActionable = isActionable;
8714
8735
  window.__haltija_refRegistry = refRegistry;
8715
8736
  }
8716
8737
  registerDevChannel();
@@ -8723,6 +8744,9 @@ ${elementSummary}${moreText}`;
8723
8744
  if (existingVersion !== VERSION2) {
8724
8745
  console.log(`${LOG_PREFIX} Version mismatch (${existingVersion} -> ${VERSION2}), replacing`);
8725
8746
  existing.remove();
8747
+ } else if (existing.isDefunct) {
8748
+ console.log(`${LOG_PREFIX} Existing widget is defunct (killed), replacing`);
8749
+ existing.remove();
8726
8750
  } else {
8727
8751
  return existing;
8728
8752
  }
package/bin/hj.mjs CHANGED
@@ -357,6 +357,58 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
357
357
  if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
358
358
  notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`)
359
359
  }
360
+
361
+ // Does this tab actually PAINT? `visibilityState` answers "is this tab selected", not "is this
362
+ // tab being composited", and the two diverge for occluded windows, offscreen windows and a
363
+ // sleeping display. A starved tab renders nothing while reporting `visible`, geometry probes
364
+ // still return real numbers, and the absence of an element stops being evidence of anything.
365
+ //
366
+ // That is worse than a missing feature: it invites a plausible code-level explanation. An agent
367
+ // driving a React app found four routes "not mounting" on hard navigation, had a coherent
368
+ // mechanism (the router gates its first mount on rAF), reproduced it four times, and nearly
369
+ // filed it as an application bug. Opening a second tab fixed all four (#28). This check is here
370
+ // to convert that silent, confidently-wrong outcome into a visible one.
371
+ if (ready && tabs.length) {
372
+ const probe =
373
+ `new Promise(r => { const s = Date.now();` +
374
+ ` const t = setTimeout(() => r({ fired: false, ms: Date.now() - s }), 2000);` +
375
+ ` requestAnimationFrame(() => { clearTimeout(t); r({ fired: true, ms: Date.now() - s }) }) })`
376
+ let raf = null
377
+ try {
378
+ // BOUND IT. The probe resolves in ~2s at the latest when a browser is there to answer, so a
379
+ // longer wait means nothing is coming — and doctor is a pre-flight: a lane runs it to find
380
+ // out quickly, not to sit through another component's timeout. Without this, a connected
381
+ // socket that never answers `/eval` (a widget mid-teardown, or a test harness holding an
382
+ // open WebSocket) made `hj doctor` block for the server's full browser timeout.
383
+ const cancel = AbortSignal.timeout(3000)
384
+ const r = await fetch(`http://localhost:${port}/eval`, {
385
+ method: 'POST',
386
+ headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Haltija-Token': token } : {}) },
387
+ body: JSON.stringify({ code: probe }),
388
+ signal: cancel,
389
+ })
390
+ if (r.ok) {
391
+ const j = await r.json()
392
+ if (j && j.success && j.data && typeof j.data.fired === 'boolean') raf = j.data
393
+ }
394
+ } catch {
395
+ // Fall through to the unchecked branch — never a pass.
396
+ }
397
+ if (raf === null) {
398
+ unchecked.push(
399
+ `could not run the requestAnimationFrame probe — whether this tab actually paints is ` +
400
+ `UNKNOWN. If elements seem missing, suspect a non-compositing tab before the page.`,
401
+ )
402
+ } else if (!raf.fired) {
403
+ problems.push(
404
+ `requestAnimationFrame DID NOT FIRE within 2s — this tab is not compositing, even though ` +
405
+ `it reports visibilityState "visible". Anything rAF-driven (React's scheduler, tosijs ` +
406
+ `queueRender, animations, virtual scrollers) will never render, so a missing element ` +
407
+ `is NOT evidence of an application bug. Bring a window to the front, or wake the ` +
408
+ `display, and re-run.`,
409
+ )
410
+ }
411
+ }
360
412
  // Declared origins decide WHICH tab answers. A broken declaration silently disables the
361
413
  // routing it configures, and doctor is where a CI lane finds out.
362
414
  const origins = describeOrigins(tabs)
@@ -0,0 +1,29 @@
1
+ /** ⚠️ AUTO-GENERATED FROM src/private-state.ts — DO NOT EDIT. Run: bun run build */
2
+ // src/private-state.ts
3
+ function stalePrivateEntries(names, deps) {
4
+ const out = [];
5
+ for (const name of names) {
6
+ const m = /^haltija-private-(\d+)(\.json)?$/.exec(name);
7
+ if (!m)
8
+ continue;
9
+ const pid = parseInt(m[1], 10);
10
+ if (!pid || pid === deps.selfPid)
11
+ continue;
12
+ if (deps.isAlive(pid))
13
+ continue;
14
+ out.push(name);
15
+ }
16
+ return out;
17
+ }
18
+ function pidIsAlive(pid) {
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ } catch (err) {
23
+ return err?.code === "EPERM";
24
+ }
25
+ }
26
+ export {
27
+ stalePrivateEntries,
28
+ pidIsAlive
29
+ };
@@ -13,10 +13,11 @@
13
13
  */
14
14
 
15
15
  import { spawn, execSync as execSyncImported } from 'child_process'
16
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync } from 'fs'
17
17
  import { homedir, platform, tmpdir } from 'os'
18
18
  import { fileURLToPath } from 'url'
19
19
  import { dirname, join } from 'path'
20
+ import { stalePrivateEntries, pidIsAlive } from './private-state.mjs'
20
21
 
21
22
  const __dirname = dirname(fileURLToPath(import.meta.url))
22
23
  const serverPath = join(__dirname, '../dist/server.js')
@@ -452,8 +453,45 @@ if (privateMode) {
452
453
  ? args[portFileIdx + 1]
453
454
  : join(tmpdir(), `haltija-private-${process.pid}.json`)
454
455
  env.HALTIJA_PORT_FILE = privatePortFile
456
+ sweepStalePrivateState()
455
457
  }
456
458
 
459
+ /**
460
+ * Remove the scratch left by private runs that are no longer alive.
461
+ *
462
+ * Two kinds accumulate in tmpdir, both named `haltija-private-<pid>`:
463
+ * - the port-file (`<pid>.json`) this launcher writes
464
+ * - the Electron profile directory (issue #31), several MB of cache per run
465
+ *
466
+ * Swept at STARTUP rather than on exit because Chromium flushes its caches AFTER Electron's
467
+ * 'will-quit' fires — deleting the profile there simply gets it recreated, which is what happened
468
+ * when this was first written that way. Liveness of the owning pid is the test, so a sweep can
469
+ * never touch a concurrent run: the whole point of private mode is that instances don't interfere,
470
+ * and a cleanup that deletes a live peer's profile would be a far worse bug than the litter.
471
+ *
472
+ * Runs for `--private --headless` too, since the launcher is common to both.
473
+ */
474
+ function sweepStalePrivateState() {
475
+ const dir = tmpdir()
476
+ let entries
477
+ try {
478
+ entries = readdirSync(dir)
479
+ } catch {
480
+ return
481
+ }
482
+ // The decision lives in src/private-state.ts and is unit-tested: never sweep a live peer, never
483
+ // sweep ourselves, and treat EPERM from kill(pid,0) as ALIVE (the process exists, it is just not
484
+ // ours to signal). Getting that backwards would delete another user's running instance.
485
+ for (const name of stalePrivateEntries(entries, { selfPid: process.pid, isAlive: pidIsAlive })) {
486
+ try {
487
+ rmSync(join(dir, name), { recursive: true, force: true })
488
+ } catch {
489
+ // Best effort — the OS reaps tmpdir eventually, and failing to tidy must never stop a run.
490
+ }
491
+ }
492
+ }
493
+
494
+
457
495
  /** Poll the private instance's port-file until it reports its ephemeral port. */
458
496
  /**
459
497
  * Wait for a private instance to report its ephemeral port. 30s because this may be waiting on a
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.12.0'
6
+ export const HJ_VERSION = '1.12.2'