dsh-browser-playwright-codex 0.7.8 → 0.8.1

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.
@@ -14,6 +14,9 @@ let _enabled = true;
14
14
  let _engine = undefined;
15
15
  /** User's window-mode choice: true = headless, undefined = follow the config. */
16
16
  let _headless = undefined;
17
+ /** 进场特效的开关:true/false = 用户自己选的,undefined = 跟配置的默认值。 */
18
+ let _swarm = undefined;
19
+ let _swarmTuning = undefined;
17
20
  /** Config default pushed in by the provider (display only). */
18
21
  let _configuredHeadless = undefined;
19
22
  /** Default pushed in by the provider that is actually loaded (display only). */
@@ -40,6 +43,31 @@ function load() {
40
43
  // Same contract as engine: absent/unknown means "follow the configured
41
44
  // default", so files written before this setting existed stay valid.
42
45
  _headless = typeof obj.headless === 'boolean' ? obj.headless : undefined;
46
+ // 同一个约定:没写过或值不认识 = 跟配置默认值,老状态文件照样有效。
47
+ _swarm = typeof obj.swarm === 'boolean' ? obj.swarm : undefined;
48
+ const tuning = obj.swarmTuning;
49
+ if (tuning !== undefined && tuning !== null && typeof tuning === 'object') {
50
+ // 只收认识的项;缺的项不写,读的时候自然落回配置默认值。
51
+ const next = {};
52
+ if (typeof tuning.speed === 'number')
53
+ next.speed = tuning.speed;
54
+ if (typeof tuning.maxMs === 'number')
55
+ next.maxMs = tuning.maxMs;
56
+ if (typeof tuning.oncePerSite === 'boolean')
57
+ next.oncePerSite = tuning.oncePerSite;
58
+ if (typeof tuning.playOnVeiled === 'boolean')
59
+ next.playOnVeiled = tuning.playOnVeiled;
60
+ if (tuning.whileAiWorks === 'auto-close' || tuning.whileAiWorks === 'keep-alive')
61
+ next.whileAiWorks = tuning.whileAiWorks;
62
+ if (tuning.range === 'viewport' || tuning.range === 'page')
63
+ next.range = tuning.range;
64
+ if (typeof tuning.persistPlayed === 'boolean')
65
+ next.persistPlayed = tuning.persistPlayed;
66
+ _swarmTuning = next;
67
+ }
68
+ else {
69
+ _swarmTuning = undefined;
70
+ }
43
71
  }
44
72
  }
