@reconcrap/boss-recommend-mcp 2.1.23 → 2.1.24

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.
Files changed (66) hide show
  1. package/README.md +5 -0
  2. package/bin/boss-recommend-mcp.js +4 -4
  3. package/package.json +6 -1
  4. package/scripts/install-macos.sh +280 -280
  5. package/scripts/postinstall.cjs +44 -44
  6. package/skills/boss-chat/README.md +43 -42
  7. package/skills/boss-chat/SKILL.md +107 -106
  8. package/skills/boss-recommend-pipeline/README.md +13 -13
  9. package/skills/boss-recommend-pipeline/SKILL.md +47 -47
  10. package/skills/boss-recruit-pipeline/README.md +19 -19
  11. package/skills/boss-recruit-pipeline/SKILL.md +89 -89
  12. package/src/chat-mcp.js +301 -127
  13. package/src/core/boss-cards/index.js +199 -199
  14. package/src/core/browser/index.js +286 -136
  15. package/src/core/capture/index.js +2930 -1201
  16. package/src/core/cv-acquisition/index.js +512 -238
  17. package/src/core/cv-capture-target/index.js +513 -299
  18. package/src/core/greet-quota/index.js +71 -71
  19. package/src/core/infinite-list/index.js +31 -31
  20. package/src/core/reporting/legacy-csv.js +12 -12
  21. package/src/core/run/detached-launcher.js +305 -0
  22. package/src/core/run/index.js +105 -52
  23. package/src/core/run/timing.js +33 -33
  24. package/src/core/run/windows-detached-worker.ps1 +106 -0
  25. package/src/core/screening/index.js +2135 -2135
  26. package/src/core/self-heal/index.js +989 -973
  27. package/src/core/self-heal/viewport.js +1505 -564
  28. package/src/detached-worker.js +99 -99
  29. package/src/domains/chat/action-journal.js +443 -0
  30. package/src/domains/chat/cards.js +137 -137
  31. package/src/domains/chat/constants.js +9 -9
  32. package/src/domains/chat/detail.js +1684 -401
  33. package/src/domains/chat/index.js +8 -7
  34. package/src/domains/chat/jobs.js +620 -620
  35. package/src/domains/chat/page-guard.js +157 -122
  36. package/src/domains/chat/roots.js +56 -56
  37. package/src/domains/chat/run-service.js +1801 -760
  38. package/src/domains/common/account-rights-panel.js +314 -314
  39. package/src/domains/common/recovery-settle.js +159 -159
  40. package/src/domains/recommend/actions.js +515 -472
  41. package/src/domains/recommend/cards.js +243 -243
  42. package/src/domains/recommend/colleague-contact.js +333 -333
  43. package/src/domains/recommend/constants.js +92 -92
  44. package/src/domains/recommend/detail.js +12 -12
  45. package/src/domains/recommend/filters.js +611 -611
  46. package/src/domains/recommend/index.js +9 -9
  47. package/src/domains/recommend/jobs.js +542 -542
  48. package/src/domains/recommend/location.js +736 -736
  49. package/src/domains/recommend/refresh.js +422 -422
  50. package/src/domains/recommend/roots.js +80 -80
  51. package/src/domains/recommend/run-service.js +1791 -1205
  52. package/src/domains/recommend/scopes.js +246 -246
  53. package/src/domains/recruit/actions.js +277 -277
  54. package/src/domains/recruit/cards.js +74 -74
  55. package/src/domains/recruit/constants.js +236 -236
  56. package/src/domains/recruit/detail.js +588 -588
  57. package/src/domains/recruit/index.js +9 -9
  58. package/src/domains/recruit/instruction-parser.js +866 -866
  59. package/src/domains/recruit/refresh.js +45 -45
  60. package/src/domains/recruit/roots.js +68 -68
  61. package/src/domains/recruit/run-service.js +1817 -1620
  62. package/src/domains/recruit/search.js +3229 -3229
  63. package/src/index.js +16 -1
  64. package/src/parser.js +1296 -1296
  65. package/src/recommend-mcp.js +551 -362
  66. package/src/recommend-scheduler.js +75 -75
