@hyzyn/dsh-docker 0.9.2 → 0.9.3

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.
@@ -29,8 +29,32 @@ const BUDGET = {
29
29
  snapshotMs: 2500,
30
30
  snapshotRows: 5000,
31
31
  burstMs: 8000,
32
- burstMaxLagMs: 400,
33
- maxNodes: 60_000,
32
+ /* D152 实测:突发最大延迟 1~2ms(改前 52~97ms),留足余量给慢机器。 */
33
+ burstMaxLagMs: 60,
34
+ /* 正文节点数:窗口化后只剩「可见 + 上下 overscan」的几十行(实测 213~229 节点)。 */
35
+ maxNodes: 800,
36
+ /* 挂载行数上限:这是「虚拟滚动还生不生效」的直接护栏(实测 56~57 行,改前 5000)。 */
37
+ mountedRows: 200,
38
+ /*
39
+ * 持续洪泛(D151 引入场景 / D152 达标):缓冲已满后的**稳态**才是用户看到的
40
+ * 「日志一快就卡」——每帧都在淘汰上千行、插入上千行,突发场景(缓冲还没满、
41
+ * 只追加)测不到这一段。
42
+ *
43
+ * 改前基线(2026-09-29):最大延迟 70~97ms、>50ms 长帧 25~26 个 / 6s。同轮验证过
44
+ * 「行元素缓存」(±0)与「合帧间隔退避」(把最大延迟抬到 144ms)都压不动它——
45
+ * 真解是把挂载行数从 5000 降到可见的几十行(D152 虚拟滚动),改后 5~7ms / 0 个。
46
+ * 这里的预算按「改后实测 + 余量」设:虚拟滚动退化(比如窗口算错挂回几千行)会立刻红。
47
+ */
48
+ sustainedMs: 6000,
49
+ /* D152 实测:持续洪泛最大延迟 5~7ms、长帧 >50ms = 0(改前 70~97ms / 25~26 个)。 */
50
+ sustainedMaxLagMs: 50,
51
+ sustainedLongFrames: 0,
52
+ /*
53
+ * 每事件 1 行的预算:同一行速率下把事件数放大 50 倍(= 逐 chunk 一帧 SSE 的极端),
54
+ * 用它当「客户端每事件开销」的回归护栏。实测值由 createSseCoalescer(D151)在
55
+ * 服务端消掉——合帧后真实客户端看到的正是「每事件 50 行」那一档。
56
+ */
57
+ perLineEventMaxLagMs: 100,
34
58
  }
35
59
 
