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 +177 -0
- package/apps/desktop/main.js +94 -24
- package/apps/desktop/package.json +1 -1
- package/apps/desktop/resources/component.js +26 -2
- package/bin/hj.mjs +52 -0
- package/bin/private-state.mjs +29 -0
- package/bin/tosijs-dev.mjs +39 -1
- package/bin/version.mjs +1 -1
- package/dist/api-schema.d.ts +42 -42
- package/dist/component.d.ts +9 -1
- package/dist/component.esm.js +26 -2
- package/dist/component.js +26 -2
- package/dist/drag.d.ts +41 -0
- package/dist/hj.js +25 -2
- package/dist/index.js +220 -79
- package/dist/private-state.d.ts +34 -0
- package/dist/server.js +220 -79
- package/dist/test-actions.d.ts +29 -0
- package/dist/types.d.ts +27 -1
- package/dist/version.d.ts +1 -1
- package/docs/CI-INTEGRATION.md +7 -1
- package/package.json +1 -1
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
|
package/apps/desktop/main.js
CHANGED
|
@@ -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()
|
|
694
|
-
//
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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)
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
// src/version.ts
|
|
49
|
-
var VERSION = "1.12.
|
|
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
|
-
|
|
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
|
+
};
|
package/bin/tosijs-dev.mjs
CHANGED
|
@@ -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