@@ -1,564 +1,1505 @@
1
- import {
2
- getNodeBox,
3
- querySelector,
4
- sleep
5
- } from "../browser/index.js";
6
-
7
- export const VIEWPORT_COLLAPSE_RATIO_THRESHOLD = 0.6;
8
- export const VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH = 1000;
9
- export const VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO = 0.85;
10
-
11
- const ABSOLUTE_COLLAPSE_LIMITS = Object.freeze({
12
- clientHeight: 260,
13
- clientWidth: 280,
14
- frameHeight: 320,
15
- frameWidth: 460,
16
- viewportHeight: 260,
17
- viewportWidth: 360
18
- });
19
-
20
- function normalizeText(value) {
21
- return String(value ?? "").replace(/\s+/g, " ").trim();
22
- }
23
-
24
- function getPositiveNumber(...values) {
25
- for (const value of values) {
26
- const number = Number(value);
27
- if (Number.isFinite(number) && number > 0) return number;
28
- }
29
- return 0;
30
- }
31
-
32
- function rootNodeId(roots = {}, name) {
33
- const root = roots[name];
34
- if (typeof root === "number") return root;
35
- if (root?.nodeId) return root.nodeId;
36
- if (root?.documentNodeId) return root.documentNodeId;
37
- return 0;
38
- }
39
-
40
- function compactRect(rect = {}) {
41
- return {
42
- width: getPositiveNumber(rect.width),
43
- height: getPositiveNumber(rect.height)
44
- };
45
- }
46
-
47
- function pickViewportSize(layoutMetrics = {}, axis = "width") {
48
- const clientKey = axis === "width" ? "clientWidth" : "clientHeight";
49
- return getPositiveNumber(
50
- layoutMetrics?.cssVisualViewport?.[clientKey],
51
- layoutMetrics?.cssLayoutViewport?.[clientKey],
52
- layoutMetrics?.visualViewport?.[clientKey],
53
- layoutMetrics?.layoutViewport?.[clientKey]
54
- );
55
- }
56
-
57
- async function getLayoutMetrics(client) {
58
- if (typeof client?.Page?.getLayoutMetrics !== "function") return null;
59
- try {
60
- return await client.Page.getLayoutMetrics();
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- export async function getCurrentWindowInfo(client) {
67
- if (typeof client?.Browser?.getWindowForTarget !== "function") {
68
- return {
69
- ok: false,
70
- unsupported: true,
71
- error: "Browser.getWindowForTarget is not available"
72
- };
73
- }
74
-
75
- try {
76
- const targetWindow = await client.Browser.getWindowForTarget({});
77
- let bounds = targetWindow?.bounds || null;
78
- if (
79
- targetWindow?.windowId
80
- && typeof client?.Browser?.getWindowBounds === "function"
81
- ) {
82
- const currentBounds = await client.Browser.getWindowBounds({
83
- windowId: targetWindow.windowId
84
- });
85
- bounds = currentBounds?.bounds || bounds;
86
- }
87
- return {
88
- ok: true,
89
- windowId: targetWindow?.windowId || null,
90
- bounds
91
- };
92
- } catch (error) {
93
- return {
94
- ok: false,
95
- error: error?.message || String(error)
96
- };
97
- }
98
- }
99
-
100
- async function readBox(client, nodeId) {
101
- if (!nodeId) return null;
102
- try {
103
- return await getNodeBox(client, nodeId);
104
- } catch {
105
- return null;
106
- }
107
- }
108
-
109
- async function readBestContentBox(client, rootNodeIdValue) {
110
- const directBox = await readBox(client, rootNodeIdValue);
111
- if (directBox?.rect?.width && directBox?.rect?.height) return directBox;
112
-
113
- for (const selector of ["body", "html"]) {
114
- const nodeId = await querySelector(client, rootNodeIdValue, selector).catch(() => 0);
115
- const box = await readBox(client, nodeId);
116
- if (box?.rect?.width && box?.rect?.height) return box;
117
- }
118
- return directBox;
119
- }
120
-
121
- export function buildViewportHealthDiagnostics(state, windowInfo = null, layoutMetrics = null) {
122
- const topViewport = state?.topViewport || {};
123
- const bounds = windowInfo?.bounds || null;
124
- const windowState = normalizeText(bounds?.windowState || "").toLowerCase() || null;
125
- const windowWidth = getPositiveNumber(bounds?.width);
126
- const screenAvailWidth = getPositiveNumber(topViewport.screenAvailWidth);
127
- const topOuterWidth = getPositiveNumber(topViewport.outerWidth);
128
- const actualWidth = getPositiveNumber(
129
- layoutMetrics?.cssVisualViewport?.clientWidth,
130
- layoutMetrics?.cssLayoutViewport?.clientWidth,
131
- topViewport.visualWidth,
132
- topViewport.innerWidth,
133
- state?.viewport?.width,
134
- state?.clientWidth,
135
- state?.frameRect?.width
136
- );
137
- const actualHeight = getPositiveNumber(
138
- layoutMetrics?.cssVisualViewport?.clientHeight,
139
- layoutMetrics?.cssLayoutViewport?.clientHeight,
140
- topViewport.visualHeight,
141
- topViewport.innerHeight,
142
- state?.viewport?.height,
143
- state?.clientHeight,
144
- state?.frameRect?.height
145
- );
146
- const hasScreenWidth = screenAvailWidth > 0;
147
- const nearFullscreen = Boolean(
148
- windowState === "maximized"
149
- || (
150
- windowWidth > 0
151
- && hasScreenWidth
152
- && windowWidth >= screenAvailWidth * VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO
153
- )
154
- || (
155
- topOuterWidth > 0
156
- && hasScreenWidth
157
- && topOuterWidth >= screenAvailWidth * VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO
158
- )
159
- );
160
- const fallbackExpectedWidth = getPositiveNumber(screenAvailWidth, windowWidth, topOuterWidth);
161
- let expectedWidth = 0;
162
- if (windowWidth > 0) {
163
- expectedWidth = hasScreenWidth && windowWidth >= screenAvailWidth * VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO
164
- ? Math.min(windowWidth, screenAvailWidth)
165
- : windowWidth;
166
- } else if (topOuterWidth > 0) {
167
- expectedWidth = hasScreenWidth && topOuterWidth >= screenAvailWidth * VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO
168
- ? Math.min(topOuterWidth, screenAvailWidth)
169
- : topOuterWidth;
170
- } else {
171
- expectedWidth = fallbackExpectedWidth;
172
- }
173
- const widthRatio = actualWidth > 0 && expectedWidth > 0
174
- ? actualWidth / expectedWidth
175
- : null;
176
- const relativeCollapsed = Boolean(
177
- nearFullscreen
178
- && expectedWidth >= VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH
179
- && actualWidth > 0
180
- && widthRatio !== null
181
- && widthRatio <= VIEWPORT_COLLAPSE_RATIO_THRESHOLD
182
- );
183
-
184
- return {
185
- threshold: VIEWPORT_COLLAPSE_RATIO_THRESHOLD,
186
- minExpectedWidth: VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH,
187
- nearFullscreen,
188
- windowState,
189
- windowWidth,
190
- screenAvailWidth,
191
- topOuterWidth,
192
- actualWidth,
193
- actualHeight,
194
- expectedWidth,
195
- widthRatio,
196
- relativeCollapsed
197
- };
198
- }
199
-
200
- export function isListViewportCollapsed(state) {
201
- if (!state?.ok) return false;
202
- if (state.viewportDiagnostics?.relativeCollapsed === true) return true;
203
- const clientHeight = Number(state.clientHeight || 0);
204
- const clientWidth = Number(state.clientWidth || 0);
205
- const frameWidth = Number(state.frameRect?.width || 0);
206
- const frameHeight = Number(state.frameRect?.height || 0);
207
- const viewportWidth = Number(state.viewport?.width || 0);
208
- const viewportHeight = Number(state.viewport?.height || 0);
209
-
210
- return (
211
- (clientHeight > 0 && clientHeight < ABSOLUTE_COLLAPSE_LIMITS.clientHeight)
212
- || (clientWidth > 0 && clientWidth < ABSOLUTE_COLLAPSE_LIMITS.clientWidth)
213
- || (frameHeight > 0 && frameHeight < ABSOLUTE_COLLAPSE_LIMITS.frameHeight)
214
- || (frameWidth > 0 && frameWidth < ABSOLUTE_COLLAPSE_LIMITS.frameWidth)
215
- || (viewportHeight > 0 && viewportHeight < ABSOLUTE_COLLAPSE_LIMITS.viewportHeight)
216
- || (viewportWidth > 0 && viewportWidth < ABSOLUTE_COLLAPSE_LIMITS.viewportWidth)
217
- );
218
- }
219
-
220
- export async function readViewportState(client, {
221
- roots = {},
222
- root = "frame",
223
- frameOwnerRoot = "frameOwner"
224
- } = {}) {
225
- const targetRootNodeId = rootNodeId(roots, root);
226
- if (!targetRootNodeId) {
227
- return {
228
- ok: false,
229
- root,
230
- error: `Root not found: ${root}`
231
- };
232
- }
233
-
234
- const layoutMetrics = await getLayoutMetrics(client);
235
- const windowInfo = await getCurrentWindowInfo(client);
236
- const contentBox = await readBestContentBox(client, targetRootNodeId);
237
- const ownerNodeId = rootNodeId(roots, frameOwnerRoot);
238
- const ownerBox = ownerNodeId ? await readBox(client, ownerNodeId) : null;
239
- const frameRect = compactRect(ownerBox?.rect || contentBox?.rect || {});
240
- const clientWidth = getPositiveNumber(
241
- contentBox?.rect?.width,
242
- frameRect.width,
243
- pickViewportSize(layoutMetrics, "width")
244
- );
245
- const clientHeight = getPositiveNumber(
246
- contentBox?.rect?.height,
247
- frameRect.height,
248
- pickViewportSize(layoutMetrics, "height")
249
- );
250
- const viewportWidth = pickViewportSize(layoutMetrics, "width") || clientWidth;
251
- const viewportHeight = pickViewportSize(layoutMetrics, "height") || clientHeight;
252
- const bounds = windowInfo?.bounds || {};
253
- const topViewport = {
254
- innerWidth: viewportWidth,
255
- innerHeight: viewportHeight,
256
- outerWidth: getPositiveNumber(bounds.width, viewportWidth),
257
- outerHeight: getPositiveNumber(bounds.height, viewportHeight),
258
- visualWidth: getPositiveNumber(layoutMetrics?.cssVisualViewport?.clientWidth, viewportWidth),
259
- visualHeight: getPositiveNumber(layoutMetrics?.cssVisualViewport?.clientHeight, viewportHeight),
260
- screenAvailWidth: getPositiveNumber(bounds.width),
261
- screenAvailHeight: getPositiveNumber(bounds.height),
262
- devicePixelRatio: getPositiveNumber(layoutMetrics?.cssVisualViewport?.scale, 1)
263
- };
264
- const state = {
265
- ok: true,
266
- root,
267
- rootNodeId: targetRootNodeId,
268
- frameOwnerRoot,
269
- frameOwnerNodeId: ownerNodeId || null,
270
- clientWidth,
271
- clientHeight,
272
- frameRect,
273
- viewport: {
274
- width: viewportWidth,
275
- height: viewportHeight
276
- },
277
- topViewport,
278
- windowInfo
279
- };
280
- state.viewportDiagnostics = buildViewportHealthDiagnostics(state, windowInfo, layoutMetrics);
281
- state.collapsed = isListViewportCollapsed(state);
282
- return state;
283
- }
284
-
285
- export async function setWindowStateIfPossible(client, windowState, reason = "viewport_recovery") {
286
- const windowInfo = await getCurrentWindowInfo(client);
287
- if (!windowInfo.ok || !windowInfo.windowId || typeof client?.Browser?.setWindowBounds !== "function") {
288
- return {
289
- ok: false,
290
- reason,
291
- windowState,
292
- error: windowInfo.error || "Browser.setWindowBounds is not available"
293
- };
294
- }
295
-
296
- try {
297
- await client.Browser.setWindowBounds({
298
- windowId: windowInfo.windowId,
299
- bounds: {
300
- windowState
301
- }
302
- });
303
- return {
304
- ok: true,
305
- reason,
306
- windowState,
307
- windowId: windowInfo.windowId,
308
- before: windowInfo.bounds || null
309
- };
310
- } catch (error) {
311
- return {
312
- ok: false,
313
- reason,
314
- windowState,
315
- windowId: windowInfo.windowId,
316
- error: error?.message || String(error)
317
- };
318
- }
319
- }
320
-
321
- export async function toggleWindowStateForViewportRecovery(client, {
322
- reason = "viewport_recovery",
323
- settleMs = 520,
324
- bringToFront = true
325
- } = {}) {
326
- const currentInfo = await getCurrentWindowInfo(client);
327
- const currentState = normalizeText(currentInfo?.bounds?.windowState || "").toLowerCase();
328
- const sequence = currentState === "normal"
329
- ? ["maximized"]
330
- : ["normal", "maximized"];
331
- const attempts = [];
332
-
333
- for (const windowState of sequence) {
334
- const attempt = await setWindowStateIfPossible(client, windowState, reason);
335
- attempts.push(attempt);
336
- if (attempt.ok && settleMs > 0) await sleep(settleMs);
337
- }
338
-
339
- if (bringToFront && typeof client?.Page?.bringToFront === "function") {
340
- await client.Page.bringToFront();
341
- }
342
-
343
- return {
344
- ok: attempts.some((attempt) => attempt.ok),
345
- applied: attempts.some((attempt) => attempt.ok),
346
- reason,
347
- current_state: currentState || null,
348
- sequence,
349
- attempts
350
- };
351
- }
352
-
353
- export function compactViewportState(state = null) {
354
- if (!state) return null;
355
- return {
356
- ok: Boolean(state.ok),
357
- root: state.root || null,
358
- error: state.error || null,
359
- clientWidth: state.clientWidth || 0,
360
- clientHeight: state.clientHeight || 0,
361
- frameRect: state.frameRect || null,
362
- viewport: state.viewport || null,
363
- topViewport: state.topViewport
364
- ? {
365
- innerWidth: state.topViewport.innerWidth || 0,
366
- innerHeight: state.topViewport.innerHeight || 0,
367
- outerWidth: state.topViewport.outerWidth || 0,
368
- outerHeight: state.topViewport.outerHeight || 0,
369
- visualWidth: state.topViewport.visualWidth || 0,
370
- visualHeight: state.topViewport.visualHeight || 0,
371
- screenAvailWidth: state.topViewport.screenAvailWidth || 0,
372
- screenAvailHeight: state.topViewport.screenAvailHeight || 0,
373
- devicePixelRatio: state.topViewport.devicePixelRatio || 0
374
- }
375
- : null,
376
- viewportDiagnostics: state.viewportDiagnostics || null,
377
- collapsed: Boolean(state.collapsed)
378
- };
379
- }
380
-
381
- export function compactViewportHealthResult(result = null) {
382
- if (!result) return null;
383
- return {
384
- ok: Boolean(result.ok),
385
- collapsed: Boolean(result.collapsed),
386
- recovered: Boolean(result.recovered),
387
- reason: result.reason || null,
388
- state: compactViewportState(result.state),
389
- before: compactViewportState(result.before),
390
- repair: result.repair
391
- ? {
392
- ok: Boolean(result.repair.ok),
393
- applied: Boolean(result.repair.applied),
394
- current_state: result.repair.current_state || null,
395
- sequence: result.repair.sequence || [],
396
- attempts: (result.repair.attempts || []).map((attempt) => ({
397
- ok: Boolean(attempt.ok),
398
- windowState: attempt.windowState,
399
- error: attempt.error || null
400
- }))
401
- }
402
- : null,
403
- error: result.error || null
404
- };
405
- }
406
-
407
- export async function ensureHealthyViewport(client, {
408
- roots = {},
409
- root = "frame",
410
- frameOwnerRoot = "frameOwner",
411
- reason = "viewport_recovery",
412
- repair = true,
413
- recoveryDelayMs = 900
414
- } = {}) {
415
- const before = await readViewportState(client, {
416
- roots,
417
- root,
418
- frameOwnerRoot
419
- });
420
- if (!before.ok) {
421
- return {
422
- ok: false,
423
- collapsed: false,
424
- recovered: false,
425
- reason,
426
- state: before,
427
- error: before.error || "viewport state could not be read"
428
- };
429
- }
430
-
431
- if (!isListViewportCollapsed(before)) {
432
- return {
433
- ok: true,
434
- collapsed: false,
435
- recovered: false,
436
- reason,
437
- state: before
438
- };
439
- }
440
-
441
- if (!repair) {
442
- return {
443
- ok: false,
444
- collapsed: true,
445
- recovered: false,
446
- reason,
447
- before,
448
- state: before,
449
- error: "viewport collapsed and repair disabled"
450
- };
451
- }
452
-
453
- const repairResult = await toggleWindowStateForViewportRecovery(client, { reason });
454
- if (recoveryDelayMs > 0) await sleep(recoveryDelayMs);
455
- const after = await readViewportState(client, {
456
- roots,
457
- root,
458
- frameOwnerRoot
459
- });
460
- const stillCollapsed = isListViewportCollapsed(after);
461
- return {
462
- ok: after.ok && !stillCollapsed,
463
- collapsed: stillCollapsed,
464
- recovered: after.ok && !stillCollapsed && repairResult.applied,
465
- reason,
466
- before,
467
- state: after,
468
- repair: repairResult,
469
- error: after.ok && !stillCollapsed
470
- ? null
471
- : "viewport collapsed after recovery attempt"
472
- };
473
- }
474
-
475
- export function createViewportRunGuard({
476
- client,
477
- domain = "boss",
478
- root = "frame",
479
- frameOwnerRoot = "frameOwner",
480
- runControl = null,
481
- getRoots = null,
482
- rootNodesFromState = (rootState) => rootState?.rootNodes || rootState?.roots || rootState || {},
483
- repair = true,
484
- maxEvents = 10
485
- } = {}) {
486
- if (!client) throw new Error("createViewportRunGuard requires a guarded CDP client");
487
- const events = [];
488
- const stats = {
489
- checks: 0,
490
- recoveries: 0,
491
- failures: 0
492
- };
493
-
494
- function recordEvent(phase, health) {
495
- const compact = compactViewportHealthResult(health);
496
- const shouldRecord = Boolean(health?.recovered || !health?.ok || health?.collapsed);
497
- if (!shouldRecord) return compact;
498
- const event = {
499
- phase,
500
- at: new Date().toISOString(),
501
- ...compact
502
- };
503
- events.push(event);
504
- if (events.length > maxEvents) events.shift();
505
- if (runControl) {
506
- runControl.checkpoint({
507
- viewport_health: event,
508
- viewport_health_events: events.slice(),
509
- viewport_health_stats: { ...stats }
510
- });
511
- }
512
- return compact;
513
- }
514
-
515
- async function ensure(rootState, {
516
- phase = "run",
517
- reason = `${domain}:${phase}`
518
- } = {}) {
519
- let currentRootState = rootState;
520
- if (!currentRootState && typeof getRoots === "function") {
521
- currentRootState = await getRoots(client);
522
- }
523
- const roots = rootNodesFromState(currentRootState);
524
- stats.checks += 1;
525
- const health = await ensureHealthyViewport(client, {
526
- roots,
527
- root,
528
- frameOwnerRoot,
529
- reason,
530
- repair
531
- });
532
- if (health.recovered) stats.recoveries += 1;
533
- if (!health.ok) stats.failures += 1;
534
- const compact = recordEvent(phase, health);
535
- if (!health.ok) {
536
- const error = new Error(`${String(domain).toUpperCase()}_LIST_VIEWPORT_COLLAPSED`);
537
- error.code = "LIST_VIEWPORT_COLLAPSED";
538
- error.domain = domain;
539
- error.phase = phase;
540
- error.viewport_health = compact;
541
- throw error;
542
- }
543
- if (health.recovered && typeof getRoots === "function") {
544
- currentRootState = await getRoots(client);
545
- }
546
- return {
547
- rootState: currentRootState,
548
- health,
549
- compact,
550
- stats: { ...stats },
551
- events: events.slice()
552
- };
553
- }
554
-
555
- return {
556
- ensure,
557
- getStats() {
558
- return { ...stats };
559
- },
560
- getEvents() {
561
- return events.slice();
562
- }
563
- };
564
- }
1
+ import {
2
+ getNodeBox,
3
+ querySelector,
4
+ sleep
5
+ } from "../browser/index.js";
6
+
7
+ export const VIEWPORT_COLLAPSE_RATIO_THRESHOLD = 0.6;
8
+ export const VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH = 1000;
9
+ export const VIEWPORT_COLLAPSE_NEAR_FULLSCREEN_RATIO = 0.85;
10
+
11
+ // Desktop visual viewports are stable to roughly a pixel. A small absolute
12
+ // floor filters rounding noise while the ratio catches cumulative losses long
13
+ // before they grow into the old catastrophic-collapse thresholds.
14
+ const VIEWPORT_BASELINE_MIN_RATIO = 0.995;
15
+ const VIEWPORT_BASELINE_MIN_LOSS_PX = 4;
16
+ const WINDOW_BOUNDS_CHANGE_TOLERANCE_PX = 2;
17
+
18
+ const ABSOLUTE_COLLAPSE_LIMITS = Object.freeze({
19
+ clientHeight: 260,
20
+ clientWidth: 280,
21
+ frameHeight: 320,
22
+ frameWidth: 460,
23
+ viewportHeight: 260,
24
+ viewportWidth: 360
25
+ });
26
+
27
+ function normalizeText(value) {
28
+ return String(value ?? "").replace(/\s+/g, " ").trim();
29
+ }
30
+
31
+ function getPositiveNumber(...values) {
32
+ for (const value of values) {
33
+ const number = Number(value);
34
+ if (Number.isFinite(number) && number > 0) return number;
35
+ }
36
+ return 0;
37
+ }
38
+
39
+ function rootNodeId(roots = {}, name) {
40
+ for (const root of [roots?.[name], roots?.rootNodes?.[name], roots?.roots?.[name]]) {
41
+ if (typeof root === "number" && root > 0) return root;
42
+ if (root?.nodeId) return root.nodeId;
43
+ if (root?.documentNodeId) return root.documentNodeId;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ function getFiniteNumber(value) {
49
+ if (value === null || value === undefined || value === "") return null;
50
+ const number = Number(value);
51
+ return Number.isFinite(number) ? number : null;
52
+ }
53
+
54
+ function compactRect(rect = {}) {
55
+ return {
56
+ width: getPositiveNumber(rect.width),
57
+ height: getPositiveNumber(rect.height)
58
+ };
59
+ }
60
+
61
+ function pickViewportSize(layoutMetrics = {}, axis = "width") {
62
+ const clientKey = axis === "width" ? "clientWidth" : "clientHeight";
63
+ return getPositiveNumber(
64
+ layoutMetrics?.cssVisualViewport?.[clientKey],
65
+ layoutMetrics?.cssLayoutViewport?.[clientKey],
66
+ layoutMetrics?.visualViewport?.[clientKey],
67
+ layoutMetrics?.layoutViewport?.[clientKey]
68
+ );
69
+ }
70
+
71
+ const BASELINE_DIMENSIONS = Object.freeze([
72
+ ["clientWidth", (state) => state?.clientWidth],
73
+ ["clientHeight", (state) => state?.clientHeight],
74
+ ["frameWidth", (state) => state?.frameRect?.width],
75
+ ["frameHeight", (state) => state?.frameRect?.height],
76
+ ["viewportWidth", (state) => state?.viewport?.width],
77
+ ["viewportHeight", (state) => state?.viewport?.height]
78
+ ]);
79
+
80
+ function readDimensionSnapshot(state = {}) {
81
+ return Object.fromEntries(BASELINE_DIMENSIONS.map(([name, read]) => [
82
+ name,
83
+ getPositiveNumber(read(state))
84
+ ]));
85
+ }
86
+
87
+ function readWindowSnapshot(state = {}) {
88
+ const info = state?.windowInfo || {};
89
+ const bounds = info?.bounds || {};
90
+ return {
91
+ ok: Boolean(info.ok && info.windowId != null && getPositiveNumber(bounds.width) && getPositiveNumber(bounds.height)),
92
+ windowId: info.windowId ?? null,
93
+ left: getFiniteNumber(bounds.left),
94
+ top: getFiniteNumber(bounds.top),
95
+ width: getPositiveNumber(bounds.width),
96
+ height: getPositiveNumber(bounds.height),
97
+ windowState: normalizeText(bounds.windowState || "").toLowerCase() || null
98
+ };
99
+ }
100
+
101
+ function createViewportBaseline(state, {
102
+ reason = "initial_healthy_viewport",
103
+ stableSamples = 1,
104
+ establishedAt = new Date().toISOString()
105
+ } = {}) {
106
+ return {
107
+ version: 1,
108
+ establishedAt,
109
+ reason,
110
+ stableSamples: Math.max(1, Number(stableSamples) || 1),
111
+ dimensions: readDimensionSnapshot(state),
112
+ window: readWindowSnapshot(state)
113
+ };
114
+ }
115
+
116
+ function mergeStableBaselineSamples(firstState, secondState, options = {}) {
117
+ const baseline = createViewportBaseline(firstState, {
118
+ ...options,
119
+ stableSamples: 2
120
+ });
121
+ const second = readDimensionSnapshot(secondState);
122
+ for (const [name] of BASELINE_DIMENSIONS) {
123
+ baseline.dimensions[name] = Math.max(
124
+ getPositiveNumber(baseline.dimensions[name]),
125
+ getPositiveNumber(second[name])
126
+ );
127
+ }
128
+ baseline.window = readWindowSnapshot(secondState);
129
+ return baseline;
130
+ }
131
+
132
+ function compareViewportToBaseline(state, baseline) {
133
+ if (!baseline?.dimensions) {
134
+ return {
135
+ available: false,
136
+ drifted: false,
137
+ dimensions: {}
138
+ };
139
+ }
140
+
141
+ const current = readDimensionSnapshot(state);
142
+ const dimensions = {};
143
+ const driftedDimensions = [];
144
+ const missingDimensions = [];
145
+ for (const [name] of BASELINE_DIMENSIONS) {
146
+ const expected = getPositiveNumber(baseline.dimensions[name]);
147
+ const actual = getPositiveNumber(current[name]);
148
+ const missing = expected > 0 && actual <= 0;
149
+ const lossPx = expected > 0 && actual > 0
150
+ ? Math.max(0, expected - actual)
151
+ : expected > 0
152
+ ? expected
153
+ : 0;
154
+ const ratio = expected > 0 && actual > 0 ? actual / expected : null;
155
+ const drifted = Boolean(
156
+ missing
157
+ || (
158
+ lossPx > VIEWPORT_BASELINE_MIN_LOSS_PX
159
+ && ratio !== null
160
+ && ratio < VIEWPORT_BASELINE_MIN_RATIO
161
+ )
162
+ );
163
+ dimensions[name] = {
164
+ expected,
165
+ actual,
166
+ lossPx,
167
+ ratio,
168
+ missing,
169
+ drifted
170
+ };
171
+ if (missing) missingDimensions.push(name);
172
+ if (drifted) driftedDimensions.push(name);
173
+ }
174
+
175
+ return {
176
+ available: true,
177
+ thresholdRatio: VIEWPORT_BASELINE_MIN_RATIO,
178
+ minLossPx: VIEWPORT_BASELINE_MIN_LOSS_PX,
179
+ drifted: driftedDimensions.length > 0,
180
+ driftedDimensions,
181
+ missingDimensions,
182
+ dimensions
183
+ };
184
+ }
185
+
186
+ function compareWindowToBaseline(state, baseline) {
187
+ const expected = baseline?.window || null;
188
+ const actual = readWindowSnapshot(state);
189
+ if (!expected?.ok || !actual.ok) {
190
+ return {
191
+ verified: false,
192
+ changed: false,
193
+ reason: "window_bounds_unreadable",
194
+ expected,
195
+ actual
196
+ };
197
+ }
198
+ if (expected.windowId !== actual.windowId) {
199
+ return {
200
+ verified: false,
201
+ changed: false,
202
+ reason: "window_id_changed",
203
+ expected,
204
+ actual
205
+ };
206
+ }
207
+ const widthDelta = actual.width - expected.width;
208
+ const heightDelta = actual.height - expected.height;
209
+ const changed = Boolean(
210
+ Math.abs(widthDelta) > WINDOW_BOUNDS_CHANGE_TOLERANCE_PX
211
+ || Math.abs(heightDelta) > WINDOW_BOUNDS_CHANGE_TOLERANCE_PX
212
+ );
213
+ return {
214
+ verified: true,
215
+ changed,
216
+ reason: changed ? "verified_browser_bounds_change" : "browser_bounds_unchanged",
217
+ widthDelta,
218
+ heightDelta,
219
+ stateChanged: expected.windowState !== actual.windowState,
220
+ expected,
221
+ actual
222
+ };
223
+ }
224
+
225
+ async function getLayoutMetrics(client) {
226
+ if (typeof client?.Page?.getLayoutMetrics !== "function") return null;
227
+ try {
228
+ return await client.Page.getLayoutMetrics();
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ export async function getCurrentWindowInfo(client) {
235
+ if (typeof client?.Browser?.getWindowForTarget !== "function") {
236
+ return {
237
+ ok: false,
238
+ unsupported: true,
239
+ error: "Browser.getWindowForTarget is not available"
240
+ };
241
+ }
242
+
243
+ try {
244
+ const targetWindow = await client.Browser.getWindowForTarget({});
245
+ let bounds = targetWindow?.bounds || null;
246
+ if (
247
+ targetWindow?.windowId != null
248
+ && typeof client?.Browser?.getWindowBounds === "function"
249
+ ) {
250
+ const currentBounds = await client.Browser.getWindowBounds({
251
+ windowId: targetWindow.windowId
252
+ });
253
+ bounds = currentBounds?.bounds || bounds;
254
+ }
255
+ return {
256
+ ok: true,
257
+ windowId: targetWindow?.windowId ?? null,
258
+ bounds
259
+ };
260
+ } catch (error) {
261
+ return {
262
+ ok: false,
263
+ error: error?.message || String(error)
264
+ };
265
+ }
266
+ }
267
+
268
+ async function readBox(client, nodeId) {
269
+ if (!nodeId) return null;
270
+ try {
271
+ return await getNodeBox(client, nodeId);
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ async function readBestContentBox(client, rootNodeIdValue) {
278
+ const directBox = await readBox(client, rootNodeIdValue);
279
+ if (directBox?.rect?.width && directBox?.rect?.height) return directBox;
280
+
281
+ for (const selector of ["body", "html"]) {
282
+ const nodeId = await querySelector(client, rootNodeIdValue, selector).catch(() => 0);
283
+ const box = await readBox(client, nodeId);
284
+ if (box?.rect?.width && box?.rect?.height) return box;
285
+ }
286
+ return directBox;
287
+ }
288
+
289
+ export function buildViewportHealthDiagnostics(state, windowInfo = null, layoutMetrics = null) {
290
+ const topViewport = state?.topViewport || {};
291
+ const bounds = windowInfo?.bounds || null;
292
+ const windowState = normalizeText(bounds?.windowState || "").toLowerCase() || null;
293
+ const browserWindowWidth = getPositiveNumber(
294
+ bounds?.width,
295
+ topViewport.browserWindowWidth,
296
+ topViewport.outerWidth
297
+ );
298
+ const browserWindowHeight = getPositiveNumber(
299
+ bounds?.height,
300
+ topViewport.browserWindowHeight,
301
+ topViewport.outerHeight
302
+ );
303
+ const topOuterWidth = getPositiveNumber(topViewport.outerWidth);
304
+ const actualWidth = getPositiveNumber(
305
+ layoutMetrics?.cssVisualViewport?.clientWidth,
306
+ layoutMetrics?.cssLayoutViewport?.clientWidth,
307
+ topViewport.visualWidth,
308
+ topViewport.innerWidth,
309
+ state?.viewport?.width,
310
+ state?.clientWidth,
311
+ state?.frameRect?.width
312
+ );
313
+ const actualHeight = getPositiveNumber(
314
+ layoutMetrics?.cssVisualViewport?.clientHeight,
315
+ layoutMetrics?.cssLayoutViewport?.clientHeight,
316
+ topViewport.visualHeight,
317
+ topViewport.innerHeight,
318
+ state?.viewport?.height,
319
+ state?.clientHeight,
320
+ state?.frameRect?.height
321
+ );
322
+ const nearFullscreen = Boolean(
323
+ windowState === "maximized"
324
+ || windowState === "fullscreen"
325
+ );
326
+ const expectedWidth = getPositiveNumber(browserWindowWidth, topOuterWidth);
327
+ const expectedHeight = getPositiveNumber(browserWindowHeight, topViewport.outerHeight);
328
+ const widthRatio = actualWidth > 0 && expectedWidth > 0
329
+ ? actualWidth / expectedWidth
330
+ : null;
331
+ const heightRatio = actualHeight > 0 && expectedHeight > 0
332
+ ? actualHeight / expectedHeight
333
+ : null;
334
+ const relativeWidthCollapsed = Boolean(
335
+ expectedWidth >= VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH
336
+ && actualWidth > 0
337
+ && widthRatio !== null
338
+ && widthRatio <= VIEWPORT_COLLAPSE_RATIO_THRESHOLD
339
+ );
340
+ const relativeCollapsed = relativeWidthCollapsed;
341
+
342
+ return {
343
+ threshold: VIEWPORT_COLLAPSE_RATIO_THRESHOLD,
344
+ minExpectedWidth: VIEWPORT_COLLAPSE_MIN_EXPECTED_WIDTH,
345
+ nearFullscreen,
346
+ windowState,
347
+ browserWindowWidth,
348
+ browserWindowHeight,
349
+ topOuterWidth,
350
+ actualWidth,
351
+ actualHeight,
352
+ expectedWidth,
353
+ expectedHeight,
354
+ widthRatio,
355
+ heightRatio,
356
+ relativeWidthCollapsed,
357
+ relativeCollapsed
358
+ };
359
+ }
360
+
361
+ export function isListViewportCollapsed(state) {
362
+ if (!state?.ok) return false;
363
+ if (state.viewportDiagnostics?.relativeCollapsed === true) return true;
364
+ const clientHeight = Number(state.clientHeight || 0);
365
+ const clientWidth = Number(state.clientWidth || 0);
366
+ const frameWidth = Number(state.frameRect?.width || 0);
367
+ const frameHeight = Number(state.frameRect?.height || 0);
368
+ const viewportWidth = Number(state.viewport?.width || 0);
369
+ const viewportHeight = Number(state.viewport?.height || 0);
370
+
371
+ return (
372
+ (clientHeight > 0 && clientHeight < ABSOLUTE_COLLAPSE_LIMITS.clientHeight)
373
+ || (clientWidth > 0 && clientWidth < ABSOLUTE_COLLAPSE_LIMITS.clientWidth)
374
+ || (frameHeight > 0 && frameHeight < ABSOLUTE_COLLAPSE_LIMITS.frameHeight)
375
+ || (frameWidth > 0 && frameWidth < ABSOLUTE_COLLAPSE_LIMITS.frameWidth)
376
+ || (viewportHeight > 0 && viewportHeight < ABSOLUTE_COLLAPSE_LIMITS.viewportHeight)
377
+ || (viewportWidth > 0 && viewportWidth < ABSOLUTE_COLLAPSE_LIMITS.viewportWidth)
378
+ );
379
+ }
380
+
381
+ export async function readViewportState(client, {
382
+ roots = {},
383
+ root = "frame",
384
+ frameOwnerRoot = "frameOwner"
385
+ } = {}) {
386
+ const targetRootNodeId = rootNodeId(roots, root);
387
+ if (!targetRootNodeId) {
388
+ return {
389
+ ok: false,
390
+ root,
391
+ error: `Root not found: ${root}`
392
+ };
393
+ }
394
+
395
+ const layoutMetrics = await getLayoutMetrics(client);
396
+ const windowInfo = await getCurrentWindowInfo(client);
397
+ const contentBox = await readBestContentBox(client, targetRootNodeId);
398
+ const ownerNodeId = rootNodeId(roots, frameOwnerRoot);
399
+ const ownerBox = ownerNodeId ? await readBox(client, ownerNodeId) : null;
400
+ const contentRectReadable = Boolean(
401
+ getPositiveNumber(contentBox?.rect?.width)
402
+ && getPositiveNumber(contentBox?.rect?.height)
403
+ );
404
+ const ownerRectReadable = Boolean(
405
+ !ownerNodeId
406
+ || (
407
+ getPositiveNumber(ownerBox?.rect?.width)
408
+ && getPositiveNumber(ownerBox?.rect?.height)
409
+ )
410
+ );
411
+ const measurementEvidence = {
412
+ targetRootNodeId,
413
+ contentRectReadable,
414
+ frameOwnerNodeId: ownerNodeId || null,
415
+ ownerRectReadable,
416
+ layoutMetricsReadable: Boolean(layoutMetrics),
417
+ browserWindowReadable: Boolean(windowInfo?.ok)
418
+ };
419
+ if (!contentRectReadable || !ownerRectReadable) {
420
+ return {
421
+ ok: false,
422
+ root,
423
+ rootNodeId: targetRootNodeId,
424
+ frameOwnerRoot,
425
+ frameOwnerNodeId: ownerNodeId || null,
426
+ windowInfo,
427
+ measurementEvidence,
428
+ error: !contentRectReadable
429
+ ? `Viewport root geometry is unreadable: ${root}`
430
+ : `Viewport frame-owner geometry is unreadable: ${frameOwnerRoot}`
431
+ };
432
+ }
433
+ const frameRect = compactRect(ownerBox?.rect || contentBox?.rect || {});
434
+ const clientWidth = getPositiveNumber(
435
+ contentBox?.rect?.width,
436
+ frameRect.width,
437
+ pickViewportSize(layoutMetrics, "width")
438
+ );
439
+ const clientHeight = getPositiveNumber(
440
+ contentBox?.rect?.height,
441
+ frameRect.height,
442
+ pickViewportSize(layoutMetrics, "height")
443
+ );
444
+ const viewportWidth = pickViewportSize(layoutMetrics, "width") || clientWidth;
445
+ const viewportHeight = pickViewportSize(layoutMetrics, "height") || clientHeight;
446
+ const bounds = windowInfo?.bounds || {};
447
+ const topViewport = {
448
+ innerWidth: viewportWidth,
449
+ innerHeight: viewportHeight,
450
+ outerWidth: getPositiveNumber(bounds.width, viewportWidth),
451
+ outerHeight: getPositiveNumber(bounds.height, viewportHeight),
452
+ visualWidth: getPositiveNumber(layoutMetrics?.cssVisualViewport?.clientWidth, viewportWidth),
453
+ visualHeight: getPositiveNumber(layoutMetrics?.cssVisualViewport?.clientHeight, viewportHeight),
454
+ browserWindowWidth: getPositiveNumber(bounds.width),
455
+ browserWindowHeight: getPositiveNumber(bounds.height),
456
+ visualViewportScale: getPositiveNumber(layoutMetrics?.cssVisualViewport?.scale, 1)
457
+ };
458
+ const state = {
459
+ ok: true,
460
+ root,
461
+ rootNodeId: targetRootNodeId,
462
+ frameOwnerRoot,
463
+ frameOwnerNodeId: ownerNodeId || null,
464
+ clientWidth,
465
+ clientHeight,
466
+ frameRect,
467
+ viewport: {
468
+ width: viewportWidth,
469
+ height: viewportHeight
470
+ },
471
+ topViewport,
472
+ windowInfo,
473
+ measurementEvidence
474
+ };
475
+ state.viewportDiagnostics = buildViewportHealthDiagnostics(state, windowInfo, layoutMetrics);
476
+ state.collapsed = isListViewportCollapsed(state);
477
+ return state;
478
+ }
479
+
480
+ export async function setWindowStateIfPossible(client, windowState, reason = "viewport_recovery") {
481
+ const windowInfo = await getCurrentWindowInfo(client);
482
+ if (!windowInfo.ok || windowInfo.windowId == null || typeof client?.Browser?.setWindowBounds !== "function") {
483
+ return {
484
+ ok: false,
485
+ reason,
486
+ windowState,
487
+ error: windowInfo.error || "Browser.setWindowBounds is not available"
488
+ };
489
+ }
490
+
491
+ try {
492
+ await client.Browser.setWindowBounds({
493
+ windowId: windowInfo.windowId,
494
+ bounds: {
495
+ windowState
496
+ }
497
+ });
498
+ return {
499
+ ok: true,
500
+ reason,
501
+ windowState,
502
+ windowId: windowInfo.windowId,
503
+ before: windowInfo.bounds || null
504
+ };
505
+ } catch (error) {
506
+ return {
507
+ ok: false,
508
+ reason,
509
+ windowState,
510
+ windowId: windowInfo.windowId,
511
+ error: error?.message || String(error)
512
+ };
513
+ }
514
+ }
515
+
516
+ function captureRestorableWindowSnapshot(windowInfo = {}) {
517
+ const bounds = windowInfo?.bounds || {};
518
+ const windowState = normalizeText(bounds.windowState || "").toLowerCase();
519
+ const width = getPositiveNumber(bounds.width);
520
+ const height = getPositiveNumber(bounds.height);
521
+ const restorableState = ["normal", "maximized", "fullscreen"].includes(windowState);
522
+ return {
523
+ ok: Boolean(
524
+ windowInfo?.ok
525
+ && windowInfo.windowId != null
526
+ && restorableState
527
+ && width
528
+ && height
529
+ ),
530
+ windowId: windowInfo?.windowId ?? null,
531
+ windowState: restorableState ? windowState : null,
532
+ left: getFiniteNumber(bounds.left),
533
+ top: getFiniteNumber(bounds.top),
534
+ width,
535
+ height
536
+ };
537
+ }
538
+
539
+ function buildOriginalWindowRestoreBounds(snapshot = {}) {
540
+ const bounds = {
541
+ windowState: snapshot.windowState
542
+ };
543
+ if (snapshot.windowState === "normal") {
544
+ if (snapshot.left !== null) bounds.left = snapshot.left;
545
+ if (snapshot.top !== null) bounds.top = snapshot.top;
546
+ bounds.width = snapshot.width;
547
+ bounds.height = snapshot.height;
548
+ }
549
+ return bounds;
550
+ }
551
+
552
+ function verifyOriginalWindowRestoration(original, currentInfo) {
553
+ const actual = captureRestorableWindowSnapshot(currentInfo);
554
+ if (!original?.ok || !actual.ok) {
555
+ return {
556
+ verified: false,
557
+ reason: "window_restoration_readback_unavailable",
558
+ expected: original || null,
559
+ actual
560
+ };
561
+ }
562
+ if (actual.windowId !== original.windowId) {
563
+ return {
564
+ verified: false,
565
+ reason: "window_restoration_target_changed",
566
+ expected: original,
567
+ actual
568
+ };
569
+ }
570
+
571
+ const deltas = {
572
+ left: original.left !== null && actual.left !== null ? actual.left - original.left : null,
573
+ top: original.top !== null && actual.top !== null ? actual.top - original.top : null,
574
+ width: actual.width - original.width,
575
+ height: actual.height - original.height
576
+ };
577
+ const stateMatches = actual.windowState === original.windowState;
578
+ const positionMatches = Boolean(
579
+ (
580
+ original.left === null
581
+ || (actual.left !== null && Math.abs(deltas.left) <= WINDOW_BOUNDS_CHANGE_TOLERANCE_PX)
582
+ )
583
+ && (
584
+ original.top === null
585
+ || (actual.top !== null && Math.abs(deltas.top) <= WINDOW_BOUNDS_CHANGE_TOLERANCE_PX)
586
+ )
587
+ );
588
+ const sizeMatches = Boolean(
589
+ Math.abs(deltas.width) <= WINDOW_BOUNDS_CHANGE_TOLERANCE_PX
590
+ && Math.abs(deltas.height) <= WINDOW_BOUNDS_CHANGE_TOLERANCE_PX
591
+ );
592
+ const verified = stateMatches && positionMatches && sizeMatches;
593
+ return {
594
+ verified,
595
+ reason: verified
596
+ ? "original_window_state_and_bounds_verified"
597
+ : !stateMatches
598
+ ? "original_window_state_not_restored"
599
+ : !positionMatches
600
+ ? "original_window_position_not_restored"
601
+ : "original_window_size_not_restored",
602
+ stateMatches,
603
+ positionMatches,
604
+ sizeMatches,
605
+ tolerancePx: WINDOW_BOUNDS_CHANGE_TOLERANCE_PX,
606
+ deltas,
607
+ expected: original,
608
+ actual
609
+ };
610
+ }
611
+
612
+ async function setExactWindowBounds(client, {
613
+ windowId,
614
+ bounds,
615
+ reason,
616
+ step
617
+ }) {
618
+ if (windowId == null || typeof client?.Browser?.setWindowBounds !== "function") {
619
+ return {
620
+ ok: false,
621
+ reason,
622
+ step,
623
+ windowId: windowId ?? null,
624
+ bounds,
625
+ windowState: bounds?.windowState || null,
626
+ error: "Browser.setWindowBounds is not available"
627
+ };
628
+ }
629
+ try {
630
+ await client.Browser.setWindowBounds({ windowId, bounds });
631
+ return {
632
+ ok: true,
633
+ reason,
634
+ step,
635
+ windowId,
636
+ bounds,
637
+ windowState: bounds?.windowState || null
638
+ };
639
+ } catch (error) {
640
+ return {
641
+ ok: false,
642
+ reason,
643
+ step,
644
+ windowId,
645
+ bounds,
646
+ windowState: bounds?.windowState || null,
647
+ error: error?.message || String(error)
648
+ };
649
+ }
650
+ }
651
+
652
+ export async function toggleWindowStateForViewportRecovery(client, {
653
+ reason = "viewport_recovery",
654
+ settleMs = 520,
655
+ bringToFront = true
656
+ } = {}) {
657
+ const currentInfo = await getCurrentWindowInfo(client);
658
+ const original = captureRestorableWindowSnapshot(currentInfo);
659
+ const currentState = original.windowState;
660
+ const sequence = currentState === "normal"
661
+ ? ["maximized", "normal"]
662
+ : ["normal", currentState].filter(Boolean);
663
+ const attempts = [];
664
+
665
+ if (original.ok) {
666
+ const perturb = await setExactWindowBounds(client, {
667
+ windowId: original.windowId,
668
+ bounds: { windowState: sequence[0] },
669
+ reason,
670
+ step: "perturb_window_state"
671
+ });
672
+ attempts.push(perturb);
673
+ if (perturb.ok && settleMs > 0) await sleep(settleMs);
674
+
675
+ const restore = await setExactWindowBounds(client, {
676
+ windowId: original.windowId,
677
+ bounds: buildOriginalWindowRestoreBounds(original),
678
+ reason,
679
+ step: "restore_original_window"
680
+ });
681
+ attempts.push(restore);
682
+ if (restore.ok && settleMs > 0) await sleep(settleMs);
683
+ }
684
+
685
+ if (bringToFront && typeof client?.Page?.bringToFront === "function") {
686
+ await client.Page.bringToFront();
687
+ }
688
+
689
+ const restoredInfo = original.ok ? await getCurrentWindowInfo(client) : null;
690
+ const restoration = verifyOriginalWindowRestoration(original, restoredInfo);
691
+ const applied = attempts.some((attempt) => attempt.ok);
692
+
693
+ return {
694
+ ok: Boolean(applied && restoration.verified),
695
+ applied,
696
+ reason,
697
+ current_state: currentState || null,
698
+ restored_state: sequence[sequence.length - 1] || null,
699
+ original_window: original,
700
+ restored_window: captureRestorableWindowSnapshot(restoredInfo || {}),
701
+ original_state_restored: Boolean(restoration.verified),
702
+ restoration,
703
+ sequence,
704
+ attempts,
705
+ error: original.ok
706
+ ? restoration.verified
707
+ ? null
708
+ : restoration.reason
709
+ : "original window state and bounds are unreadable"
710
+ };
711
+ }
712
+
713
+ export function compactViewportState(state = null) {
714
+ if (!state) return null;
715
+ return {
716
+ ok: Boolean(state.ok),
717
+ root: state.root || null,
718
+ error: state.error || null,
719
+ clientWidth: state.clientWidth || 0,
720
+ clientHeight: state.clientHeight || 0,
721
+ frameRect: state.frameRect || null,
722
+ viewport: state.viewport || null,
723
+ topViewport: state.topViewport
724
+ ? {
725
+ innerWidth: state.topViewport.innerWidth || 0,
726
+ innerHeight: state.topViewport.innerHeight || 0,
727
+ outerWidth: state.topViewport.outerWidth || 0,
728
+ outerHeight: state.topViewport.outerHeight || 0,
729
+ visualWidth: state.topViewport.visualWidth || 0,
730
+ visualHeight: state.topViewport.visualHeight || 0,
731
+ browserWindowWidth: state.topViewport.browserWindowWidth || 0,
732
+ browserWindowHeight: state.topViewport.browserWindowHeight || 0,
733
+ visualViewportScale: state.topViewport.visualViewportScale || 0
734
+ }
735
+ : null,
736
+ measurementEvidence: state.measurementEvidence || null,
737
+ viewportDiagnostics: state.viewportDiagnostics || null,
738
+ baselineComparison: state.baselineComparison || null,
739
+ collapseEvidence: state.collapseEvidence || null,
740
+ collapsed: Boolean(state.collapsed)
741
+ };
742
+ }
743
+
744
+ function compactViewportBaseline(baseline = null) {
745
+ if (!baseline) return null;
746
+ return {
747
+ version: baseline.version || 1,
748
+ establishedAt: baseline.establishedAt || null,
749
+ reason: baseline.reason || null,
750
+ stableSamples: Number(baseline.stableSamples || 0),
751
+ dimensions: baseline.dimensions || null,
752
+ window: baseline.window || null
753
+ };
754
+ }
755
+
756
+ export function compactViewportHealthResult(result = null) {
757
+ if (!result) return null;
758
+ return {
759
+ ok: Boolean(result.ok),
760
+ collapsed: Boolean(result.collapsed),
761
+ recovered: Boolean(result.recovered),
762
+ recoveryMode: result.recoveryMode || null,
763
+ reason: result.reason || null,
764
+ baselineEstablished: Boolean(result.baselineEstablished),
765
+ rebaselined: Boolean(result.rebaselined),
766
+ baseline: compactViewportBaseline(result.baseline),
767
+ baselineComparison: result.baselineComparison || null,
768
+ windowBoundsChange: result.windowBoundsChange || null,
769
+ stability: result.stability || null,
770
+ state: compactViewportState(result.state),
771
+ before: compactViewportState(result.before),
772
+ repair: result.repair
773
+ ? {
774
+ ok: Boolean(result.repair.ok),
775
+ applied: Boolean(result.repair.applied),
776
+ current_state: result.repair.current_state || null,
777
+ restored_state: result.repair.restored_state || null,
778
+ original_state_restored: Boolean(result.repair.original_state_restored),
779
+ original_window: result.repair.original_window || null,
780
+ restored_window: result.repair.restored_window || null,
781
+ restoration: result.repair.restoration || null,
782
+ sequence: result.repair.sequence || [],
783
+ attempts: (result.repair.attempts || []).map((attempt) => ({
784
+ ok: Boolean(attempt.ok),
785
+ step: attempt.step || null,
786
+ windowId: attempt.windowId ?? null,
787
+ windowState: attempt.windowState,
788
+ bounds: attempt.bounds || null,
789
+ error: attempt.error || null
790
+ })),
791
+ error: result.repair.error || null
792
+ }
793
+ : null,
794
+ rootReacquisition: result.rootReacquisition || null,
795
+ error: result.error || null
796
+ };
797
+ }
798
+
799
+ function annotateViewportState(state, baseline = null) {
800
+ if (!state?.ok) {
801
+ if (state) {
802
+ state.collapseEvidence = {
803
+ unreadable: true,
804
+ catastrophic: false,
805
+ baselineDrift: false
806
+ };
807
+ }
808
+ return state;
809
+ }
810
+ const catastrophic = isListViewportCollapsed(state);
811
+ const baselineComparison = compareViewportToBaseline(state, baseline);
812
+ const baselineDrift = Boolean(baselineComparison.drifted);
813
+ state.baselineComparison = baselineComparison;
814
+ state.collapseEvidence = {
815
+ unreadable: false,
816
+ catastrophic,
817
+ baselineDrift,
818
+ driftedDimensions: baselineComparison.driftedDimensions || []
819
+ };
820
+ state.collapsed = catastrophic || baselineDrift;
821
+ return state;
822
+ }
823
+
824
+ function buildStabilityEvidence(firstState, secondState) {
825
+ const reference = createViewportBaseline(firstState, {
826
+ reason: "stability_reference"
827
+ });
828
+ const comparison = compareViewportToBaseline(secondState, reference);
829
+ const windowBounds = compareWindowToBaseline(secondState, reference);
830
+ return {
831
+ required: true,
832
+ verified: Boolean(
833
+ secondState?.ok
834
+ && !comparison.drifted
835
+ && windowBounds.verified
836
+ && !windowBounds.changed
837
+ ),
838
+ sampleCount: 2,
839
+ comparison,
840
+ windowBounds
841
+ };
842
+ }
843
+
844
+ async function reacquireViewportRoots(reacquireRoots, {
845
+ phase,
846
+ root,
847
+ frameOwnerRoot
848
+ }) {
849
+ if (typeof reacquireRoots !== "function") {
850
+ return {
851
+ ok: false,
852
+ phase,
853
+ error: "viewport root reacquisition is unavailable"
854
+ };
855
+ }
856
+ try {
857
+ const roots = await reacquireRoots({ phase, root, frameOwnerRoot });
858
+ const targetRootNodeId = rootNodeId(roots, root);
859
+ if (!targetRootNodeId) {
860
+ return {
861
+ ok: false,
862
+ phase,
863
+ error: `Reacquired viewport root was not found: ${root}`
864
+ };
865
+ }
866
+ return {
867
+ ok: true,
868
+ phase,
869
+ roots,
870
+ targetRootNodeId,
871
+ frameOwnerNodeId: rootNodeId(roots, frameOwnerRoot) || null
872
+ };
873
+ } catch (error) {
874
+ return {
875
+ ok: false,
876
+ phase,
877
+ error: error?.message || String(error)
878
+ };
879
+ }
880
+ }
881
+
882
+ function compactRootReacquisition(samples = []) {
883
+ return {
884
+ required: true,
885
+ verified: samples.length === 2 && samples.every((sample) => sample?.ok),
886
+ samples: samples.map((sample) => ({
887
+ ok: Boolean(sample?.ok),
888
+ phase: sample?.phase || null,
889
+ targetRootNodeId: sample?.targetRootNodeId || null,
890
+ frameOwnerNodeId: sample?.frameOwnerNodeId || null,
891
+ error: sample?.error || null
892
+ }))
893
+ };
894
+ }
895
+
896
+ export async function ensureHealthyViewport(client, {
897
+ roots = {},
898
+ root = "frame",
899
+ frameOwnerRoot = "frameOwner",
900
+ reason = "viewport_recovery",
901
+ repair = true,
902
+ recoveryDelayMs = 900,
903
+ recoverySettleMs = 520,
904
+ baseline = null,
905
+ allowVerifiedWindowRebaseline = true,
906
+ reacquireRoots = null
907
+ } = {}) {
908
+ let before = await readViewportState(client, {
909
+ roots,
910
+ root,
911
+ frameOwnerRoot
912
+ });
913
+ if (!before.ok) {
914
+ annotateViewportState(before, baseline);
915
+ const unreadableBefore = before;
916
+ const rootSamples = [];
917
+ const firstRoots = await reacquireViewportRoots(reacquireRoots, {
918
+ phase: "unreadable_root_sample_1",
919
+ root,
920
+ frameOwnerRoot
921
+ });
922
+ rootSamples.push(firstRoots);
923
+ if (!firstRoots.ok) {
924
+ return {
925
+ ok: false,
926
+ collapsed: false,
927
+ recovered: false,
928
+ reason,
929
+ before: unreadableBefore,
930
+ state: unreadableBefore,
931
+ rootReacquisition: compactRootReacquisition(rootSamples),
932
+ baseline,
933
+ baselineEstablished: false,
934
+ rebaselined: false,
935
+ error: unreadableBefore.error || firstRoots.error || "viewport state could not be read"
936
+ };
937
+ }
938
+
939
+ const firstFreshState = await readViewportState(client, {
940
+ roots: firstRoots.roots,
941
+ root,
942
+ frameOwnerRoot
943
+ });
944
+ annotateViewportState(firstFreshState, baseline);
945
+ if (!firstFreshState.ok) {
946
+ return {
947
+ ok: false,
948
+ collapsed: false,
949
+ recovered: false,
950
+ reason,
951
+ before: unreadableBefore,
952
+ state: firstFreshState,
953
+ rootReacquisition: compactRootReacquisition(rootSamples),
954
+ baseline,
955
+ baselineEstablished: false,
956
+ rebaselined: false,
957
+ baselineComparison: firstFreshState.baselineComparison || null,
958
+ error: firstFreshState.error || "reacquired viewport state could not be read"
959
+ };
960
+ }
961
+ if (firstFreshState.collapsed) {
962
+ // The stale handle was replaced successfully and the fresh geometry now
963
+ // proves a real collapse. Continue through the normal window-repair path
964
+ // with that fresh root instead of misclassifying it as unreadable.
965
+ before = firstFreshState;
966
+ roots = firstRoots.roots;
967
+ } else {
968
+ const secondRoots = await reacquireViewportRoots(reacquireRoots, {
969
+ phase: "unreadable_root_sample_2",
970
+ root,
971
+ frameOwnerRoot
972
+ });
973
+ rootSamples.push(secondRoots);
974
+ if (!secondRoots.ok) {
975
+ return {
976
+ ok: false,
977
+ collapsed: false,
978
+ recovered: false,
979
+ reason,
980
+ before: unreadableBefore,
981
+ state: firstFreshState,
982
+ rootReacquisition: compactRootReacquisition(rootSamples),
983
+ baseline,
984
+ baselineEstablished: false,
985
+ rebaselined: false,
986
+ baselineComparison: firstFreshState.baselineComparison || null,
987
+ error: secondRoots.error || "viewport roots could not be reacquired for stability verification"
988
+ };
989
+ }
990
+
991
+ const secondFreshState = await readViewportState(client, {
992
+ roots: secondRoots.roots,
993
+ root,
994
+ frameOwnerRoot
995
+ });
996
+ annotateViewportState(secondFreshState, baseline);
997
+ const stability = secondFreshState.ok
998
+ ? buildStabilityEvidence(firstFreshState, secondFreshState)
999
+ : {
1000
+ required: true,
1001
+ verified: false,
1002
+ sampleCount: 2,
1003
+ error: secondFreshState.error || "reacquired viewport stability sample could not be read"
1004
+ };
1005
+ const rootsRecovered = Boolean(
1006
+ secondFreshState.ok
1007
+ && !secondFreshState.collapsed
1008
+ && stability.verified
1009
+ && rootSamples.every((sample) => sample.ok)
1010
+ );
1011
+ const nextBaseline = rootsRecovered
1012
+ ? baseline || mergeStableBaselineSamples(firstFreshState, secondFreshState, {
1013
+ reason: "reacquired_healthy_viewport"
1014
+ })
1015
+ : baseline;
1016
+ return {
1017
+ ok: rootsRecovered,
1018
+ collapsed: Boolean(secondFreshState.collapsed),
1019
+ recovered: rootsRecovered,
1020
+ recoveryMode: rootsRecovered ? "root_reacquisition" : null,
1021
+ reason,
1022
+ before: unreadableBefore,
1023
+ state: secondFreshState,
1024
+ rootReacquisition: compactRootReacquisition(rootSamples),
1025
+ baseline: nextBaseline,
1026
+ baselineEstablished: Boolean(rootsRecovered && !baseline),
1027
+ rebaselined: false,
1028
+ windowBoundsChange: compareWindowToBaseline(secondFreshState, baseline),
1029
+ baselineComparison: secondFreshState.baselineComparison || null,
1030
+ stability,
1031
+ error: rootsRecovered
1032
+ ? null
1033
+ : secondFreshState.ok && !secondFreshState.collapsed
1034
+ ? "reacquired viewport roots did not stabilize across two healthy readings"
1035
+ : secondFreshState.error || "reacquired viewport state is unsafe"
1036
+ };
1037
+ }
1038
+ }
1039
+
1040
+ let effectiveBaseline = baseline;
1041
+ const windowBoundsChange = compareWindowToBaseline(before, baseline);
1042
+ const windowResizeCandidate = Boolean(
1043
+ baseline
1044
+ && allowVerifiedWindowRebaseline
1045
+ && windowBoundsChange.verified
1046
+ && windowBoundsChange.changed
1047
+ );
1048
+ if (windowResizeCandidate) {
1049
+ const candidateBaseline = createViewportBaseline(before, {
1050
+ reason: "verified_browser_bounds_change"
1051
+ });
1052
+ annotateViewportState(before, candidateBaseline);
1053
+ const confirmation = await readViewportState(client, {
1054
+ roots,
1055
+ root,
1056
+ frameOwnerRoot
1057
+ });
1058
+ if (!confirmation.ok) {
1059
+ annotateViewportState(confirmation, candidateBaseline);
1060
+ return {
1061
+ ok: false,
1062
+ collapsed: false,
1063
+ recovered: false,
1064
+ reason,
1065
+ before,
1066
+ state: confirmation,
1067
+ baseline,
1068
+ baselineEstablished: false,
1069
+ rebaselined: false,
1070
+ windowBoundsChange,
1071
+ stability: {
1072
+ required: true,
1073
+ verified: false,
1074
+ sampleCount: 2,
1075
+ error: confirmation.error || "viewport stability sample could not be read"
1076
+ },
1077
+ error: confirmation.error || "external browser resize verification could not be read"
1078
+ };
1079
+ }
1080
+ const stability = buildStabilityEvidence(before, confirmation);
1081
+ annotateViewportState(confirmation, candidateBaseline);
1082
+ if (!before.collapsed && !confirmation.collapsed && stability.verified) {
1083
+ const nextBaseline = mergeStableBaselineSamples(before, confirmation, {
1084
+ reason: "verified_browser_bounds_change"
1085
+ });
1086
+ return {
1087
+ ok: true,
1088
+ collapsed: false,
1089
+ recovered: false,
1090
+ reason,
1091
+ before,
1092
+ state: confirmation,
1093
+ baseline: nextBaseline,
1094
+ baselineEstablished: false,
1095
+ rebaselined: true,
1096
+ windowBoundsChange,
1097
+ baselineComparison: confirmation.baselineComparison,
1098
+ stability
1099
+ };
1100
+ }
1101
+ return {
1102
+ ok: false,
1103
+ collapsed: Boolean(before.collapsed || confirmation.collapsed),
1104
+ recovered: false,
1105
+ reason,
1106
+ before,
1107
+ state: confirmation,
1108
+ baseline,
1109
+ baselineEstablished: false,
1110
+ rebaselined: false,
1111
+ windowBoundsChange,
1112
+ baselineComparison: confirmation.baselineComparison,
1113
+ stability,
1114
+ error: "external browser resize was not stable across two healthy readings"
1115
+ };
1116
+ }
1117
+
1118
+ const rebaselined = false;
1119
+ annotateViewportState(before, effectiveBaseline);
1120
+
1121
+ if (!before.collapsed && !baseline) {
1122
+ const confirmation = await readViewportState(client, {
1123
+ roots,
1124
+ root,
1125
+ frameOwnerRoot
1126
+ });
1127
+ if (!confirmation.ok) {
1128
+ annotateViewportState(confirmation, effectiveBaseline);
1129
+ return {
1130
+ ok: false,
1131
+ collapsed: false,
1132
+ recovered: false,
1133
+ reason,
1134
+ before,
1135
+ state: confirmation,
1136
+ baseline,
1137
+ baselineEstablished: false,
1138
+ rebaselined: false,
1139
+ windowBoundsChange,
1140
+ stability: {
1141
+ required: true,
1142
+ verified: false,
1143
+ sampleCount: 2,
1144
+ error: confirmation.error || "viewport stability sample could not be read"
1145
+ },
1146
+ error: confirmation.error || "viewport stability sample could not be read"
1147
+ };
1148
+ }
1149
+ const stability = buildStabilityEvidence(before, confirmation);
1150
+ const candidateBaseline = createViewportBaseline(before, {
1151
+ reason: "initial_healthy_viewport"
1152
+ });
1153
+ annotateViewportState(confirmation, candidateBaseline);
1154
+ if (!confirmation.collapsed && stability.verified) {
1155
+ const nextBaseline = mergeStableBaselineSamples(before, confirmation, {
1156
+ reason: "initial_healthy_viewport"
1157
+ });
1158
+ return {
1159
+ ok: true,
1160
+ collapsed: false,
1161
+ recovered: false,
1162
+ reason,
1163
+ before,
1164
+ state: confirmation,
1165
+ baseline: nextBaseline,
1166
+ baselineEstablished: true,
1167
+ rebaselined: false,
1168
+ windowBoundsChange,
1169
+ baselineComparison: confirmation.baselineComparison,
1170
+ stability
1171
+ };
1172
+ }
1173
+ if (!stability.verified && !confirmation.collapsed) {
1174
+ return {
1175
+ ok: false,
1176
+ collapsed: false,
1177
+ recovered: false,
1178
+ reason,
1179
+ before,
1180
+ state: confirmation,
1181
+ baseline,
1182
+ baselineEstablished: false,
1183
+ rebaselined: false,
1184
+ windowBoundsChange,
1185
+ baselineComparison: confirmation.baselineComparison,
1186
+ stability,
1187
+ error: "viewport baseline did not stabilize across consecutive readings"
1188
+ };
1189
+ }
1190
+ before = confirmation;
1191
+ effectiveBaseline = candidateBaseline;
1192
+ }
1193
+
1194
+ if (!before.collapsed) {
1195
+ return {
1196
+ ok: true,
1197
+ collapsed: false,
1198
+ recovered: false,
1199
+ reason,
1200
+ state: before,
1201
+ baseline: effectiveBaseline,
1202
+ baselineEstablished: false,
1203
+ rebaselined: false,
1204
+ windowBoundsChange,
1205
+ baselineComparison: before.baselineComparison,
1206
+ stability: {
1207
+ required: false,
1208
+ verified: true,
1209
+ sampleCount: 1
1210
+ }
1211
+ };
1212
+ }
1213
+
1214
+ if (!repair) {
1215
+ return {
1216
+ ok: false,
1217
+ collapsed: true,
1218
+ recovered: false,
1219
+ reason,
1220
+ before,
1221
+ state: before,
1222
+ baseline: effectiveBaseline,
1223
+ baselineEstablished: false,
1224
+ rebaselined,
1225
+ windowBoundsChange,
1226
+ baselineComparison: before.baselineComparison,
1227
+ error: "viewport collapsed and repair disabled"
1228
+ };
1229
+ }
1230
+
1231
+ const repairResult = await toggleWindowStateForViewportRecovery(client, {
1232
+ reason,
1233
+ settleMs: recoverySettleMs
1234
+ });
1235
+ if (!repairResult.ok || !repairResult.original_state_restored) {
1236
+ return {
1237
+ ok: false,
1238
+ collapsed: true,
1239
+ recovered: false,
1240
+ reason,
1241
+ before,
1242
+ state: before,
1243
+ repair: repairResult,
1244
+ baseline: effectiveBaseline,
1245
+ baselineEstablished: false,
1246
+ rebaselined,
1247
+ windowBoundsChange,
1248
+ baselineComparison: before.baselineComparison || null,
1249
+ error: "original browser window state and bounds were not verified after recovery"
1250
+ };
1251
+ }
1252
+ if (recoveryDelayMs > 0) await sleep(recoveryDelayMs);
1253
+ const rootSamples = [];
1254
+ const firstRoots = await reacquireViewportRoots(reacquireRoots, {
1255
+ phase: "post_repair_sample_1",
1256
+ root,
1257
+ frameOwnerRoot
1258
+ });
1259
+ rootSamples.push(firstRoots);
1260
+ if (!firstRoots.ok) {
1261
+ return {
1262
+ ok: false,
1263
+ collapsed: true,
1264
+ recovered: false,
1265
+ reason,
1266
+ before,
1267
+ state: before,
1268
+ repair: repairResult,
1269
+ rootReacquisition: compactRootReacquisition(rootSamples),
1270
+ baseline: effectiveBaseline,
1271
+ baselineEstablished: false,
1272
+ rebaselined,
1273
+ windowBoundsChange,
1274
+ baselineComparison: before.baselineComparison || null,
1275
+ error: firstRoots.error || "viewport roots could not be reacquired after recovery"
1276
+ };
1277
+ }
1278
+ const after = await readViewportState(client, {
1279
+ roots: firstRoots.roots,
1280
+ root,
1281
+ frameOwnerRoot
1282
+ });
1283
+ annotateViewportState(after, effectiveBaseline);
1284
+ if (!after.ok || after.collapsed) {
1285
+ return {
1286
+ ok: false,
1287
+ collapsed: Boolean(after.collapsed),
1288
+ recovered: false,
1289
+ reason,
1290
+ before,
1291
+ state: after,
1292
+ repair: repairResult,
1293
+ rootReacquisition: compactRootReacquisition(rootSamples),
1294
+ baseline: effectiveBaseline,
1295
+ baselineEstablished: false,
1296
+ rebaselined,
1297
+ windowBoundsChange,
1298
+ baselineComparison: after.baselineComparison || null,
1299
+ error: after.ok
1300
+ ? "viewport collapsed after recovery attempt"
1301
+ : after.error || "viewport state could not be read after recovery"
1302
+ };
1303
+ }
1304
+
1305
+ const secondRoots = await reacquireViewportRoots(reacquireRoots, {
1306
+ phase: "post_repair_sample_2",
1307
+ root,
1308
+ frameOwnerRoot
1309
+ });
1310
+ rootSamples.push(secondRoots);
1311
+ if (!secondRoots.ok) {
1312
+ return {
1313
+ ok: false,
1314
+ collapsed: false,
1315
+ recovered: false,
1316
+ reason,
1317
+ before,
1318
+ state: after,
1319
+ repair: repairResult,
1320
+ rootReacquisition: compactRootReacquisition(rootSamples),
1321
+ baseline: effectiveBaseline,
1322
+ baselineEstablished: false,
1323
+ rebaselined,
1324
+ windowBoundsChange,
1325
+ baselineComparison: after.baselineComparison || null,
1326
+ error: secondRoots.error || "viewport roots could not be reacquired for recovery verification"
1327
+ };
1328
+ }
1329
+ const verification = await readViewportState(client, {
1330
+ roots: secondRoots.roots,
1331
+ root,
1332
+ frameOwnerRoot
1333
+ });
1334
+ annotateViewportState(verification, effectiveBaseline);
1335
+ const stability = verification.ok
1336
+ ? buildStabilityEvidence(after, verification)
1337
+ : {
1338
+ required: true,
1339
+ verified: false,
1340
+ sampleCount: 2,
1341
+ error: verification.error || "viewport recovery verification could not be read"
1342
+ };
1343
+ const recovered = Boolean(
1344
+ verification.ok
1345
+ && !verification.collapsed
1346
+ && stability.verified
1347
+ && repairResult.original_state_restored
1348
+ && rootSamples.every((sample) => sample.ok)
1349
+ );
1350
+ const nextBaseline = recovered
1351
+ ? effectiveBaseline || mergeStableBaselineSamples(after, verification, {
1352
+ reason: "recovered_healthy_viewport"
1353
+ })
1354
+ : effectiveBaseline;
1355
+ return {
1356
+ ok: recovered,
1357
+ collapsed: Boolean(verification.collapsed),
1358
+ recovered,
1359
+ reason,
1360
+ before,
1361
+ state: verification,
1362
+ repair: repairResult,
1363
+ rootReacquisition: compactRootReacquisition(rootSamples),
1364
+ baseline: nextBaseline,
1365
+ baselineEstablished: Boolean(recovered && !baseline),
1366
+ rebaselined,
1367
+ windowBoundsChange,
1368
+ baselineComparison: verification.baselineComparison || null,
1369
+ stability,
1370
+ error: recovered
1371
+ ? null
1372
+ : verification.ok && !verification.collapsed
1373
+ ? "viewport did not stabilize after recovery attempt with verified window restoration"
1374
+ : verification.error || "viewport collapsed after recovery attempt"
1375
+ };
1376
+ }
1377
+
1378
+ export function createViewportRunGuard({
1379
+ client,
1380
+ domain = "boss",
1381
+ root = "frame",
1382
+ frameOwnerRoot = "frameOwner",
1383
+ runControl = null,
1384
+ getRoots = null,
1385
+ rootNodesFromState = (rootState) => rootState?.rootNodes || rootState?.roots || rootState || {},
1386
+ repair = true,
1387
+ recoveryDelayMs = 900,
1388
+ recoverySettleMs = 520,
1389
+ maxEvents = 10
1390
+ } = {}) {
1391
+ if (!client) throw new Error("createViewportRunGuard requires a guarded CDP client");
1392
+ const events = [];
1393
+ let baseline = null;
1394
+ const stats = {
1395
+ checks: 0,
1396
+ recoveries: 0,
1397
+ failures: 0,
1398
+ baseline_establishments: 0,
1399
+ rebaselines: 0,
1400
+ baseline_drift_detections: 0,
1401
+ unreadable_measurements: 0
1402
+ };
1403
+
1404
+ function recordEvent(phase, health) {
1405
+ const compact = compactViewportHealthResult(health);
1406
+ const shouldRecord = Boolean(
1407
+ health?.recovered
1408
+ || health?.rebaselined
1409
+ || !health?.ok
1410
+ || health?.collapsed
1411
+ );
1412
+ if (!shouldRecord) return compact;
1413
+ const event = {
1414
+ phase,
1415
+ at: new Date().toISOString(),
1416
+ ...compact
1417
+ };
1418
+ events.push(event);
1419
+ if (events.length > maxEvents) events.shift();
1420
+ if (runControl) {
1421
+ runControl.checkpoint({
1422
+ viewport_health: event,
1423
+ viewport_health_events: events.slice(),
1424
+ viewport_health_stats: { ...stats }
1425
+ });
1426
+ }
1427
+ return compact;
1428
+ }
1429
+
1430
+ async function ensure(rootState, {
1431
+ phase = "run",
1432
+ reason = `${domain}:${phase}`
1433
+ } = {}) {
1434
+ let currentRootState = rootState;
1435
+ if (!currentRootState && typeof getRoots === "function") {
1436
+ currentRootState = await getRoots(client);
1437
+ }
1438
+ const roots = rootNodesFromState(currentRootState);
1439
+ let latestReacquiredRootState = null;
1440
+ const reacquireRoots = typeof getRoots === "function"
1441
+ ? async () => {
1442
+ const freshRootState = await getRoots(client);
1443
+ latestReacquiredRootState = freshRootState;
1444
+ return rootNodesFromState(freshRootState);
1445
+ }
1446
+ : null;
1447
+ stats.checks += 1;
1448
+ const health = await ensureHealthyViewport(client, {
1449
+ roots,
1450
+ root,
1451
+ frameOwnerRoot,
1452
+ reason,
1453
+ repair,
1454
+ recoveryDelayMs,
1455
+ recoverySettleMs,
1456
+ baseline,
1457
+ reacquireRoots
1458
+ });
1459
+ if (health.baselineEstablished) stats.baseline_establishments += 1;
1460
+ if (health.rebaselined) stats.rebaselines += 1;
1461
+ if (health.before?.collapseEvidence?.baselineDrift) stats.baseline_drift_detections += 1;
1462
+ if (
1463
+ !health.state?.ok
1464
+ || health.state?.collapseEvidence?.unreadable
1465
+ || health.before?.collapseEvidence?.unreadable
1466
+ ) {
1467
+ stats.unreadable_measurements += 1;
1468
+ }
1469
+ if (health.ok && health.baseline) baseline = health.baseline;
1470
+ if (health.recovered) stats.recoveries += 1;
1471
+ if (!health.ok) stats.failures += 1;
1472
+ const compact = recordEvent(phase, health);
1473
+ if (!health.ok) {
1474
+ const error = new Error(`${String(domain).toUpperCase()}_LIST_VIEWPORT_COLLAPSED`);
1475
+ error.code = "LIST_VIEWPORT_COLLAPSED";
1476
+ error.domain = domain;
1477
+ error.phase = phase;
1478
+ error.viewport_health = compact;
1479
+ throw error;
1480
+ }
1481
+ if (health.recovered && latestReacquiredRootState) {
1482
+ currentRootState = latestReacquiredRootState;
1483
+ }
1484
+ return {
1485
+ rootState: currentRootState,
1486
+ health,
1487
+ compact,
1488
+ stats: { ...stats },
1489
+ events: events.slice()
1490
+ };
1491
+ }
1492
+
1493
+ return {
1494
+ ensure,
1495
+ getStats() {
1496
+ return { ...stats };
1497
+ },
1498
+ getEvents() {
1499
+ return events.slice();
1500
+ },
1501
+ getBaseline() {
1502
+ return compactViewportBaseline(baseline);
1503
+ }
1504
+ };
1505
+ }