36
60
  function skip(reason) {
@@ -86,13 +110,22 @@ const MEASURE = `(async () => {
86
110
  try {
87
111
  await waitFor(() => q('.dk_panel'))
88
112
  await waitFor(() => qa('.dk_card').length > 0)
89
- const LINE = '2026-09-24T12:00:00.000Z [INFO] request handled in 12ms path=/api/v1/orders/{id} trace=abc'
113
+ // 行格式按宿主日志解析的**真实形状**造(12:00:00.000 [INFO] ...):插件会给
114
+ // 时间戳与级别各套一个 span,行里的元素数(→ DOM 节点数)才是真的
115
+ const LINE = '12:00:00.000 [INFO] request handled in 12ms path=/api/v1/orders/{id} trace=abc'
90
116
  const BIG = Array.from({ length: 5000 }, (_, i) => LINE + ' seq=' + String(i)).join('\\n')
117
+ /*
118
+ * TALL:同样的 5000 行,但每行多出 320 个字符——面板会**折行**,行高从 ~20px 变成
119
+ * 好几十像素。用来造「刷新换了一代、连行高都不一样」的现场(见下面的换代回归)。
120
+ */
121
+ const TALL = Array.from({ length: 5000 }, (_, i) => LINE + ' seq=' + String(i) + ' ' + 'x'.repeat(320)).join('\\n')
91
122
  const realFetch = window.fetch.bind(window)
123
+ let truncateNext = false
124
+ let tallNext = false
92
125
  window.fetch = (url, init) => {
93
126
  const target = typeof url === 'string' ? url : String(url && url.url ? url.url : url)
94
127
  if (target.indexOf('/logs/stream') === -1 && target.indexOf('/logs') !== -1) {
95
- return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, logs: { id: 'app-web-1', text: BIG, truncated: false } }) })
128
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, logs: { id: 'app-web-1', text: tallNext ? TALL : BIG, truncated: truncateNext } }) })
96
129
  }
97
130
  return realFetch(url, init)
98
131
  }
@@ -104,12 +137,84 @@ const MEASURE = `(async () => {
104
137
  if (!logsTab) throw new Error('没有日志页签')
105
138
  const t0 = performance.now()
106
139
  logsTab.click()
107
- await waitFor(() => qa('.dk_logLine').length >= 4000)
140
+ // 行数真值读 data-log-total(D152 窗口化之后 DOM 里只挂可见的几十行,
141
+ // .dk_logLine 计数不再等于「视图里有多少行」;挂载行数另记 mountedDomRows)
142
+ const totalRows = () => Number(q('.dk_logBody')?.getAttribute('data-log-total') ?? '0')
143
+ const mountedRows = () => qa('.dk_logLine').length
144
+ await waitFor(() => totalRows() >= 5000)
108
145
  await sleep(150)
109
146
  out.snapshotMs = Math.round(performance.now() - t0)
110
- out.snapshotRows = qa('.dk_logLine').length
147
+ out.snapshotRows = totalRows()
148
+ out.snapshotDomRows = mountedRows()
149
+ out.snapshotViewH = q('.dk_logBody') ? q('.dk_logBody').clientHeight : -1
150
+ out.snapshotScrollH = q('.dk_logBody') ? q('.dk_logBody').scrollHeight : -1
111
151
  out.snapshotNodes = q('.dk_logBody') ? q('.dk_logBody').querySelectorAll('*').length : -1
112
152
 
153
+ /*
154
+ * 窗口化正确性(D152):只挂几十行还必须是**对的那几十行**。快照内容是确定的
155
+ * (BIG = seq=0..4999),所以滚到顶 = 第一行 seq=0、滚到底 = 最后一行 seq=4999。
156
+ * 这一条比性能数字更重要:窗口算错会表现为「滚上去看到的不是历史」。
157
+ */
158
+ const textOf = (el) => (el ? el.textContent : '')
159
+ const bodyEl = q('.dk_logBody')
160
+ if (bodyEl) {
161
+ bodyEl.scrollTop = 0
162
+ await sleep(150)
163
+ out.topFirstRow = textOf(qa('.dk_logLine')[0])
164
+ bodyEl.scrollTop = bodyEl.scrollHeight
165
+ await sleep(150)
166
+ const mounted = qa('.dk_logLine')
167
+ out.bottomLastRow = textOf(mounted[mounted.length - 1])
168
+ bodyEl.scrollTop = 0
169
+ await sleep(100)
170
+ }
171
+ const refreshBtn = qa('button').find((el) => String(el.title).indexOf('刷新日志') >= 0)
172
+
173
+ /*
174
+ * 换代的回归(D155):快照刷新 = 换了一代行(id 是位置寻址 's'+index,刷新后同一个
175
+ * id 已是另一行内容)。判据取**几何**、不取滚动位置:这一代的垫高必须由**这一代**的
176
+ * 实测高度算出来。先刷一代会折行的高行(TALL,每行 ~150px)并扫几个位置把它们量进
177
+ * 缓存,再刷回普通行(每行 ~20px)——若换代不作废缓存,上一代的 150px 会留在垫高里,
178
+ * scrollHeight 虚高几万像素(滚动条、跳转、"滚到顶看到第几行"全靠它)。
179
+ *
180
+ * 为什么不判「scrollTop 不许动」:跨帧锚**本来就应该**补偿视口上方行高的真实变化,
181
+ * 那是 D154 的正当职责——实测过,正确实现在这里会跟着上方 overscan 变高而位移
182
+ * 1881px,那是它该做的;拿滚动位置当判据会把正确实现判成错的。
183
+ */
184
+ let refreshGeometryH = -1
185
+ let refreshTotal = -1
186
+ if (bodyEl && refreshBtn) {
187
+ tallNext = true
188
+ refreshBtn.click()
189
+ await sleep(300)
190
+ for (const frac of [0.15, 0.3, 0.45, 0.6, 0.75, 0.9]) {
191
+ bodyEl.scrollTop = Math.floor(bodyEl.scrollHeight * frac)
192
+ await sleep(120)
193
+ }
194
+ tallNext = false
195
+ refreshBtn.click()
196
+ await sleep(300)
197
+ refreshGeometryH = bodyEl.scrollHeight
198
+ refreshTotal = totalRows()
199
+ }
200
+ out.refreshGeometryH = refreshGeometryH
201
+ out.refreshTotal = refreshTotal
202
+
203
+ /*
204
+ * 字节闸的可见性(用户实际踩过的坑):LINES 是**行数**上限,而「输出上限(KB)」是
205
+ * 另一个**字节**闸——它一生效,界面必须明说「已截断」,否则用户只会看到「选 1000 行
206
+ * 却只回了 985 行」而不知道是哪一个闸。这里让快照响应带 truncated:true 并刷新一次。
207
+ */
208
+ truncateNext = true
209
+ if (refreshBtn) {
210
+ refreshBtn.click()
211
+ await sleep(250)
212
+ out.truncatedBanner = qa('.dk_banner').some((el) => el.textContent.indexOf('已截断') >= 0)
213
+ } else {
214
+ out.truncatedBanner = null
215
+ }
216
+ truncateNext = false
217
+
113
218
  let maxLag = 0
114
219
  let last = performance.now()
115
220
  const probe = setInterval(() => {
@@ -119,8 +224,11 @@ const MEASURE = `(async () => {
119
224
  }, 16)
120
225
 
121
226
  let emit = null
227
+ let sourceCount = 0
228
+ const openedEventSources = () => sourceCount
122
229
  window.EventSource = class {
123
230
  constructor() {
231
+ sourceCount += 1
124
232
  this.readyState = 0
125
233
  this.listeners = new Map()
126
234
  setTimeout(() => { this.readyState = 1; if (this.onopen) this.onopen() }, 0)
@@ -148,9 +256,179 @@ const MEASURE = `(async () => {
148
256
  await sleep(800)
149
257
  clearInterval(probe)
150
258
  out.burstMs = Math.round(performance.now() - t1)
151
- out.burstRows = qa('.dk_logLine').length
259
+ out.burstRows = totalRows()
260
+ out.burstDomRows = mountedRows()
152
261
  out.burstMaxLagMs = Math.round(maxLag)
153
262
  out.burstNodes = q('.dk_logBody') ? q('.dk_logBody').querySelectorAll('*').length : -1
263
+
264
+ /*
265
+ * 行按真实 JSON 日志的长度(~150 字符)造:折行(word-break)会把布局成本一并
266
+ * 压进来。CHUNK_LONG = 50 行一块,给下面两个剖面共用。
267
+ */
268
+ const LONG = LINE + ' payload=' + 'x'.repeat(60) + '\\n'
269
+ const CHUNK_LONG = Array.from({ length: 50 }, (_, i) => LONG.slice(0, -1) + ' seq=' + String(i)).join('\\n') + '\\n'
270
+
271
+ /*
272
+ * 事件洪泛剖面对照(D151):**同一行速率**(≈6.2k 行/秒)下,每事件 1 行 vs
273
+ * 每事件 50 行。服务端把逐 chunk 的帧合成一个窗口推下来(见 src/index.ts 的
274
+ * createSseCoalescer)之后,客户端每秒要处理的 line 事件少 1~2 个数量级;
275
+ * 这一段量的就是「每事件固定开销」(SSE 派发 + JSON.parse + pushChunk 的字符串
276
+ * 拼接)在页面里值多少——它正是逐 chunk 一帧 SSE 的代价。
277
+ */
278
+ const profile = async (linesPerEvent, ms) => {
279
+ const perTick = Math.max(1, Math.round(100 / linesPerEvent))
280
+ const chunk = linesPerEvent === 1 ? LONG : CHUNK_LONG
281
+ let pMax = 0
282
+ let pLong50 = 0
283
+ let pSamples = 0
284
+ let pLast = performance.now()
285
+ const probeP = setInterval(() => {
286
+ const now = performance.now()
287
+ const lag = now - pLast - 16
288
+ pLast = now
289
+ pSamples += 1
290
+ if (lag > 50) pLong50 += 1
291
+ pMax = Math.max(pMax, lag)
292
+ }, 16)
293
+ const t0 = performance.now()
294
+ while (performance.now() - t0 < ms) {
295
+ for (let i = 0; i < perTick; i += 1) emit('line', { d: chunk })
296
+ await sleep(16)
297
+ }
298
+ clearInterval(probeP)
299
+ return { maxLag: Math.round(pMax), long50: pLong50, samples: pSamples }
300
+ }
301
+ out.perLineEvent = await profile(1, 2000)
302
+ out.perChunkEvent = await profile(50, 2000)
303
+
304
+ /*
305
+ * 持续洪泛(D151):缓冲已满之后的稳态。每 50ms 推 20×50=1000 行 ≈ 20k 行/秒,
306
+ * 持续 ${String(BUDGET.sustainedMs)} ms——每帧都在「淘汰最旧 + 插入最新」,这才是
307
+ * 用户报的「日志一快页面就卡」。指标:事件循环延迟剖面 + 长帧数。
308
+ */
309
+ let sMaxLag = 0
310
+ let sLong50 = 0
311
+ let sLong100 = 0
312
+ let sSamples = 0
313
+ let lastS = performance.now()
314
+ const probeS = setInterval(() => {
315
+ const now = performance.now()
316
+ const lag = now - lastS - 16
317
+ lastS = now
318
+ sSamples += 1
319
+ if (lag > 50) sLong50 += 1
320
+ if (lag > 100) sLong100 += 1
321
+ sMaxLag = Math.max(sMaxLag, lag)
322
+ }, 16)
323
+ const t2 = performance.now()
324
+ while (performance.now() - t2 < ${String(BUDGET.sustainedMs)}) {
325
+ for (let i = 0; i < 20; i += 1) emit('line', { d: CHUNK_LONG })
326
+ await sleep(50)
327
+ }
328
+ await sleep(600)
329
+ clearInterval(probeS)
330
+ out.sustainedMs = Math.round(performance.now() - t2)
331
+ out.sustainedMaxLagMs = Math.round(sMaxLag)
332
+ out.sustainedLongFrames = sLong100
333
+ out.sustainedLong50Frames = sLong50
334
+ out.sustainedSamples = sSamples
335
+ out.sustainedRows = totalRows()
336
+ out.sustainedDomRows = mountedRows()
337
+ // 跟随还生不生效:贴底时最后挂出来的一行必须是**最新那一行**(seq=49 是本轮最后一块的末行)
338
+ const tailRows = qa('.dk_logLine')
339
+ out.sustainedLastRow = textOf(tailRows[tailRows.length - 1])
340
+ out.sustainedNodes = q('.dk_logBody') ? q('.dk_logBody').querySelectorAll('*').length : -1
341
+ /*
342
+ * 回归(D152 修复 + 用户现场):主机侧积压的 banner 一弹、流一断重连,两件事都不许发生——
343
+ * 1. 跟随不能**悄悄停掉**(banner 改变面板高度会被误判成「用户上滚」);
344
+ * 2. 正文不能空白(窗口按状态里的旧滚动位置渲染,而浏览器的真实位置已经不匹配)。
345
+ * 「有没有行真的落在视口里」是这两条的统一判据。
346
+ */
347
+ const visibleRowsNow = () => {
348
+ const body = q('.dk_logBody')
349
+ if (!body) return -1
350
+ const rect = body.getBoundingClientRect()
351
+ return qa('.dk_logLine').filter((el) => {
352
+ const r = el.getBoundingClientRect()
353
+ return r.bottom > rect.top && r.top < rect.bottom
354
+ }).length
355
+ }
356
+ const lastRowTextNow = () => {
357
+ const rows = qa('.dk_logLine')
358
+ return rows.length === 0 ? '' : rows[rows.length - 1].textContent
359
+ }
360
+ emit('end', { reason: 'output-limit', code: null })
361
+ await sleep(250)
362
+ out.limitVisibleRows = visibleRowsNow()
363
+ out.limitBackToBottom = q('.dk_backToBottom') !== null
364
+ out.limitState = q('.dk_followState') ? q('.dk_followState').textContent.trim() : ''
365
+ await sleep(1600)
366
+ out.reconnectVisibleRows = visibleRowsNow()
367
+ out.reconnectBackToBottom = q('.dk_backToBottom') !== null
368
+ out.reconnectLastRow = lastRowTextNow()
369
+ out.reconnectSources = openedEventSources()
370
+
371
+ /*
372
+ * 「上滚暂停跟随」的两个方向(D152 加固):只有**真的有人滚**(1.5s 内有过手势)才算用户
373
+ * 上滚——洪泛里位置被夹紧/抖动不该停掉跟随。此刻流是静止的(我们不再 emit),所以判定确定。
374
+ */
375
+ const backButtonShown = () => q('.dk_backToBottom') !== null
376
+ const gestureBody = q('.dk_logBody')
377
+ // 正例:真手势 + 位置上移 → 必须出现「回到底部」
378
+ gestureBody.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }))
379
+ gestureBody.scrollTop = Math.max(0, gestureBody.scrollTop - 400)
380
+ await sleep(250)
381
+ out.gestureUnpinned = backButtonShown()
382
+ const backBtn = q('.dk_backToBottom')
383
+ if (backBtn !== null) backBtn.click()
384
+ await sleep(250)
385
+ out.gestureRepinned = backButtonShown() === false
386
+ // 反例:没有手势、只把位置移上去(模拟夹紧/抖动)→ 不许停跟随。
387
+ // 先等出手势窗口(1.5s)——否则上一步的 wheel 还「新鲜」,反例就不成立了
388
+ await sleep(1700)
389
+ gestureBody.scrollTop = Math.max(0, gestureBody.scrollTop - 400)
390
+ await sleep(250)
391
+ out.noGestureBackBtn = backButtonShown()
392
+ // 再来一批日志:必须贴回最新行(说明跟随确实还在)
393
+ for (let i = 0; i < 40; i += 1) emit('line', { d: CHUNK_LONG })
394
+ await sleep(600)
395
+ out.noGestureLastRow = lastRowTextNow()
396
+
397
+ /*
398
+ * 回归(D154):洪泛中**驻留历史**。跟随流没停,环形缓冲还在淘汰最旧的行——
399
+ * 可见区顶行**上方**的总高每帧都在变短,若不补偿,视口内容会持续上移,读历史
400
+ * 成了自动「倒带」。旧实现的锚点补偿以「本次渲染自己的 anchorOffset」为基准,
401
+ * 只补得到量高修正、补不到淘汰位移,恰恰漏掉这条最高频的路径。
402
+ * 判据:慢速洪泛(每 120ms 一行,1.6s ≈ 13 行 ≈ 260px 位移)下驻留 1.2s,
403
+ * 可见区顶行必须还是同一行、位置几乎不动。驻留点离底部 400px > 位移量,
404
+ * 不会被「贴近底部」判定误拉回贴底。
405
+ */
406
+ gestureBody.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true }))
407
+ gestureBody.scrollTop = Math.max(0, gestureBody.scrollTop - 400)
408
+ let parkN = 0
409
+ const parkFlood = setInterval(() => {
410
+ parkN += 1
411
+ emit('line', { d: 'park-' + String(parkN) + '\\n' })
412
+ }, 120)
413
+ await sleep(400)
414
+ const topVisibleRow = () => {
415
+ const body = q('.dk_logBody')
416
+ if (!body) return null
417
+ const rect = body.getBoundingClientRect()
418
+ for (const el of qa('.dk_logLine')) {
419
+ const r = el.getBoundingClientRect()
420
+ if (r.bottom > rect.top + 4) return { id: el.getAttribute('data-log-row'), top: Math.round(r.top) }
421
+ }
422
+ return null
423
+ }
424
+ const parked = topVisibleRow()
425
+ await sleep(1200)
426
+ const reparked = topVisibleRow()
427
+ clearInterval(parkFlood)
428
+ out.parkedRowId = parked === null ? '' : String(parked.id)
429
+ out.parkSameRow = parked !== null && reparked !== null && parked.id === reparked.id
430
+ out.parkTopDelta = parked !== null && reparked !== null ? Math.abs(reparked.top - parked.top) : -1
431
+
154
432
  out.droppedBanner = qa('.dk_banner').some((el) => el.textContent.indexOf('丢弃最早') >= 0)
155
433
  out.stillInteractive = qa('.dk_tab').length >= 3 && q('.dk_logBody') !== null
156
434
  return JSON.stringify(out)
@@ -184,8 +462,16 @@ if (result.error !== undefined) {
184
462
  process.exit(1)
185
463
  }
186
464
 
187
- console.log('[log-perf] 5000 行快照:' + String(result.snapshotMs) + 'ms / ' + String(result.snapshotRows) + ' 行 / ' + String(result.snapshotNodes) + ' 个 DOM 节点')
188
- console.log('[log-perf] FOLLOW 突发 20000 行:' + String(result.burstMs) + 'ms / 事件循环最大延迟 ' + String(result.burstMaxLagMs) + 'ms / 收敛后 ' + String(result.burstRows) + ' 行 / ' + String(result.burstNodes) + ' 节点')
465
+ console.log('[log-perf] 5000 行快照:' + String(result.snapshotMs) + 'ms / 共 ' + String(result.snapshotRows) + ' 行 / 挂 ' + String(result.snapshotDomRows) + ' 行 / ' + String(result.snapshotNodes) + ' 个 DOM 节点 / 视口 ' + String(result.snapshotViewH) + 'px / 滚动高 ' + String(result.snapshotScrollH) + 'px')
466
+ console.log('[log-perf] FOLLOW 突发 20000 行:' + String(result.burstMs) + 'ms / 事件循环最大延迟 ' + String(result.burstMaxLagMs) + 'ms / 收敛后 ' + String(result.burstRows) + ' 行 / 挂 ' + String(result.burstDomRows) + ' 行 / ' + String(result.burstNodes) + ' 节点')
467
+ console.log('[log-perf] 窗口化正确性:滚到顶第一行「' + String(result.topFirstRow).slice(0, 60) + '」/ 滚到底最后一行「' + String(result.bottomLastRow).slice(0, 60) + '」')
468
+ console.log('[log-perf] 事件洪泛 6.2k 行/秒:每事件 1 行 → 最大延迟 ' + String(result.perLineEvent.maxLag) + 'ms / 长帧 >50ms ' + String(result.perLineEvent.long50) + ' 个;每事件 50 行 → 最大延迟 ' + String(result.perChunkEvent.maxLag) + 'ms / 长帧 >50ms ' + String(result.perChunkEvent.long50) + ' 个')
469
+ console.log('[log-perf] 持续洪泛 20k 行/秒 × ' + String(BUDGET.sustainedMs) + 'ms:最大延迟 ' + String(result.sustainedMaxLagMs) + 'ms / 长帧 >50ms ' + String(result.sustainedLong50Frames) + ' 个 / >100ms ' + String(result.sustainedLongFrames) + ' 个 / ' + String(result.sustainedSamples) + ' 次采样 / ' + String(result.sustainedRows) + ' 行 / 挂 ' + String(result.sustainedDomRows) + ' 行 / ' + String(result.sustainedNodes) + ' 节点')
470
+ console.log('[log-perf] 字节闸可见性:快照 truncated=true 时出现「已截断」横幅 = ' + String(result.truncatedBanner))
471
+ console.log('[log-perf] 上滚判定:真手势上滚→出现回到底部 ' + String(result.gestureUnpinned) + ' / 点击后恢复贴底 ' + String(result.gestureRepinned) + ' / 无手势位移→回到底部 ' + String(result.noGestureBackBtn) + ' / 之后末行是最新 seq=49:' + String(String(result.noGestureLastRow).indexOf('seq=49') >= 0))
472
+ console.log('[log-perf] 积压/重连回归:积压后可见行 ' + String(result.limitVisibleRows) + ' / 回到底部按钮 ' + String(result.limitBackToBottom) + ' / 状态「' + String(result.limitState) + '」 → 重连后可见行 ' + String(result.reconnectVisibleRows) + ' / 回到底部按钮 ' + String(result.reconnectBackToBottom) + ' / 末行含最新 seq=49:' + String(String(result.reconnectLastRow).indexOf('seq=49') >= 0) + ' / 事件源 ' + String(result.reconnectSources) + ' 个')
473
+ console.log('[log-perf] 驻留历史回归:洪泛中驻留 1.2s → 同一行 ' + String(result.parkSameRow) + ' / 顶行位移 ' + String(result.parkTopDelta) + 'px(锚点 id=' + String(result.parkedRowId) + ')')
474
+ console.log('[log-perf] 换代回归(快照刷新):刷新回普通行后滚动高 ' + String(result.refreshGeometryH) + 'px(上限 ' + String(BUDGET.snapshotRows * 23) + 'px = 5000 行 × 20px 估算 + 余量)/ 行数 ' + String(result.refreshTotal))
189
475
  console.log('[log-perf] 丢弃横幅 ' + String(result.droppedBanner) + ' / 仍可交互 ' + String(result.stillInteractive))
190
476
 
191
477
  const failures = []
@@ -193,8 +479,39 @@ if (result.snapshotMs > BUDGET.snapshotMs) failures.push('快照渲染 ' + Strin
193
479
  if (result.snapshotRows < BUDGET.snapshotRows) failures.push('快照只渲染了 ' + String(result.snapshotRows) + ' 行(应 ≥ ' + String(BUDGET.snapshotRows) + ')')
194
480
  if (result.burstMs > BUDGET.burstMs) failures.push('突发处理 ' + String(result.burstMs) + 'ms 超出预算 ' + String(BUDGET.burstMs) + 'ms')
195
481
  if (result.burstMaxLagMs > BUDGET.burstMaxLagMs) failures.push('事件循环最大延迟 ' + String(result.burstMaxLagMs) + 'ms 超出预算 ' + String(BUDGET.burstMaxLagMs) + 'ms')
482
+ if (result.sustainedMaxLagMs > BUDGET.sustainedMaxLagMs) failures.push('持续洪泛最大延迟 ' + String(result.sustainedMaxLagMs) + 'ms 超出预算 ' + String(BUDGET.sustainedMaxLagMs) + 'ms')
483
+ if (result.sustainedLongFrames > BUDGET.sustainedLongFrames) failures.push('持续洪泛出现了 ' + String(result.sustainedLongFrames) + ' 个 >100ms 的长帧(上限 ' + String(BUDGET.sustainedLongFrames) + ')')
484
+ if (result.sustainedRows > BUDGET.snapshotRows) failures.push('洪泛后行数 ' + String(result.sustainedRows) + ' 超过缓冲上限 ' + String(BUDGET.snapshotRows))
485
+ if (result.perLineEvent.maxLag > BUDGET.perLineEventMaxLagMs) failures.push('每事件 1 行的最大延迟 ' + String(result.perLineEvent.maxLag) + 'ms 超出预算 ' + String(BUDGET.perLineEventMaxLagMs) + 'ms')
486
+ // 窗口化(D152):挂载行数有上限(虚拟滚动还生不生效)+ 窗口算错会立刻体现在内容上
487
+ for (const [name, value] of [['快照', result.snapshotDomRows], ['突发', result.burstDomRows], ['洪泛', result.sustainedDomRows]]) {
488
+ if (value > BUDGET.mountedRows) failures.push(name + '阶段挂载了 ' + String(value) + ' 行,超出窗口上限 ' + String(BUDGET.mountedRows) + '(虚拟滚动没生效?)')
489
+ }
490
+ if (String(result.topFirstRow).indexOf('seq=0') === -1) failures.push('滚到顶部第一行不是 seq=0:窗口算错了(拿到「' + String(result.topFirstRow).slice(0, 80) + '」)')
491
+ if (String(result.bottomLastRow).indexOf('seq=4999') === -1) failures.push('滚到底部最后一行不是 seq=4999:窗口算错了(拿到「' + String(result.bottomLastRow).slice(0, 80) + '」)')
492
+ // 贴底跟随:洪泛结束后最后挂出来的一行必须是最新行(否则「跟随」只剩状态行在说)
493
+ if (String(result.sustainedLastRow).indexOf('seq=49') === -1) failures.push('洪泛结束后最后一行不是最新行:贴底跟随失效(拿到「' + String(result.sustainedLastRow).slice(0, 80) + '」)')
494
+ // —— D152 修复的回归(用户现场:积压 banner 一弹就「跟随停掉 + 正文空白」)——
495
+ if (result.limitVisibleRows <= 0) failures.push('主机侧积压之后正文空白:视口里一行都没有(窗口与真实滚动位置失配)')
496
+ if (result.limitBackToBottom !== false) failures.push('主机侧积压之后跟随被悄悄停掉(出现了「回到底部」按钮)——banner 改高度不该被当成用户上滚')
497
+ if (result.reconnectVisibleRows <= 0) failures.push('重连之后正文空白:视口里一行都没有')
498
+ if (String(result.reconnectLastRow).indexOf('seq=49') === -1) failures.push('重连之后没有贴回最新行:跟随没恢复(拿到「' + String(result.reconnectLastRow).slice(0, 80) + '」)')
499
+ if (result.reconnectSources < 2) failures.push('积压之后没有自动重连(事件源只有 ' + String(result.reconnectSources) + ' 个)')
500
+ // 字节闸一生效就必须有黄色横幅(否则「选 1000 行只回来 985 行」会被误解成丢行)
501
+ if (result.truncatedBanner !== true) failures.push('快照被「输出上限(KB)」截断时没有给出「已截断」横幅(拿到 ' + String(result.truncatedBanner) + ')')
502
+ // 上滚判定只在真手势下成立:真手势必须停跟随,无手势的位移绝不许停(D152 加固)
503
+ if (result.gestureUnpinned !== true) failures.push('真手势上滚之后没有暂停跟随(D61 的语义被改坏了)')
504
+ if (result.gestureRepinned !== true) failures.push('点「回到底部」之后没有恢复贴底')
505
+ if (result.noGestureBackBtn !== false) failures.push('没有手势时位置上移被误判成「用户上滚」→ 跟随停掉、冒出「回到底部」')
506
+ if (String(result.noGestureLastRow).indexOf('seq=49') === -1) failures.push('无手势位移之后没有贴回最新行(跟随没恢复):拿到「' + String(result.noGestureLastRow).slice(0, 80) + '」')
507
+ // —— D154 修复的回归:洪泛中驻留历史,缓冲持续淘汰不许把内容「倒带」——
508
+ if (result.parkSameRow !== true) failures.push('洪泛中驻留历史 1.2s 后可见区顶行变了(环形缓冲淘汰的位移没补偿,读历史在自动上飘):锚点 id=' + String(result.parkedRowId) + ' / 位移 ' + String(result.parkTopDelta) + 'px')
509
+ if (result.parkTopDelta < 0 || result.parkTopDelta > 24) failures.push('洪泛中驻留历史时顶行位移 ' + String(result.parkTopDelta) + 'px 超出容差 24px')
510
+ // —— D155 修复的回归:快照刷新换代,不许把滚动位置推走 ——
511
+ if (result.refreshTotal !== BUDGET.snapshotRows) failures.push('换代回归:刷新后行数 ' + String(result.refreshTotal) + ' ≠ ' + String(BUDGET.snapshotRows))
512
+ if (result.refreshGeometryH < 0 || result.refreshGeometryH > BUDGET.snapshotRows * 23) failures.push('换代后滚动高 ' + String(result.refreshGeometryH) + 'px 虚高(上限 ' + String(BUDGET.snapshotRows * 23) + 'px):上一代的实测高度还留在垫高里——快照 id 是位置寻址,刷新后 id 已是另一行(D155)')
196
513
  if (result.burstRows > BUDGET.snapshotRows) failures.push('突发后行数 ' + String(result.burstRows) + ' 超过缓冲上限 ' + String(BUDGET.snapshotRows))
197
- if (result.snapshotNodes > BUDGET.maxNodes || result.burstNodes > BUDGET.maxNodes) failures.push('DOM 节点数超出预算 ' + String(BUDGET.maxNodes))
514
+ if (result.snapshotNodes > BUDGET.maxNodes || result.burstNodes > BUDGET.maxNodes || result.sustainedNodes > BUDGET.maxNodes) failures.push('DOM 节点数超出预算 ' + String(BUDGET.maxNodes))
198
515
  if (result.droppedBanner !== true) failures.push('突发撑爆缓冲后没有「已丢弃最早内容」横幅')
199
516
  if (result.stillInteractive !== true) failures.push('突发后界面失去可交互性')
200
517