45
73
  catch {
@@ -69,6 +97,10 @@ function persist() {
69
97
  const payload = _engine === undefined ? { enabled: _enabled } : { enabled: _enabled, engine: _engine };
70
98
  if (_headless !== undefined)
71
99
  payload.headless = _headless;
100
+ if (_swarm !== undefined)
101
+ payload.swarm = _swarm;
102
+ if (_swarmTuning !== undefined)
103
+ payload.swarmTuning = _swarmTuning;
72
104
  fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
73
105
  fs.renameSync(tmp, target);
74
106
  }
@@ -127,6 +159,74 @@ export function setHeadless(next) {
127
159
  persist();
128
160
  return true;
129
161
  }
162
+ /**
163
+ * 进场特效的开关:用户自己选过的值,没选过返回 undefined(由配置默认值说话)。
164
+ */
165
+ export function getSwarm() {
166
+ load();
167
+ return _swarm;
168
+ }
169
+ /** 记下用户对进场特效的选择。Returns true when the value changed. */
170
+ export function setSwarm(next) {
171
+ load();
172
+ if (typeof next !== 'boolean')
173
+ return false;
174
+ if (_swarm === next)
175
+ return false;
176
+ _swarm = next;
177
+ persist();
178
+ return true;
179
+ }
180
+ /** 用户自己选过吗?(设置卡用来区分「你选的」和「默认的」。) */
181
+ export function isSwarmUserChosen() {
182
+ load();
183
+ return _swarm !== undefined;
184
+ }
185
+ /** 播放参数:只含用户改过的项,其余跟配置默认值。 */
186
+ export function getSwarmTuning() {
187
+ load();
188
+ return _swarmTuning ?? {};
189
+ }
190
+ /** 改播放参数(只传要改的项)。Returns true when something changed. */
191
+ export function setSwarmTuning(patch) {
192
+ load();
193
+ const next = { ...(_swarmTuning ?? {}) };
194
+ let changed = false;
195
+ if (typeof patch.speed === 'number' && next.speed !== patch.speed) {
196
+ next.speed = patch.speed;
197
+ changed = true;
198
+ }
199
+ if (typeof patch.maxMs === 'number' && next.maxMs !== patch.maxMs) {
200
+ next.maxMs = patch.maxMs;
201
+ changed = true;
202
+ }
203
+ if (typeof patch.oncePerSite === 'boolean' && next.oncePerSite !== patch.oncePerSite) {
204
+ next.oncePerSite = patch.oncePerSite;
205
+ changed = true;
206
+ }
207
+ if (typeof patch.playOnVeiled === 'boolean' && next.playOnVeiled !== patch.playOnVeiled) {
208
+ next.playOnVeiled = patch.playOnVeiled;
209
+ changed = true;
210
+ }
211
+ if ((patch.whileAiWorks === 'auto-close' || patch.whileAiWorks === 'keep-alive') &&
212
+ next.whileAiWorks !== patch.whileAiWorks) {
213
+ next.whileAiWorks = patch.whileAiWorks;
214
+ changed = true;
215
+ }
216
+ if ((patch.range === 'viewport' || patch.range === 'page') && next.range !== patch.range) {
217
+ next.range = patch.range;
218
+ changed = true;
219
+ }
220
+ if (typeof patch.persistPlayed === 'boolean' && next.persistPlayed !== patch.persistPlayed) {
221
+ next.persistPlayed = patch.persistPlayed;
222
+ changed = true;
223
+ }
224
+ if (!changed)
225
+ return false;
226
+ _swarmTuning = next;
227
+ persist();
228
+ return true;
229
+ }
130
230
  /** Publish the configured default (launch.headless) for display. */
131
231
  export function publishConfiguredHeadless(value) {
132
232
  _configuredHeadless = value === true;
package/lib/session.d.ts CHANGED
@@ -149,6 +149,17 @@ export declare class PlaywrightSession {
149
149
  capture(page: Page, opts: {
150
150
  interactiveOnly?: boolean;
151
151
  } | undefined, afterAction?: boolean): Promise<BrowserSnapshot>;
152
+ /**
153
+ * Close one page whose script has stopped answering, so the next acquire starts
154
+ * on a live tab instead of inheriting it. Bounded and best-effort: main thread
155
+ * wedged badly enough to stall an evaluate may never answer close either, and
156
+ * this runs on the error path — it must never turn a timeout into a second hang.
157
+ *
158
+ * 代价要说清楚:`runBeforeUnload:false` 意味着**不弹**「确定要离开吗」,所以页面上
159
+ * 填了一半的表单、没提交的内容直接丢弃;如果那是唯一一个标签,接下来是一张空白页。
160
+ * 对真卡死的页这笔账划算,但用户必须被告知——所以 capture() 的报错文案里写明这件事。
161
+ */
162
+ private discardStuckPage;
152
163
  /**
153
164
  * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
154
165
  * An unavailable or unreadable aria tree (`missing`) degrades to the legacy
package/lib/session.js CHANGED
@@ -240,7 +240,12 @@ export class PlaywrightSession {
240
240
  }
241
241
  /** Element-level stability before a pointer/keyboard action, bounded. Returns true when stable. */
242
242
  async waitForElementStable(locator) {
243
- const ok = await locator.evaluate(elementStabilityProbe, { frames: 2, timeoutMs: 1500 }).catch(() => false);
243
+ // locator.evaluate has no timeout of its own either: the probe runs page
244
+ // script, so a wedged main thread makes it hang before the action it is
245
+ // supposed to protect. Degrade to "not stable" and let the action's own
246
+ // Playwright timeout report the real problem (same contract as before, just
247
+ // no longer unbounded).
248
+ const ok = await withDeadline(locator.evaluate(elementStabilityProbe, { frames: 2, timeoutMs: 1500 }).catch(() => false), Math.max(3000, this.timeoutMs()), 'waiting for the element to hold still', this.currentSignal, 'resolve-undefined').catch(() => false);
244
249
  return ok === true;
245
250
  }
246
251
  /** Post-action settle: wait out a real navigation, then render-stabilize before snapshotting. */
@@ -327,19 +332,42 @@ export class PlaywrightSession {
327
332
  return this.attachDiff(attachNoProgress(this, attachDocResponse(this, page, raw), afterAction));
328
333
  };
329
334
  try {
330
- const snap = await withDeadline(read(), this.timeoutMs(), 'reading the page content', this.currentSignal);
335
+ const snap = await withDeadline(read(), this.timeoutMs(), 'reading the page content', this.currentSignal, 'reject',
336
+ // 报错文案必须说人话、说真话:这一页真的被关掉了,页面里没提交的东西跟着没了。
337
+ // 关页不弹 beforeunload(弹了可能又卡死),所以用户只有从这条消息里才知道。
338
+ 'That page has been closed; anything typed into it but not submitted is lost, and if it was the last tab the session now starts on a blank page.');
331
339
  return snap;
332
340
  }
333
341
  catch (error) {
334
342
  // 超时只能让我们这一侧返回,页面里那个 evaluate 还在跑。作废会话句柄,
335
- // 下一次调用会重新拿到干净窗口,而不是继承这个卡住的页面。
343
+ // 再把那张卡死的页面真正关掉——否则持久化模式下 ensurePage() 会把同一个
344
+ // 页面交还给下一次调用,所谓「自愈」只是换了个句柄继续超时(issue #5)。
336
345
  if (isReadTimeout(error)) {
337
346
  this.closed = true;
338
347
  this.provider.invalidateOwner(this.owner);
348
+ await this.discardStuckPage(page);
339
349
  }
340
350
  throw error;
341
351
  }
342
352
  }
353
+ /**
354
+ * Close one page whose script has stopped answering, so the next acquire starts
355
+ * on a live tab instead of inheriting it. Bounded and best-effort: main thread
356
+ * wedged badly enough to stall an evaluate may never answer close either, and
357
+ * this runs on the error path — it must never turn a timeout into a second hang.
358
+ *
359
+ * 代价要说清楚:`runBeforeUnload:false` 意味着**不弹**「确定要离开吗」,所以页面上
360
+ * 填了一半的表单、没提交的内容直接丢弃;如果那是唯一一个标签,接下来是一张空白页。
361
+ * 对真卡死的页这笔账划算,但用户必须被告知——所以 capture() 的报错文案里写明这件事。
362
+ */
363
+ async discardStuckPage(page) {
364
+ try {
365
+ await withDeadline(page.close({ runBeforeUnload: false }), 2_000, 'closing the stuck page', undefined, 'resolve-undefined');
366
+ }
367
+ catch {
368
+ /* already gone, or too wedged to close: nothing better we can do */
369
+ }
370
+ }
343
371
  /**
344
372
  * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
345
373
  * An unavailable or unreadable aria tree (`missing`) degrades to the legacy
@@ -556,7 +584,11 @@ export class PlaywrightSession {
556
584
  if (page === undefined)
557
585
  continue;
558
586
  this.guardBlockedDialog(page);
559
- out.push({ index, url: page.url(), title: await page.title(), active: index === activeIndex });
587
+ // page.title() is a page round trip with no timeout: one tab running a
588
+ // stuck script used to hang the whole tab list, and every tab after it.
589
+ // An unreadable title degrades to "" — the URL still identifies the tab.
590
+ const title = await withDeadline(page.title().catch(() => ''), Math.max(2000, Math.min(this.timeoutMs(), 5000)), 'reading the title of tab ' + String(index), this.currentSignal, 'resolve-undefined').catch(() => undefined);
591
+ out.push({ index, url: page.url(), title: title ?? '', active: index === activeIndex });
560
592
  }
561
593
  return out;
562
594
  });
@@ -600,7 +632,29 @@ export class PlaywrightSession {
600
632
  });
601
633
  }
602
634
  async pageData() {
603
- return pageData(this);
635
+ // page.evaluate again: the extract walker is page script, so a stuck tab used
636
+ // to hang browser_extract exactly like a snapshot read. Same deadline, and on
637
+ // timeout the same cleanup — drop the handle and the page so the next call
638
+ // does not inherit a page that will never answer.
639
+ try {
640
+ return await withDeadline(pageData(this), this.timeoutMs(), 'reading the page content', this.currentSignal, 'reject', 'That page has been closed; anything typed into it but not submitted is lost.');
641
+ }
642
+ catch (error) {
643
+ if (isReadTimeout(error)) {
644
+ this.closed = true;
645
+ this.provider.invalidateOwner(this.owner);
646
+ let page;
647
+ try {
648
+ page = this.currentPageSync();
649
+ }
650
+ catch {
651
+ page = undefined;
652
+ }
653
+ if (page !== undefined)
654
+ await this.discardStuckPage(page);
655
+ }
656
+ throw error;
657
+ }
604
658
  }
605
659
  async evaluate(expression, signal) {
606
660
  // The switch is enforced HERE, not only in the tool layer: browser_evaluate
@@ -616,14 +670,26 @@ export class PlaywrightSession {
616
670
  this.provider.touch(this.owner);
617
671
  const page = await this.ensurePage();
618
672
  const fn = new Function('return (' + expression + ')');
673
+ // page.evaluate takes no timeout: an expression with an infinite loop (a
674
+ // shader compile, a spin on a mutex) never returns and the tool call hangs
675
+ // forever. Bound it with the same budget the snapshot read uses, and on
676
+ // timeout drop this handle + the stuck page so the next call starts clean
677
+ // instead of inheriting a page that will never answer.
678
+ const once = async () => (await withDeadline(withAbort(page.evaluate(fn), signal), this.timeoutMs(), 'evaluating page JavaScript', undefined, 'reject', 'That page has been closed; anything typed into it but not submitted is lost, and the expression may have left the page unresponsive.'));
619
679
  try {
620
- return await withAbort(page.evaluate(fn), signal);
680
+ return await once();
621
681
  }
622
682
  catch (error) {
683
+ if (isReadTimeout(error)) {
684
+ this.closed = true;
685
+ this.provider.invalidateOwner(this.owner);
686
+ await this.discardStuckPage(page);
687
+ throw error;
688
+ }
623
689
  if (!isContextDestroyed(error) || signal?.aborted === true)
624
690
  throw error;
625
691
  await this.settleNavigation(page);
626
- return withAbort(page.evaluate(fn), signal);
692
+ return once();
627
693
  }
628
694
  });
629
695
  }
@@ -665,13 +731,16 @@ export class PlaywrightSession {
665
731
  // inside the driver, and the failure gets reported as a protocol error.
666
732
  const session = this.cdpSession;
667
733
  const send = session.send.bind(session);
668
- return await withAbort(send(method, params ?? {}), signal);
734
+ // A CDP command has no timeout of its own either, and some of them
735
+ // (screenshots, layout dumps) run page script internally. Same budget as
736
+ // a page read; the CDP session itself stays usable, only this call dies.
737
+ return await withDeadline(withAbort(send(method, params ?? {}), signal), this.timeoutMs(), 'running CDP command ' + method, undefined, 'reject', 'The command was abandoned; the page may still be working on it. Take a fresh snapshot to see where it landed.');
669
738
  }
670
739
  catch (error) {
671
- if (isAbortError(error))
740
+ // A deadline and a cancel both mean what they say: never relabel them as
741
+ // a protocol failure (the wrapping below only owns real CDP errors).
742
+ if (isAbortError(error) || isReadTimeout(error))
672
743
  throw error;
673
- // CDP protocol errors are already structured ("Protocol error
674
- // (DOM.foo): ..."); wrap non-abort failures in a stable code.
675
744
  const detail = error instanceof Error ? error.message : String(error);
676
745
  if (/Protocol error/.test(detail)) {
677
746
  const m = /\(([^)]+)\): ([\s\S]+)/.exec(detail);
@@ -63,6 +63,7 @@ import * as runtimeState from "./runtime-state.js";
63
63
  // The patch writer lives in its own dependency-free module so it can be tested
64
64
  // without a DSH host (a half-written patch layer would break every plugin load).
65
65
  import { homePatchPath, parseGatesBlock, writeGates } from "./gates-patch.js";
66
+ import { onPersistPlayedChanged, resetPlayed } from "./swarm-intro.js";
66
67
  import { readFileSync } from 'node:fs';
67
68
  import { clean, isTargetId, resolvePaths, scan } from "./cache-clean.js";
68
69
  const { getEnabled, setEnabled, getEffectiveEngine, setSnapshotEngine, isEngineUserChosen } = runtimeState;
@@ -238,9 +239,119 @@ function registerWindowRoute(ctx) {
238
239
  },
239
240
  }));
240
241
  }
242
+ /**
243
+ * POST /swarm { enabled } — 进场特效的开关。
244
+ *
245
+ * 只是用户在设置卡上的选择,落盘后立刻生效:下一次导航就会按新值决定播不播,
246
+ * 不用重启,也不用断开已经开着的窗口。
247
+ */
248
+ function registerSwarmRoute(ctx) {
249
+ ctx.effect(() => ctx.webServer.register({
250
+ kind: 'exact',
251
+ path: PREFIX + '/swarm',
252
+ handler: async (req, res) => {
253
+ if (req.method !== 'POST')
254
+ return sendJson(res, 405, { error: { code: 'METHOD', message: 'POST only' } });
255
+ if (req.headers[HEADER] !== 'browser-toggle')
256
+ return sendJson(res, 403, { error: { code: 'CSRF', message: 'missing plugin header' } });
257
+ const body = await readBody(req);
258
+ if (!body.ok)
259
+ return sendJson(res, 400, { error: { code: 'BODY_READ', message: body.error } });
260
+ let payload;
261
+ try {
262
+ payload = JSON.parse(body.text || '{}');
263
+ }
264
+ catch {
265
+ return sendJson(res, 400, { error: { code: 'INVALID_JSON', message: 'body must be JSON' } });
266
+ }
267
+ if (payload.enabled !== undefined && typeof payload.enabled !== 'boolean')
268
+ return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`enabled` must be a boolean' } });
269
+ // 清空播放记录:打开开关之后想让每个网站重新播一遍,就顺手清一下。
270
+ const reset = payload.resetPlayed === true;
271
+ if (reset) {
272
+ try {
273
+ resetPlayed();
274
+ }
275
+ catch {
276
+ /* 文件不存在就是已经清过了 */
277
+ }
278
+ }
279
+ let changed = false;
280
+ if (typeof payload.enabled === 'boolean')
281
+ changed = runtimeState.setSwarm(payload.enabled) || changed;
282
+ // 播放参数:速度 1–3;时长 0(完整播完)或 3000–30000 毫秒;是否每站只播一次。
283
+ const patch = {};
284
+ if (payload.speed !== undefined) {
285
+ const speed = Number(payload.speed);
286
+ if (!Number.isFinite(speed) || speed < 1 || speed > 3)
287
+ return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`speed` must be 1–3' } });
288
+ patch.speed = Math.round(speed);
289
+ }
290
+ if (payload.maxMs !== undefined) {
291
+ const maxMs = Number(payload.maxMs);
292
+ if (!Number.isFinite(maxMs) || (maxMs !== 0 && (maxMs < 3000 || maxMs > 30000)))
293
+ return sendJson(res, 400, {
294
+ error: { code: 'INVALID_BODY', message: '`maxMs` must be 0 or 3000–30000' },
295
+ });
296
+ patch.maxMs = Math.round(maxMs);
297
+ }
298
+ if (payload.oncePerSite !== undefined) {
299
+ if (typeof payload.oncePerSite !== 'boolean')
300
+ return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`oncePerSite` must be a boolean' } });
301
+ patch.oncePerSite = payload.oncePerSite;
302
+ }
303
+ if (payload.playOnVeiled !== undefined) {
304
+ if (typeof payload.playOnVeiled !== 'boolean')
305
+ return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`playOnVeiled` must be a boolean' } });
306
+ patch.playOnVeiled = payload.playOnVeiled;
307
+ }
308
+ if (payload.whileAiWorks !== undefined) {
309
+ if (payload.whileAiWorks !== 'auto-close' && payload.whileAiWorks !== 'keep-alive')
310
+ return sendJson(res, 400, {
311
+ error: { code: 'INVALID_BODY', message: '`whileAiWorks` must be auto-close or keep-alive' },
312
+ });
313
+ patch.whileAiWorks = payload.whileAiWorks;
314
+ }
315
+ if (payload.range !== undefined) {
316
+ if (payload.range !== 'viewport' && payload.range !== 'page')
317
+ return sendJson(res, 400, {
318
+ error: { code: 'INVALID_BODY', message: '`range` must be viewport or page' },
319
+ });
320
+ patch.range = payload.range;
321
+ }
322
+ if (payload.persistPlayed !== undefined) {
323
+ if (typeof payload.persistPlayed !== 'boolean')
324
+ return sendJson(res, 400, { error: { code: 'INVALID_BODY', message: '`persistPlayed` must be a boolean' } });
325
+ patch.persistPlayed = payload.persistPlayed;
326
+ }
327
+ const wasPersist = runtimeState.getSwarmTuning().persistPlayed === true;
328
+ if (Object.keys(patch).length > 0)
329
+ changed = runtimeState.setSwarmTuning(patch) || changed;
330
+ // 开关真的翻了才动记录:打开立刻把这趟落盘,关掉删盘(防幽灵复活)。
331
+ const nowPersist = runtimeState.getSwarmTuning().persistPlayed === true;
332
+ if (nowPersist !== wasPersist)
333
+ onPersistPlayedChanged(nowPersist);
334
+ const tuning = runtimeState.getSwarmTuning();
335
+ sendJson(res, 200, {
336
+ swarm: runtimeState.getSwarm(),
337
+ swarmSpeed: tuning.speed ?? null,
338
+ swarmMaxMs: tuning.maxMs ?? null,
339
+ swarmOncePerSite: tuning.oncePerSite ?? null,
340
+ swarmPlayOnVeiled: tuning.playOnVeiled ?? null,
341
+ swarmWhileAiWorks: tuning.whileAiWorks ?? null,
342
+ swarmRange: tuning.range ?? null,
343
+ swarmPersistPlayed: tuning.persistPlayed ?? null,
344
+ changed,
345
+ resetPlayed: reset,
346
+ appliedOn: 'next-navigation',
347
+ });
348
+ },
349
+ }));
350
+ }
241
351
  export function apply(ctx) {
242
352
  registerDiskRoutes(ctx);
243
353
  registerWindowRoute(ctx);
354
+ registerSwarmRoute(ctx);
244
355
  ctx.effect(() => ctx.webServer.register({
245
356
  kind: 'exact',
246
357
  path: `${PREFIX}/state`,
@@ -265,6 +376,18 @@ export function apply(ctx) {
265
376
  headless: runtimeState.getEffectiveHeadless(),
266
377
  headlessUserChosen: runtimeState.isHeadlessUserChosen(),
267
378
  headlessConfigured: runtimeState.getConfiguredHeadless(),
379
+ // 进场特效:null = 用户没表态,跟配置默认(出厂开)。
380
+ swarm: runtimeState.getSwarm() ?? null,
381
+ swarmUserChosen: runtimeState.isSwarmUserChosen(),
382
+ // 播放参数:undefined = 跟配置默认(3× / 15 秒 / 每站一次)。
383
+ swarmSpeed: runtimeState.getSwarmTuning().speed ?? null,
384
+ swarmMaxMs: runtimeState.getSwarmTuning().maxMs ?? null,
385
+ swarmOncePerSite: runtimeState.getSwarmTuning().oncePerSite ?? null,
386
+ // null = 跟配置默认(遮罩不播 / AI 动手时自动关闭动画)。
387
+ swarmPlayOnVeiled: runtimeState.getSwarmTuning().playOnVeiled ?? null,
388
+ swarmWhileAiWorks: runtimeState.getSwarmTuning().whileAiWorks ?? null,
389
+ swarmRange: runtimeState.getSwarmTuning().range ?? null,
390
+ swarmPersistPlayed: runtimeState.getSwarmTuning().persistPlayed ?? null,
268
391
  // What the user's own layer says right now. Differs from the above
269
392
  // between a write and the live reload that applies it.
270
393
  patchGates: managedBlockGates() ?? null,
@@ -42,6 +42,21 @@ const TRACKING_PARAMS = new Set([
42
42
  ]);
43
43
  /** Default cap for a rendered href, in characters. */
44
44
  export const MAX_HREF_LENGTH = 160;
45
+ /**
46
+ * Path shapes that mark a redirect-wrapping URL — a link that does not point at
47
+ * its destination but at the site's own click-tracking endpoint, which forwards
48
+ * there. Search engines wrap every organic result this way (Bing `/ck/a?...`,
49
+ * Google `/url?q=...`, Facebook `l.php?u=...`). The real destination is encoded
50
+ * in a long query parameter, so the rendered href is 160 characters of base64
51
+ * that says nothing the link's own name does not — and it repeats for every
52
+ * result on the page.
53
+ *
54
+ * This is a generic category (a tracking endpoint on the same origin as the
55
+ * page), not a per-site special case. When one matches we render a short marker
56
+ * instead of the wrapped URL: the ref is what clicks, and the link's name plus
57
+ * any destination node already carry the meaning.
58
+ */
59
+ const REDIRECT_WRAPPERS = [/\/ck\/a\?/i, /\/url\?q=/i, /l\.php\?u=/i];
45
60
  /** Clip a string to a display budget, marking the cut. */
46
61
  function clip(value, maxLength) {
47
62
  return value.length <= maxLength ? value : value.slice(0, maxLength - 1) + '…';
@@ -56,6 +71,13 @@ function clip(value, maxLength) {
56
71
  * @returns the shortened href
57
72
  */
58
73
  export function trimUrlForDisplay(href, maxLength = MAX_HREF_LENGTH) {
74
+ // A redirect-wrapping href (search-engine click tracker) is 160 characters of
75
+ // encoded destination that says nothing the link's name does not. Render a
76
+ // short marker instead; the ref is what clicks, so this never changes where a
77
+ // click lands — only what the model reads.
78
+ if (REDIRECT_WRAPPERS.some((re) => re.test(href))) {
79
+ return '→ (redirect wrapper)';
80
+ }
59
81
  const hashAt = href.indexOf('#');
60
82
  const hash = hashAt >= 0 ? href.slice(hashAt) : '';
61
83
  const head = hashAt >= 0 ? href.slice(0, hashAt) : href;
@@ -94,13 +116,14 @@ function isTransparentWrapper(node) {
94
116
  node.level === undefined &&
95
117
  node.checked === undefined &&
96
118
  node.selected === undefined &&
97
- node.disabled === undefined);
119
+ node.disabled === undefined &&
120
+ node.value === undefined);
98
121
  }
99
122
  /** Render one node line into the accumulating line list. */
100
- function renderNode(node, depth, lines) {
123
+ function renderNode(node, depth, lines, seenHrefs) {
101
124
  if (isTransparentWrapper(node)) {
102
125
  for (const child of node.children)
103
- renderNode(child, depth, lines);
126
+ renderNode(child, depth, lines, seenHrefs);
104
127
  return;
105
128
  }
106
129
  const indent = ' '.repeat(depth);
@@ -116,15 +139,33 @@ function renderNode(node, depth, lines) {
116
139
  flags.push('selected');
117
140
  if (node.disabled === true)
118
141
  flags.push('disabled');
142
+ // The current value of a text entry field: without it the model cannot see
143
+ // what a fill actually left behind, which read as "no visible change".
144
+ if (node.value !== undefined && node.value !== '')
145
+ flags.push('value=' + node.value);
119
146
  if (node.ref !== undefined)
120
147
  flags.push('ref=' + node.ref);
121
148
  if (flags.length > 0)
122
149
  line += ' [' + flags.join(', ') + ']';
123
- if (node.href !== undefined)
124
- line += ' -> ' + trimUrlForDisplay(node.href);
150
+ if (node.href !== undefined) {
151
+ // Same href, second and later occurrence within ONE snapshot: the URL is
152
+ // already printed at its first occurrence, so repeat only a pointer to it.
153
+ // Every ref and element stays — this trims bytes, never click targets. Only
154
+ // absolute URLs are deduped (a bare "/docs" can name different pages).
155
+ const abs = /^https?:\/\//i.test(node.href);
156
+ const firstRef = abs ? seenHrefs.get(node.href) : undefined;
157
+ if (firstRef !== undefined && firstRef !== node.ref) {
158
+ line += ' -> (same as ' + firstRef + ')';
159
+ }
160
+ else {
161
+ line += ' -> ' + trimUrlForDisplay(node.href);
162
+ if (abs && node.ref !== undefined)
163
+ seenHrefs.set(node.href, node.ref);
164
+ }
165
+ }
125
166
  lines.push(line);
126
167
  for (const child of node.children)
127
- renderNode(child, depth + 1, lines);
168
+ renderNode(child, depth + 1, lines, seenHrefs);
128
169
  }
129
170
  /**
130
171
  * Render a node tree as indented lines, one element per line.
@@ -133,8 +174,12 @@ function renderNode(node, depth, lines) {
133
174
  */
134
175
  export function renderSnapshotTree(nodes) {
135
176
  const lines = [];
177
+ // href -> ref of its first occurrence in THIS snapshot. Same href second and
178
+ // later renders as "(same as <ref>)" instead of repeating the URL. Scoped to
179
+ // one snapshot on purpose: a fresh snapshot after navigation re-prints URLs.
180
+ const seenHrefs = new Map();
136
181
  for (const node of nodes)
137
- renderNode(node, 0, lines);
182
+ renderNode(node, 0, lines, seenHrefs);
138
183
  return lines.join('\n');
139
184
  }
140
185
  /**