@broberg/lens-daemon 0.0.0-stage → 0.1.0
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/README.md +14 -2
- package/package.json +52 -3
- package/src/artifact-backfill.ts +253 -0
- package/src/artifact-uploads.ts +370 -0
- package/src/auth/index.ts +294 -0
- package/src/browser.ts +440 -0
- package/src/build-marker.ts +197 -0
- package/src/capture-session.ts +873 -0
- package/src/capture.ts +348 -0
- package/src/critic-dom.ts +951 -0
- package/src/critic-vision.ts +205 -0
- package/src/critic.ts +73 -0
- package/src/daemon-routes.ts +333 -0
- package/src/durability.ts +18 -0
- package/src/ensure-chromium.ts +118 -0
- package/src/export.ts +256 -0
- package/src/fill.ts +149 -0
- package/src/flow-store.ts +207 -0
- package/src/flow.ts +2869 -0
- package/src/frames.ts +195 -0
- package/src/gate.ts +123 -0
- package/src/http.ts +175 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +741 -0
- package/src/mcp.ts +562 -0
- package/src/motion-probe.ts +653 -0
- package/src/password-in-url.ts +106 -0
- package/src/pdf-text.ts +514 -0
- package/src/pixelmatch.d.ts +21 -0
- package/src/preflight.ts +76 -0
- package/src/readiness.ts +282 -0
- package/src/record-run.ts +427 -0
- package/src/record.ts +387 -0
- package/src/sandbox.ts +22 -0
- package/src/schema.ts +300 -0
- package/src/scrape.ts +116 -0
- package/src/seed-storage.ts +149 -0
- package/src/service.ts +1483 -0
- package/src/storage.ts +159 -0
- package/src/store.ts +267 -0
- package/src/swallowed-errors.ts +252 -0
- package/src/validate-vision.ts +97 -0
- package/src/verify.ts +411 -0
- package/src/video.ts +211 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
// F312.1 — the in-page motion probe.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBE IS THE GROUND TRUTH; the video is the illustration. That ordering is
|
|
4
|
+
// the whole point of F312 and it is the lesson of the night that produced it: I
|
|
5
|
+
// diagnosed a scroll bug from source three times, each diagnosis internally
|
|
6
|
+
// consistent and mutation-proven, and three of them made the product worse. What
|
|
7
|
+
// none of them had was a measurement of the thing being complained about.
|
|
8
|
+
//
|
|
9
|
+
// The complaint is never "the view moved". It is "the view moved AND I DID NOT DO
|
|
10
|
+
// IT". So the probe records both halves — the position, and when a real human
|
|
11
|
+
// input last happened — because only the pair can tell a defect from normal use.
|
|
12
|
+
|
|
13
|
+
/** One reading of the scroller, taken inside the page. */
|
|
14
|
+
export interface MotionSample {
|
|
15
|
+
/** ms since the probe started. */
|
|
16
|
+
t: number;
|
|
17
|
+
/** scrollTop at that instant. */
|
|
18
|
+
top: number;
|
|
19
|
+
/** scrollHeight — a jump with a height change is content arriving, not a jump. */
|
|
20
|
+
height: number;
|
|
21
|
+
/** F312.7 — the viewport height, so `height - client` gives the MAXIMUM scrollTop.
|
|
22
|
+
* Without it a move cannot be told apart from the browser CLAMPING scrollTop
|
|
23
|
+
* after the content above shrank, which is a completely different cause with a
|
|
24
|
+
* completely different fix. */
|
|
25
|
+
client: number;
|
|
26
|
+
/** How many rows are rendered; distinguishes a re-render from a scroll. */
|
|
27
|
+
rows: number;
|
|
28
|
+
/** The first row whose bottom clears the viewport top — "what he is looking at". */
|
|
29
|
+
firstRow: string | null;
|
|
30
|
+
/** F312.14 — WHERE that row sits, in px from the viewport top. Together with
|
|
31
|
+
* `firstRow` this is what the eye actually sees; `top` alone is not. */
|
|
32
|
+
firstRowY: number;
|
|
33
|
+
/** F312.15 — every row currently on screen, with its offset. The FIRST row can
|
|
34
|
+
* differ between two samples while the reader's own message has not moved, so
|
|
35
|
+
* the comparison needs a row that exists in both. */
|
|
36
|
+
visible: { id: string | null; y: number }[];
|
|
37
|
+
/** F312.23 — THE READER'S OWN ROW, which is the only one the question is about.
|
|
38
|
+
*
|
|
39
|
+
* `firstRow` is whatever the render window happens to start with and changes
|
|
40
|
+
* identity on every prepend. The app remembers his place in localStorage under
|
|
41
|
+
* `chat-anchor:<edge>:<channel>`; the probe reads the same key, so this costs
|
|
42
|
+
* the product nothing and exposes nothing new on `window`.
|
|
43
|
+
*
|
|
44
|
+
* `anchorY - anchorWant` is the whole question in one number: 0 means his row
|
|
45
|
+
* did not move, whatever `top` did. It is the measurement `visibleShift` cannot
|
|
46
|
+
* make, because that one needs a row present in BOTH samples and a jump larger
|
|
47
|
+
* than the viewport guarantees there is none — blind exactly where it matters. */
|
|
48
|
+
anchorId: string | null;
|
|
49
|
+
/** Where his row actually sits, in px from the scroller's top. */
|
|
50
|
+
anchorY: number | null;
|
|
51
|
+
/** Where the app PROMISED to hold it — the stored `offset`. */
|
|
52
|
+
anchorWant: number | null;
|
|
53
|
+
/** `held` his row is rendered · `bottom` he is parked at the live end ·
|
|
54
|
+
* `not-rendered` he has a place, it is outside the window · `none` no key.
|
|
55
|
+
* `bottom` is deliberately NOT `none`: being at the live end IS a remembered
|
|
56
|
+
* place, and collapsing the two would report "nothing to hold" for a reader
|
|
57
|
+
* who has one. */
|
|
58
|
+
anchorState: 'held' | 'bottom' | 'not-rendered' | 'none';
|
|
59
|
+
/** ms since the last input that CAN legitimately move the view — a wheel, a
|
|
60
|
+
* touch, an arrow/page key. */
|
|
61
|
+
sinceInput: number | null;
|
|
62
|
+
/** F312.5 — ms since the last KEYSTROKE into a field. Deliberately separate:
|
|
63
|
+
* typing is input, and typing must NEVER move the view. Folding it into
|
|
64
|
+
* `sinceInput` made the detector blind to the worst version of this defect —
|
|
65
|
+
* the one where he is mid-sentence in a table cell and the ground moves. */
|
|
66
|
+
sinceTyping: number | null;
|
|
67
|
+
/** ms since the driver last acted, or null. Its own actions are not defects. */
|
|
68
|
+
sinceDriver: number | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A move nobody asked for. */
|
|
72
|
+
export interface MotionFinding {
|
|
73
|
+
t: number;
|
|
74
|
+
from: number;
|
|
75
|
+
to: number;
|
|
76
|
+
/** Signed: negative means he was carried BACKWARDS, which is the worse direction. */
|
|
77
|
+
delta: number;
|
|
78
|
+
firstRowBefore: string | null;
|
|
79
|
+
firstRowAfter: string | null;
|
|
80
|
+
/** F312.23 — HOW FAR HIS OWN ROW SAT FROM WHERE THE APP PROMISED TO HOLD IT,
|
|
81
|
+
* before and after. `null` when the anchor is not readable at that instant
|
|
82
|
+
* (parked at the live end, outside the render window, or no key).
|
|
83
|
+
*
|
|
84
|
+
* This is the measure `visibleShift` cannot make. That one needs a row present
|
|
85
|
+
* in BOTH samples, so a jump larger than the viewport leaves it null — blind
|
|
86
|
+
* exactly where the move is biggest. This one is per-sample and independent of
|
|
87
|
+
* the pair, so a 1852px change in `top` with `anchorDrift` steady at 0 is
|
|
88
|
+
* compensation that kept him still, and the same change with `anchorDrift`
|
|
89
|
+
* jumping is a move. The recording that motivated this could not tell them
|
|
90
|
+
* apart, and two opposite fixes both looked justified. */
|
|
91
|
+
anchorDriftBefore: number | null;
|
|
92
|
+
anchorDriftAfter: number | null;
|
|
93
|
+
/** Which of the four anchor states each sample was in, so a `null` drift is
|
|
94
|
+
* never mistaken for "measured, and it was fine". */
|
|
95
|
+
anchorStateBefore: MotionSample['anchorState'];
|
|
96
|
+
anchorStateAfter: MotionSample['anchorState'];
|
|
97
|
+
/** F312.24 — DID THE VIEW MOVE, OR THE LAYOUT?
|
|
98
|
+
*
|
|
99
|
+
* The spread (`max − min`) of how far each row present in BOTH samples moved.
|
|
100
|
+
* Near 0 means every shared row travelled the same distance — the layout is
|
|
101
|
+
* rigid and the VIEW scrolled. Large means they moved by different amounts, so
|
|
102
|
+
* content between them changed height and the LAYOUT moved under him.
|
|
103
|
+
*
|
|
104
|
+
* `null` with fewer than TWO shared rows, never 0: one row cannot tell the two
|
|
105
|
+
* cases apart, and 0 would read as "the layout was rigid".
|
|
106
|
+
*
|
|
107
|
+
* `visibleShift` asks how far the picture moved using ONE shared row; this
|
|
108
|
+
* asks a different question of the same data and needs two. Both are reported,
|
|
109
|
+
* because a ±24px jitter has the same `visibleShift` whichever mechanism
|
|
110
|
+
* produced it, and the two need opposite fixes. */
|
|
111
|
+
layoutSpread: number | null;
|
|
112
|
+
/** True when the rendered row count changed too — content arrived, so the move
|
|
113
|
+
* may be a consequence rather than a cause. Reported, never used to hide it. */
|
|
114
|
+
contentChanged: boolean;
|
|
115
|
+
/** F312.5 — he was TYPING when it happened. Not a separate defect: the same
|
|
116
|
+
* move, in the circumstance where it costs the most. */
|
|
117
|
+
whileTyping: boolean;
|
|
118
|
+
/**
|
|
119
|
+
* F312.6 — WHY this move is (or is not) counted, and how long since his last
|
|
120
|
+
* scroll. Nothing is discarded any more.
|
|
121
|
+
*
|
|
122
|
+
* Christian's third case: "jeg scroller op for lige at læse noget og vupti så
|
|
123
|
+
* er den hoppet et nyt sted hen". That move lands SHORTLY AFTER his scroll —
|
|
124
|
+
* inside the momentum grace — so the first version dropped it silently. A
|
|
125
|
+
* dropped observation cannot be reviewed, and this is the same lesson as
|
|
126
|
+
* everything else tonight: classify, never discard.
|
|
127
|
+
*/
|
|
128
|
+
cause: 'none' | 'his-scroll' | 'driver';
|
|
129
|
+
/** ms since his last scroll-intent at the moment of the move; null = never. */
|
|
130
|
+
sinceInput: number | null;
|
|
131
|
+
/**
|
|
132
|
+
* F312.7 — WAS IT A CLAMP? The content above shrank far enough that the old
|
|
133
|
+
* scrollTop no longer existed, and the browser pulled it down to the new
|
|
134
|
+
* maximum. That is a different defect with a different fix from "something
|
|
135
|
+
* called scrollTo", and the two are indistinguishable without this.
|
|
136
|
+
*
|
|
137
|
+
* True when the move was BACKWARDS onto the new maximum from a position that
|
|
138
|
+
* the shrink had just made impossible. A forward move is never a clamp, and
|
|
139
|
+
* neither is a backwards one from a position still inside the new range.
|
|
140
|
+
*/
|
|
141
|
+
clamped: boolean;
|
|
142
|
+
/** scrollHeight − clientHeight, before and after. */
|
|
143
|
+
maxBefore: number;
|
|
144
|
+
maxAfter: number;
|
|
145
|
+
/**
|
|
146
|
+
* F312.14 — HOW FAR THE PICTURE MOVED, which is a different number from `delta`.
|
|
147
|
+
*
|
|
148
|
+
* Same row still at the top, same offset → 0: the reader saw nothing, however
|
|
149
|
+
* far scrollTop travelled. That is the normal case for an anchor holding a
|
|
150
|
+
* message steady while content is inserted above it, and calling it movement
|
|
151
|
+
* points the next fix at the one mechanism that is working.
|
|
152
|
+
*
|
|
153
|
+
* null when the top row CHANGED — then the picture certainly moved, and by more
|
|
154
|
+
* than a pixel count can express, so no number is offered rather than a
|
|
155
|
+
* misleading one.
|
|
156
|
+
*/
|
|
157
|
+
visibleShift: number | null;
|
|
158
|
+
/** F312.15 — how many rows were rendered either side. A top row that changed
|
|
159
|
+
* because rows were REMOVED above is the render window dropping the message
|
|
160
|
+
* under the reader; one that changed with the count steady is something
|
|
161
|
+
* scrolling him. Same symptom, opposite fix. */
|
|
162
|
+
rowsBefore: number;
|
|
163
|
+
rowsAfter: number;
|
|
164
|
+
/** Where the top row sat, either side — so a null visibleShift still shows its
|
|
165
|
+
* working rather than only its verdict. */
|
|
166
|
+
firstRowYBefore: number;
|
|
167
|
+
firstRowYAfter: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ProbeConfig {
|
|
171
|
+
/** CSS selector of the scroller. Omitted, the probe picks the tallest scrollable. */
|
|
172
|
+
selector?: string;
|
|
173
|
+
/** Sampling interval in ms. 50 = 20 Hz: fast enough to catch one flush, slow
|
|
174
|
+
* enough that the probe is not itself the jank it is measuring. */
|
|
175
|
+
intervalMs?: number;
|
|
176
|
+
/** A row's stable identity attribute. */
|
|
177
|
+
rowAttr?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The script injected into the page. Written as a string-returning function so it
|
|
182
|
+
* can be handed to `page.evaluate` without a bundler, and so the browser half is
|
|
183
|
+
* readable next to the Node half that interprets it.
|
|
184
|
+
*
|
|
185
|
+
* READ-ONLY. It attaches passive listeners and reads geometry; it never writes
|
|
186
|
+
* style, never scrolls, never forces a layout beyond the rect reads the sampling
|
|
187
|
+
* already needs. A probe that changes what it measures is worse than no probe.
|
|
188
|
+
*/
|
|
189
|
+
export function probeSource(cfg: ProbeConfig = {}): string {
|
|
190
|
+
const interval = cfg.intervalMs ?? 50;
|
|
191
|
+
const rowAttr = cfg.rowAttr ?? 'data-ev-uuid';
|
|
192
|
+
const sel = cfg.selector ? JSON.stringify(cfg.selector) : 'null';
|
|
193
|
+
return `(() => {
|
|
194
|
+
const SEL = ${sel};
|
|
195
|
+
const ROW = ${JSON.stringify(rowAttr)};
|
|
196
|
+
const pick = () => {
|
|
197
|
+
if (SEL) return document.querySelector(SEL);
|
|
198
|
+
// The tallest thing that can actually scroll. Deliberately not "the first
|
|
199
|
+
// overflow:auto": a page has several, and the one the human is reading is the
|
|
200
|
+
// one with content to spare.
|
|
201
|
+
let best = null, bestOver = 0;
|
|
202
|
+
for (const el of document.querySelectorAll('*')) {
|
|
203
|
+
const over = el.scrollHeight - el.clientHeight;
|
|
204
|
+
if (over > bestOver && el.clientHeight > 200) { best = el; bestOver = over; }
|
|
205
|
+
}
|
|
206
|
+
return best;
|
|
207
|
+
};
|
|
208
|
+
let el = pick();
|
|
209
|
+
const state = {
|
|
210
|
+
started: performance.now(),
|
|
211
|
+
lastInput: null,
|
|
212
|
+
lastTyping: null,
|
|
213
|
+
lastDriver: null,
|
|
214
|
+
samples: [],
|
|
215
|
+
selector: el ? (el.getAttribute('data-testid') ? '[data-testid="' + el.getAttribute('data-testid') + '"]' : el.tagName.toLowerCase()) : null,
|
|
216
|
+
error: el ? null : 'no scrollable element found',
|
|
217
|
+
};
|
|
218
|
+
window.__lensMotion = state;
|
|
219
|
+
// A DRIVER action stamps this; the Node half then knows the move was ours.
|
|
220
|
+
window.__lensDriverActed = () => { state.lastDriver = performance.now(); };
|
|
221
|
+
if (!el) return 'no-scroller';
|
|
222
|
+
// Passive + capture: we must not delay or alter the very interaction we measure.
|
|
223
|
+
// F312.5 — TWO CLOCKS, and the split is the whole correction.
|
|
224
|
+
//
|
|
225
|
+
// A wheel, a touch or an arrow key is a request to MOVE THE VIEW, so a move
|
|
226
|
+
// right after one is his and not a defect. A character typed into a field is
|
|
227
|
+
// input that must move NOTHING. Counting it as scroll-intent meant that while
|
|
228
|
+
// he was typing — continuously, one keystroke every ~150ms — every single move
|
|
229
|
+
// fell inside the grace window and the detector reported nothing at all. It was
|
|
230
|
+
// blind in exactly the case he described: "jeg kan ikke stå og svare på en
|
|
231
|
+
// tekst i et felt på en tabel uden at den rykker væk fra mig".
|
|
232
|
+
const NAV_KEYS = new Set(['ArrowUp','ArrowDown','PageUp','PageDown','Home','End',' ','Spacebar']);
|
|
233
|
+
const markScroll = () => { state.lastInput = performance.now(); };
|
|
234
|
+
const markType = () => { state.lastTyping = performance.now(); };
|
|
235
|
+
for (const ev of ['wheel', 'touchstart', 'touchmove', 'pointerdown'])
|
|
236
|
+
window.addEventListener(ev, markScroll, { capture: true, passive: true });
|
|
237
|
+
window.addEventListener('keydown', (e) => {
|
|
238
|
+
// A navigation key IN a text field still moves the caret, not the page — but
|
|
239
|
+
// outside one it scrolls. Editable target decides, which is the same question
|
|
240
|
+
// the browser itself asks.
|
|
241
|
+
const t = e.target;
|
|
242
|
+
const editable = t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable);
|
|
243
|
+
if (NAV_KEYS.has(e.key) && !editable) markScroll();
|
|
244
|
+
else markType();
|
|
245
|
+
}, { capture: true, passive: true });
|
|
246
|
+
// F312.14 — WHAT THE EYE SEES, not what scrollTop says.
|
|
247
|
+
//
|
|
248
|
+
// These are not the same quantity, and conflating them makes the probe report
|
|
249
|
+
// the CORRECT behaviour as a defect. When content is inserted above the
|
|
250
|
+
// viewport, holding the reader's message still REQUIRES scrollTop to change by
|
|
251
|
+
// exactly the inserted height — the number moves so that the picture does not.
|
|
252
|
+
// Measured 6 Sept: a run flagged eight 'prepend-hold' moves of −92px to −456px,
|
|
253
|
+
// every one of them the anchor doing its job.
|
|
254
|
+
//
|
|
255
|
+
// So the probe also records WHERE the first visible row sits. Same row at the
|
|
256
|
+
// same offset = the reader saw nothing move, whatever scrollTop did.
|
|
257
|
+
// F312.23 — read the reader's OWN remembered place out of the app's storage.
|
|
258
|
+
// Several channels can each have a key; the right one is the channel currently
|
|
259
|
+
// rendered, so the uuid must be present in the DOM. Picking the first key would
|
|
260
|
+
// silently report ANOTHER conversation's row and look exactly like working.
|
|
261
|
+
const anchorAt = () => {
|
|
262
|
+
let sawBottom = false;
|
|
263
|
+
let sawPlace = false;
|
|
264
|
+
try {
|
|
265
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
266
|
+
const k = localStorage.key(i);
|
|
267
|
+
if (!k || k.indexOf('chat-anchor:') !== 0) continue;
|
|
268
|
+
let v = null;
|
|
269
|
+
try { v = JSON.parse(localStorage.getItem(k)); } catch (e) { continue; }
|
|
270
|
+
if (!v) continue;
|
|
271
|
+
if (v.bottom) { sawBottom = true; continue; }
|
|
272
|
+
if (!v.uuid) continue;
|
|
273
|
+
sawPlace = true;
|
|
274
|
+
const row = el.querySelector('[' + ROW + '="' + (window.CSS && CSS.escape ? CSS.escape(v.uuid) : v.uuid) + '"]');
|
|
275
|
+
if (!row) continue;
|
|
276
|
+
return {
|
|
277
|
+
id: v.uuid,
|
|
278
|
+
y: Math.round(row.getBoundingClientRect().top - el.getBoundingClientRect().top),
|
|
279
|
+
want: typeof v.offset === 'number' ? -Math.round(v.offset) : 0,
|
|
280
|
+
state: 'held',
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
} catch (e) { /* storage can throw in a private context; report none */ }
|
|
284
|
+
if (sawPlace) return { id: null, y: null, want: null, state: 'not-rendered' };
|
|
285
|
+
if (sawBottom) return { id: null, y: null, want: null, state: 'bottom' };
|
|
286
|
+
return { id: null, y: null, want: null, state: 'none' };
|
|
287
|
+
};
|
|
288
|
+
const firstRowAt = () => {
|
|
289
|
+
const top = el.getBoundingClientRect().top;
|
|
290
|
+
const bottom = top + el.clientHeight;
|
|
291
|
+
const rows = el.querySelectorAll('[' + ROW + ']');
|
|
292
|
+
// F312.15 — record SEVERAL visible rows, not only the first.
|
|
293
|
+
//
|
|
294
|
+
// Comparing "the first visible row before" against "the first visible row
|
|
295
|
+
// after" answers a different question from the one being asked. Those can be
|
|
296
|
+
// two different messages while the one the reader is actually looking at has
|
|
297
|
+
// not moved a pixel — a message above it merely grew into view. The honest
|
|
298
|
+
// measure is: take a row present in BOTH samples and ask where it went.
|
|
299
|
+
const vis = [];
|
|
300
|
+
let first = { id: null, y: 0 };
|
|
301
|
+
for (let i = 0; i < rows.length; i++) {
|
|
302
|
+
const b = rows[i].getBoundingClientRect();
|
|
303
|
+
if (b.bottom <= top + 4) continue;
|
|
304
|
+
if (b.top >= bottom) break;
|
|
305
|
+
const id = rows[i].getAttribute(ROW);
|
|
306
|
+
const y = Math.round(b.top - top);
|
|
307
|
+
if (first.id === null) first = { id: id, y: y };
|
|
308
|
+
if (vis.length < 8) vis.push({ id: id, y: y });
|
|
309
|
+
}
|
|
310
|
+
return { id: first.id, y: first.y, vis: vis };
|
|
311
|
+
};
|
|
312
|
+
// F312.16 — WHEN DID IT LAND? A marker planted in the fed message is looked for
|
|
313
|
+
// on every tick, and the first tick that sees it is its arrival. Paired with the
|
|
314
|
+
// send time on the Node side this gives the end-to-end latency of the live
|
|
315
|
+
// stream — and, more to the point here, a defensible answer to "was the surface
|
|
316
|
+
// exercised by REAL agent output, or only by its own echo?".
|
|
317
|
+
const seen = {};
|
|
318
|
+
window.__lensMarkers = seen;
|
|
319
|
+
const scanMarkers = (nowMs) => {
|
|
320
|
+
const t = el.textContent || '';
|
|
321
|
+
let i = t.indexOf('LENSPROBE-');
|
|
322
|
+
while (i !== -1) {
|
|
323
|
+
const m = t.slice(i, i + 24).split(/[^A-Za-z0-9-]/)[0];
|
|
324
|
+
if (m && seen[m] === undefined) seen[m] = Math.round(nowMs - state.started);
|
|
325
|
+
i = t.indexOf('LENSPROBE-', i + 10);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
const tick = () => {
|
|
329
|
+
// F312.22 — RE-RESOLVE A REPLACED SCROLLER.
|
|
330
|
+
//
|
|
331
|
+
// The probe held the element it found at start. A tab switch REPLACES that
|
|
332
|
+
// node, so from that moment the probe was measuring a detached element:
|
|
333
|
+
// scrollHeight 0, no samples, and the return — the thing being measured —
|
|
334
|
+
// was invisible. Measured 6 Sept on the first tab-switch run that actually
|
|
335
|
+
// ran: heightEnd 0, and nothing recorded after the switch.
|
|
336
|
+
//
|
|
337
|
+
// Only when the node has genuinely left the document. A live element is
|
|
338
|
+
// never swapped, so this cannot make the probe follow the wrong scroller
|
|
339
|
+
// mid-measurement; and if the re-pick finds nothing, the old reference is
|
|
340
|
+
// kept rather than replaced with null, so a transient re-render does not
|
|
341
|
+
// blind the run.
|
|
342
|
+
if (el && !el.isConnected) {
|
|
343
|
+
const again = pick();
|
|
344
|
+
if (again) el = again;
|
|
345
|
+
}
|
|
346
|
+
const now = performance.now();
|
|
347
|
+
scanMarkers(now);
|
|
348
|
+
const fr = firstRowAt();
|
|
349
|
+
const an = anchorAt();
|
|
350
|
+
state.samples.push({
|
|
351
|
+
t: Math.round(now - state.started),
|
|
352
|
+
top: Math.round(el.scrollTop),
|
|
353
|
+
height: Math.round(el.scrollHeight),
|
|
354
|
+
client: Math.round(el.clientHeight),
|
|
355
|
+
rows: el.querySelectorAll('[' + ROW + ']').length,
|
|
356
|
+
firstRow: fr.id,
|
|
357
|
+
firstRowY: fr.y,
|
|
358
|
+
visible: fr.vis,
|
|
359
|
+
anchorId: an.id,
|
|
360
|
+
anchorY: an.y,
|
|
361
|
+
anchorWant: an.want,
|
|
362
|
+
anchorState: an.state,
|
|
363
|
+
sinceInput: state.lastInput == null ? null : Math.round(now - state.lastInput),
|
|
364
|
+
sinceTyping: state.lastTyping == null ? null : Math.round(now - state.lastTyping),
|
|
365
|
+
sinceDriver: state.lastDriver == null ? null : Math.round(now - state.lastDriver),
|
|
366
|
+
});
|
|
367
|
+
};
|
|
368
|
+
tick();
|
|
369
|
+
state.timer = setInterval(tick, ${interval});
|
|
370
|
+
return 'ok';
|
|
371
|
+
})()`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Read the samples back out. */
|
|
375
|
+
export const PROBE_READ = `(() => {
|
|
376
|
+
const s = window.__lensMotion;
|
|
377
|
+
if (!s) return { error: 'probe never started', samples: [] };
|
|
378
|
+
if (s.timer) clearInterval(s.timer);
|
|
379
|
+
// F312.12 — bring the page's own scroll ledger back, REBASED onto the probe's
|
|
380
|
+
// clock so an entry can be lined up with the millisecond of a finding. Absent
|
|
381
|
+
// on a surface that does not keep one; that is reported as an empty list, never
|
|
382
|
+
// as a failure, since most surfaces have nothing to declare.
|
|
383
|
+
// Entries from BEFORE the probe started keep a negative t rather than being
|
|
384
|
+
// dropped: a scroll the page performed while it was loading is still evidence,
|
|
385
|
+
// and silently discarding it makes an empty ledger ambiguous between "nothing
|
|
386
|
+
// wrote" and "everything wrote too early to count".
|
|
387
|
+
const ledger = Array.isArray(window.__chatScrollLog)
|
|
388
|
+
? window.__chatScrollLog.map((n) => ({ ...n, t: Math.round(n.t - s.started) }))
|
|
389
|
+
: null;
|
|
390
|
+
// null = the surface keeps NO ledger (the property was never created).
|
|
391
|
+
// [] = it keeps one and nothing wrote to it. Those are different answers and
|
|
392
|
+
// collapsing them is how a broken read looks like a clean result.
|
|
393
|
+
return {
|
|
394
|
+
error: s.error,
|
|
395
|
+
selector: s.selector,
|
|
396
|
+
samples: s.samples,
|
|
397
|
+
ledger,
|
|
398
|
+
markers: window.__lensMarkers || {},
|
|
399
|
+
};
|
|
400
|
+
})()`;
|
|
401
|
+
|
|
402
|
+
/** F312.12 — one entry from the page's own scroll ledger, on the probe's clock. */
|
|
403
|
+
export interface ScrollNote {
|
|
404
|
+
t: number;
|
|
405
|
+
why: string;
|
|
406
|
+
from: number;
|
|
407
|
+
to: number;
|
|
408
|
+
force: boolean;
|
|
409
|
+
refused?: string;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export interface FindOptions {
|
|
413
|
+
/** A move smaller than this is noise — sub-pixel settling, a scrollbar, a font. */
|
|
414
|
+
minDeltaPx?: number;
|
|
415
|
+
/**
|
|
416
|
+
* How long after a real input a move is still HIS. Momentum on a trackpad keeps
|
|
417
|
+
* moving the page for a while after the last wheel event, and reporting that as
|
|
418
|
+
* a defect would bury the real ones.
|
|
419
|
+
*/
|
|
420
|
+
inputGraceMs?: number;
|
|
421
|
+
/** The same grace for the driver's own actions. */
|
|
422
|
+
driverGraceMs?: number;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Moves that no input preceded.
|
|
427
|
+
*
|
|
428
|
+
* THE NEGATIVE CONTROL IS BUILT IN, not bolted on: a move within `inputGraceMs`
|
|
429
|
+
* of a human input, or `driverGraceMs` of our own driving, is NOT a finding. A
|
|
430
|
+
* detector that reports every move reports itself, and would have "found" a bug
|
|
431
|
+
* in every recording ever made — the most expensive kind of green.
|
|
432
|
+
*/
|
|
433
|
+
/** F312.23 — how far his row is from where the app said it would hold it.
|
|
434
|
+
*
|
|
435
|
+
* `null` whenever the anchor is not readable, and that is not the same as 0: a
|
|
436
|
+
* reader at the live end, or one whose message is outside the render window, has
|
|
437
|
+
* a place we simply cannot see this instant. Returning 0 there would report "his
|
|
438
|
+
* row did not move" for a row nobody looked at. */
|
|
439
|
+
export function anchorDrift(s: MotionSample): number | null {
|
|
440
|
+
if (s.anchorState !== 'held') return null;
|
|
441
|
+
if (s.anchorY == null || s.anchorWant == null) return null;
|
|
442
|
+
return s.anchorY - s.anchorWant;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function findUncausedMoves(samples: MotionSample[], opts: FindOptions = {}): MotionFinding[] {
|
|
446
|
+
const minDelta = opts.minDeltaPx ?? 24;
|
|
447
|
+
const inputGrace = opts.inputGraceMs ?? 700;
|
|
448
|
+
const driverGrace = opts.driverGraceMs ?? 900;
|
|
449
|
+
const out: MotionFinding[] = [];
|
|
450
|
+
for (let i = 1; i < samples.length; i++) {
|
|
451
|
+
const a = samples[i - 1]!;
|
|
452
|
+
const b = samples[i]!;
|
|
453
|
+
const delta = b.top - a.top;
|
|
454
|
+
if (Math.abs(delta) < minDelta) continue;
|
|
455
|
+
// CLASSIFIED, NOT DROPPED (F312.6). His finger and ours are still not
|
|
456
|
+
// defects — but they are RECORDED as such, so "he had just stopped
|
|
457
|
+
// scrolling" is visible instead of invisible.
|
|
458
|
+
//
|
|
459
|
+
// NOTE what does not appear here: `sinceTyping`. Typing is not a reason to
|
|
460
|
+
// move the view, so it can never excuse one — it only makes the move worse,
|
|
461
|
+
// which `whileTyping` records.
|
|
462
|
+
const cause: MotionFinding['cause'] =
|
|
463
|
+
b.sinceDriver != null && b.sinceDriver <= driverGrace
|
|
464
|
+
? 'driver'
|
|
465
|
+
: b.sinceInput != null && b.sinceInput <= inputGrace
|
|
466
|
+
? 'his-scroll'
|
|
467
|
+
: 'none';
|
|
468
|
+
out.push({
|
|
469
|
+
cause,
|
|
470
|
+
sinceInput: b.sinceInput,
|
|
471
|
+
t: b.t,
|
|
472
|
+
from: a.top,
|
|
473
|
+
to: b.top,
|
|
474
|
+
delta,
|
|
475
|
+
firstRowBefore: a.firstRow,
|
|
476
|
+
firstRowAfter: b.firstRow,
|
|
477
|
+
anchorDriftBefore: anchorDrift(a),
|
|
478
|
+
anchorDriftAfter: anchorDrift(b),
|
|
479
|
+
anchorStateBefore: a.anchorState,
|
|
480
|
+
anchorStateAfter: b.anchorState,
|
|
481
|
+
contentChanged: a.rows !== b.rows || a.height !== b.height,
|
|
482
|
+
whileTyping: b.sinceTyping != null && b.sinceTyping <= 1500,
|
|
483
|
+
maxBefore: a.height - a.client,
|
|
484
|
+
maxAfter: b.height - b.client,
|
|
485
|
+
visibleShift: sharedShift(a, b),
|
|
486
|
+
layoutSpread: layoutSpread(a, b),
|
|
487
|
+
rowsBefore: a.rows,
|
|
488
|
+
rowsAfter: b.rows,
|
|
489
|
+
firstRowYBefore: a.firstRowY,
|
|
490
|
+
firstRowYAfter: b.firstRowY,
|
|
491
|
+
// A CLAMP is the browser having nowhere to put you: the content above
|
|
492
|
+
// shrank, so the position you were at STOPPED EXISTING and you were pushed
|
|
493
|
+
// back to the new bottom. That means it is necessarily BACKWARDS and it
|
|
494
|
+
// requires `from` to be past the new maximum.
|
|
495
|
+
//
|
|
496
|
+
// The first version only asked "did the max fall, and are you at it now?",
|
|
497
|
+
// which is true of ANY move that lands at the bottom. Measured 6 Sept: a
|
|
498
|
+
// +5806px yank from a reading position down to the bottom — the single
|
|
499
|
+
// worst finding in the run — was labelled a clamp while the maximum had
|
|
500
|
+
// moved 31px. A clamp cannot account for more travel than the maximum lost,
|
|
501
|
+
// and calling a forced scroll a clamp points the next diagnosis at the
|
|
502
|
+
// wrong mechanism entirely.
|
|
503
|
+
clamped: a.top > b.height - b.client && b.top >= b.height - b.client - 2 && delta < 0,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return out;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* F312.15 — HOW FAR THE PICTURE MOVED, measured on a row that is on screen in
|
|
511
|
+
* BOTH samples.
|
|
512
|
+
*
|
|
513
|
+
* The first version compared the top row's identity, and answered "unknown"
|
|
514
|
+
* whenever it changed. That is too pessimistic in the exact case that matters:
|
|
515
|
+
* a message ABOVE the one being read can grow into view, making it the new top
|
|
516
|
+
* row, while the message under the reader's eye has not moved at all. Measured 6
|
|
517
|
+
* Sept — the anchor holding a message steady was reported as an unknown-sized
|
|
518
|
+
* jump nine times.
|
|
519
|
+
*
|
|
520
|
+
* So: find a row present on screen in both, and report where it went. Null only
|
|
521
|
+
* when they share NO row, which genuinely means the reader is looking at
|
|
522
|
+
* different content and no pixel count would describe it.
|
|
523
|
+
*/
|
|
524
|
+
/** F312.24 — how much the shared rows DISAGREE about how far they moved.
|
|
525
|
+
*
|
|
526
|
+
* A pure scroll moves every row by the same amount, so the spread is 0. Content
|
|
527
|
+
* above one of them growing moves them by different amounts, and the spread is
|
|
528
|
+
* that growth. It is the one number that separates "the view moved" from "the
|
|
529
|
+
* ground moved", and the drift column cannot: both produce the same drift.
|
|
530
|
+
*
|
|
531
|
+
* Fewer than two shared rows is `null`. Returning 0 there would assert rigidity
|
|
532
|
+
* from a sample that cannot see it. */
|
|
533
|
+
export function layoutSpread(a: MotionSample, b: MotionSample): number | null {
|
|
534
|
+
const before = new Map<string, number>();
|
|
535
|
+
for (const v of a.visible) if (v.id) before.set(v.id, v.y);
|
|
536
|
+
const shifts: number[] = [];
|
|
537
|
+
for (const v of b.visible) {
|
|
538
|
+
if (!v.id) continue;
|
|
539
|
+
const y = before.get(v.id);
|
|
540
|
+
if (y == null) continue;
|
|
541
|
+
shifts.push(v.y - y);
|
|
542
|
+
}
|
|
543
|
+
if (shifts.length < 2) return null;
|
|
544
|
+
return Math.round(Math.max(...shifts) - Math.min(...shifts));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function sharedShift(a: MotionSample, b: MotionSample): number | null {
|
|
548
|
+
const after = new Map<string, number>();
|
|
549
|
+
for (const r of b.visible ?? []) if (r.id != null) after.set(r.id, r.y);
|
|
550
|
+
for (const r of a.visible ?? []) {
|
|
551
|
+
if (r.id == null) continue;
|
|
552
|
+
const y = after.get(r.id);
|
|
553
|
+
if (y != null) return y - r.y;
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The moves nobody caused. Still reported — classify, never discard (F312.6). */
|
|
559
|
+
export function uncaused(findings: MotionFinding[]): MotionFinding[] {
|
|
560
|
+
return findings.filter((f) => f.cause === 'none');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* F312.19 — WHAT A DEFECT ACTUALLY IS: nobody caused it AND the reader could see
|
|
565
|
+
* it.
|
|
566
|
+
*
|
|
567
|
+
* Raised by the trail session from a real run of ours. Two findings, both
|
|
568
|
+
* `cause: 'none'`, both scrollTop −92px — and both with `visible_shift` 0 and the
|
|
569
|
+
* top row at the same offset before and after. Content above the reading position
|
|
570
|
+
* had shrunk and the browser adjusted scrollTop to match. That is scroll
|
|
571
|
+
* anchoring doing its job perfectly, reported as a defect.
|
|
572
|
+
*
|
|
573
|
+
* Their sentence is the specification: the complaint is "the picture moved and I
|
|
574
|
+
* did not do it" — and there, the picture did not move. Two false ones a minute
|
|
575
|
+
* in an ordinary chat would drown the real one.
|
|
576
|
+
*
|
|
577
|
+
* `null` COUNTS AS VISIBLE, and that asymmetry is the load-bearing part: null
|
|
578
|
+
* means the two samples shared no row at all, so the reader is looking at
|
|
579
|
+
* different content and the move was as visible as a move can be. Treating an
|
|
580
|
+
* unknown as "not seen" would hide the largest jumps this tool exists to catch.
|
|
581
|
+
*/
|
|
582
|
+
export function defects(findings: MotionFinding[], minVisiblePx = 8): MotionFinding[] {
|
|
583
|
+
return findings.filter(
|
|
584
|
+
(f) => f.cause === 'none' && (f.visibleShift === null || Math.abs(f.visibleShift) > minVisiblePx),
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* F312.8 — WAS THE THING EVEN EXERCISED?
|
|
590
|
+
*
|
|
591
|
+
* Measured the hard way: two identical 120s recordings of the same build gave 4
|
|
592
|
+
* findings and then 0. Nothing was fixed in between — the second window simply
|
|
593
|
+
* had less arriving in the stream. A zero from a quiet window is indistinguishable
|
|
594
|
+
* from a zero from a fixed product, and reporting the first as the second is the
|
|
595
|
+
* false green this whole feature exists to prevent.
|
|
596
|
+
*
|
|
597
|
+
* So a run reports how much the surface actually CHANGED. A zero with no content
|
|
598
|
+
* events is "not exercised"; a zero with forty is a result.
|
|
599
|
+
*/
|
|
600
|
+
export interface Exercise {
|
|
601
|
+
/** Samples where the row count or the height changed — i.e. content arrived. */
|
|
602
|
+
contentEvents: number;
|
|
603
|
+
rowsStart: number;
|
|
604
|
+
rowsEnd: number;
|
|
605
|
+
heightStart: number;
|
|
606
|
+
heightEnd: number;
|
|
607
|
+
/** The honest verdict on whether a zero means anything. */
|
|
608
|
+
exercised: boolean;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export function measureExercise(samples: MotionSample[]): Exercise {
|
|
612
|
+
if (samples.length === 0)
|
|
613
|
+
return { contentEvents: 0, rowsStart: 0, rowsEnd: 0, heightStart: 0, heightEnd: 0, exercised: false };
|
|
614
|
+
let events = 0;
|
|
615
|
+
for (let i = 1; i < samples.length; i++) {
|
|
616
|
+
const a = samples[i - 1]!;
|
|
617
|
+
const b = samples[i]!;
|
|
618
|
+
if (a.rows !== b.rows || a.height !== b.height) events++;
|
|
619
|
+
}
|
|
620
|
+
const first = samples[0]!;
|
|
621
|
+
const last = samples[samples.length - 1]!;
|
|
622
|
+
return {
|
|
623
|
+
contentEvents: events,
|
|
624
|
+
rowsStart: first.rows,
|
|
625
|
+
rowsEnd: last.rows,
|
|
626
|
+
heightStart: first.height,
|
|
627
|
+
heightEnd: last.height,
|
|
628
|
+
// Deliberately strict, but on the RIGHT quantity. The first version required
|
|
629
|
+
// the row count to GROW — measured wrong: the render window drops old
|
|
630
|
+
// messages as new ones arrive, so a busy 120s window ended with FEWER rows
|
|
631
|
+
// (123 → 121) and the run reported itself as not exercised while 13 content
|
|
632
|
+
// events and 900px of growth had gone past. Height is the honest signal;
|
|
633
|
+
// rows are a window, not a total.
|
|
634
|
+
exercised: events >= 10 && last.height !== first.height,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** Worst first — and BACKWARDS beats forwards at equal size, because being carried
|
|
639
|
+
* into the past is the complaint that started this. An uncaused move always
|
|
640
|
+
* outranks one he or the driver caused; the caused ones stay in the list so a
|
|
641
|
+
* "vupti, den hoppede" right after a scroll is READABLE rather than dropped. */
|
|
642
|
+
export function rankFindings(findings: MotionFinding[]): MotionFinding[] {
|
|
643
|
+
return [...findings].sort((x, y) => {
|
|
644
|
+
const own = (f: MotionFinding) => (f.cause === 'none' ? 1 : 0);
|
|
645
|
+
if (own(y) !== own(x)) return own(y) - own(x);
|
|
646
|
+
// Worst first: moving the ground while he is MID-SENTENCE beats everything.
|
|
647
|
+
const typing = (f: MotionFinding) => (f.whileTyping ? 1 : 0);
|
|
648
|
+
if (typing(y) !== typing(x)) return typing(y) - typing(x);
|
|
649
|
+
const back = (f: MotionFinding) => (f.delta < 0 ? 1 : 0);
|
|
650
|
+
if (back(y) !== back(x)) return back(y) - back(x);
|
|
651
|
+
return Math.abs(y.delta) - Math.abs(x.delta);
|
|
652
|
+
});
|
|
653
|
+
}
|