@mzzsfy/dsh-bug-perf 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,182 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+
7
+ // 客户端半区以模块加载器自注册,测试用最小 window 捕获 factory 后取 __test 纯逻辑
8
+ function loadPlugin({ modules = {} } = {}) {
9
+ let registered
10
+ const window = { __ModuleLoader__: { load: (entry) => { registered = entry } } }
11
+ new Function('window', source)(window)
12
+ const requireImpl = (id) => {
13
+ if (Object.hasOwn(modules, id)) return modules[id]
14
+ throw new Error(`module not registered: ${id}`)
15
+ }
16
+ return registered.factory(requireImpl)
17
+ }
18
+
19
+ function fakeTimers() {
20
+ let now = 0
21
+ let nextId = 0
22
+ const pending = new Map()
23
+ return {
24
+ now: () => now,
25
+ setTimeout: (fn, ms) => { const id = ++nextId; pending.set(id, { fn, at: now + ms }); return id },
26
+ clearTimeout: (id) => { pending.delete(id) },
27
+ advance(ms) {
28
+ now += ms
29
+ for (const [id, timer] of [...pending]) {
30
+ if (timer.at <= now) {
31
+ pending.delete(id)
32
+ timer.fn()
33
+ }
34
+ }
35
+ },
36
+ pendingCount: () => pending.size,
37
+ }
38
+ }
39
+
40
+ function installCoalesce(plugin, { timers, windowMs = 100 } = {}) {
41
+ class RemoteStream {}
42
+ RemoteStream.prototype.restart = function restart() { this.restarts = (this.restarts ?? 0) + 1 }
43
+ const result = plugin.__test.installRestartCoalesce({ RemoteStream, windowMs, timers })
44
+ return { result, RemoteStream }
45
+ }
46
+
47
+ // Given 同一实例在合并窗口内连续重启, When 时间推进到窗口结束, Then 只实际重开一次
48
+ test('重启合并_窗口内连发_尾随一次', () => {
49
+ const plugin = loadPlugin()
50
+ const timers = fakeTimers()
51
+ const { result, RemoteStream } = installCoalesce(plugin, { timers })
52
+ assert.equal(result.installed, true)
53
+ const stream = new RemoteStream()
54
+ for (let i = 0; i < 5; i++) {
55
+ stream.restart()
56
+ timers.advance(10)
57
+ }
58
+ assert.equal(stream.restarts, 1)
59
+ timers.advance(100)
60
+ assert.equal(stream.restarts, 2)
61
+ assert.equal(timers.pendingCount(), 0)
62
+ })
63
+
64
+ // Given 两次重启间隔超过窗口, When 依次调用, Then 各自立即生效, 不被合并
65
+ test('重启合并_窗口外_逐次生效', () => {
66
+ const plugin = loadPlugin()
67
+ const timers = fakeTimers()
68
+ const { RemoteStream } = installCoalesce(plugin, { timers })
69
+ const stream = new RemoteStream()
70
+ for (let i = 0; i < 3; i++) {
71
+ stream.restart()
72
+ timers.advance(150)
73
+ }
74
+ assert.equal(stream.restarts, 3)
75
+ })
76
+
77
+ // Given 两个实例各自连发, When 推进时间, Then 合并状态按实例隔离, 不互相吞掉
78
+ test('重启合并_实例隔离_各自一次', () => {
79
+ const plugin = loadPlugin()
80
+ const timers = fakeTimers()
81
+ const { RemoteStream } = installCoalesce(plugin, { timers })
82
+ const a = new RemoteStream()
83
+ const b = new RemoteStream()
84
+ a.restart(); b.restart()
85
+ timers.advance(10)
86
+ a.restart(); b.restart()
87
+ timers.advance(120)
88
+ assert.equal(a.restarts, 2)
89
+ assert.equal(b.restarts, 2)
90
+ })
91
+
92
+ // Given 原型已被本插件包装, When 再次安装, Then 幂等返回未安装且不叠加包装
93
+ test('重启合并_重复安装_幂等', () => {
94
+ const plugin = loadPlugin()
95
+ const { result, RemoteStream } = installCoalesce(plugin, { timers: fakeTimers() })
96
+ assert.equal(result.installed, true)
97
+ const again = plugin.__test.installRestartCoalesce({ RemoteStream, windowMs: 100, timers: fakeTimers() })
98
+ assert.equal(again.installed, false)
99
+ const stream = new RemoteStream()
100
+ stream.restart()
101
+ assert.equal(stream.restarts, 1)
102
+ })
103
+
104
+ // Given 宿主版本没有 RemoteStream.restart, When 安装, Then 跳过且不抛错(跨版本宿主不受影响)
105
+ test('重启合并_结构缺失_跳过不抛', () => {
106
+ const plugin = loadPlugin()
107
+ const missing = plugin.__test.installRestartCoalesce({ RemoteStream: undefined, windowMs: 100, timers: fakeTimers() })
108
+ assert.equal(missing.installed, false)
109
+ const noRestart = plugin.__test.installRestartCoalesce({ RemoteStream: class {}, windowMs: 100, timers: fakeTimers() })
110
+ assert.equal(noRestart.installed, false)
111
+ })
112
+
113
+ // Given 尾随时间触发时实例已释放并抛错, When 定时器到期, Then 错误被吞掉不影响页面
114
+ test('重启合并_释放后触发_不抛', () => {
115
+ const plugin = loadPlugin()
116
+ const timers = fakeTimers()
117
+ const { RemoteStream } = installCoalesce(plugin, { timers })
118
+ const stream = new RemoteStream()
119
+ stream.restart()
120
+ Object.defineProperty(stream, 'restarts', { get() { throw new Error('already disposed') }, configurable: true })
121
+ stream.restart()
122
+ assert.doesNotThrow(() => timers.advance(120))
123
+ })
124
+
125
+ // Given 客户端内联默认配置, When 与宿主共享配置对表, Then 两边默认值一致(防漂移)
126
+ test('配置_客户端内联_与宿主同源', async () => {
127
+ const plugin = loadPlugin()
128
+ const host = await import('../src/config.mjs')
129
+ assert.deepEqual(plugin.__test.DEFAULT_CONFIG, host.DEFAULT_CONFIG)
130
+ assert.deepEqual(plugin.__test.LIMITS, host.LIMITS)
131
+ assert.deepEqual(plugin.__test.normalizeConfig({}), host.normalizeConfig({}))
132
+ const raw = { diag: { enabled: true, reportEvery: 0 } }
133
+ assert.deepEqual(plugin.__test.normalizeConfig(raw), host.normalizeConfig(raw))
134
+ assert.deepEqual(
135
+ plugin.__test.normalizeConfig({ diag: { reportEvery: 99999 } }),
136
+ host.normalizeConfig({ diag: { reportEvery: 99999 } }),
137
+ )
138
+ })
139
+
140
+ // Given 远程配置缺失, When 客户端归一化, Then 落在与宿主一致的默认值上
141
+ test('配置_客户端缺失_默认值', () => {
142
+ const plugin = loadPlugin()
143
+ const config = plugin.__test.normalizeConfig(undefined)
144
+ assert.equal(config.restartCoalesce.enabled, true)
145
+ assert.equal(config.terminalWatchdog.enabled, true)
146
+ })
147
+
148
+ // Given 宿主模块表没有 gateway 客户端, When 安装合并, Then 记录原因而不是让插件装载失败
149
+ test('重启合并_缺模块_记录原因', () => {
150
+ const plugin = loadPlugin()
151
+ const result = plugin.__test.installRestartCoalesceFromRequire({
152
+ requireImpl: () => { throw new Error('module not registered') },
153
+ windowMs: 100,
154
+ timers: fakeTimers(),
155
+ })
156
+ assert.equal(result.installed, false)
157
+ assert.match(result.reason, /module not registered/)
158
+ })
159
+
160
+ // Given 模块存在, When 经 require 安装, Then 走包装且记录 installed
161
+ test('重启合并_经模块表_安装成功', () => {
162
+ const plugin = loadPlugin()
163
+ class RemoteStream {}
164
+ RemoteStream.prototype.restart = function restart() { this.restarts = (this.restarts ?? 0) + 1 }
165
+ const result = plugin.__test.installRestartCoalesceFromRequire({
166
+ requireImpl: (id) => ({ RemoteStream }),
167
+ windowMs: 100,
168
+ timers: fakeTimers(),
169
+ })
170
+ assert.equal(result.installed, true)
171
+ })
172
+
173
+ // Given 合并窗口为 0, When 同一 tick 内连发, Then 每次都立即执行(等价关闭合并但保留开关语义)
174
+ test('重启合并_窗口为零_不合并', () => {
175
+ const plugin = loadPlugin()
176
+ const timers = fakeTimers()
177
+ const { RemoteStream } = installCoalesce(plugin, { timers, windowMs: 0 })
178
+ const stream = new RemoteStream()
179
+ stream.restart()
180
+ stream.restart()
181
+ assert.equal(stream.restarts, 2)
182
+ })
@@ -0,0 +1,76 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+
7
+ function loadPlugin({ modules = {} } = {}) {
8
+ let registered
9
+ const window = { __ModuleLoader__: { load: (entry) => { registered = entry } } }
10
+ new Function('window', source)(window)
11
+ const requireImpl = (id) => {
12
+ if (Object.hasOwn(modules, id)) return modules[id]
13
+ throw new Error(`module not registered: ${id}`)
14
+ }
15
+ return registered.factory(requireImpl)
16
+ }
17
+
18
+ const reactStub = {
19
+ createElement: (type, props, ...children) => ({ type, props, children }),
20
+ useState: () => [undefined, () => {}],
21
+ useEffect: () => {},
22
+ }
23
+
24
+ // Given React 与 slots 就绪, When 注册设置 UI, Then 两个官方插槽都登记且 key 正确
25
+ test('设置UI_注册官方插槽', () => {
26
+ const plugin = loadPlugin({ modules: { react: reactStub } })
27
+ const registrations = []
28
+ const injected = []
29
+ const styleEffects = []
30
+ const scope = {
31
+ slots: {
32
+ inject: (name, callback) => { injected.push(name); callback() },
33
+ register: (options, component) => registrations.push({ options, component }),
34
+ },
35
+ effect: (factory) => { styleEffects.push(factory) },
36
+ }
37
+ const ctx = { inject: (services, callback) => { if (services.includes('slots')) callback(scope) } }
38
+ plugin.__test.registerSettingsUi(ctx)
39
+ assert.deepEqual(injected.sort(), ['plugins.bundle.config', 'settings.plugin.item'])
40
+ const byName = Object.fromEntries(registrations.map((entry) => [entry.options.name, entry.options]))
41
+ assert.equal(byName['settings.plugin.item'].key, 'bug-perf')
42
+ assert.equal(byName['plugins.bundle.config'].key, '@mzzsfy/dsh-bug-perf')
43
+ assert.equal(styleEffects.length, 1)
44
+ })
45
+
46
+ // Given 无 React 或 slots, When 注册, Then 静默跳过,不影响其它补丁
47
+ test('设置UI_缺依赖_静默跳过', () => {
48
+ const noReact = loadPlugin()
49
+ let called = false
50
+ noReact.__test.registerSettingsUi({ inject: () => { called = true } })
51
+ assert.equal(called, false)
52
+ const plugin = loadPlugin({ modules: { react: reactStub } })
53
+ assert.doesNotThrow(() => plugin.__test.registerSettingsUi({}))
54
+ })
55
+
56
+ // Given 设置节定义, When 检查字段, Then 各节与共享配置的键一致
57
+ test('设置UI_节字段与配置同源', () => {
58
+ const plugin = loadPlugin({ modules: { react: reactStub } })
59
+ const keys = plugin.__test.UI_SECTIONS.map((section) => section.key)
60
+ assert.deepEqual(keys, Object.keys(plugin.__test.DEFAULT_CONFIG))
61
+ for (const section of plugin.__test.UI_SECTIONS) {
62
+ for (const field of section.fields) {
63
+ assert.equal(typeof plugin.__test.DEFAULT_CONFIG[section.key][field.name], 'number')
64
+ }
65
+ }
66
+ })
67
+
68
+ // Given 客户端注入设置样式, When 检查注入契约, Then data-plugin 与样式创建点数一致且开关选择器锚定 checkbox
69
+ test('设置UI_样式注入契约', () => {
70
+ const creates = (source.match(/createElement\('style'\)/g) ?? []).length
71
+ const markers = (source.match(/style\.setAttribute\('data-plugin'/g) ?? []).length
72
+ assert.equal(creates, 2)
73
+ assert.equal(markers, creates)
74
+ assert.match(source, /input\[type="checkbox"\]:checked \+ \.bp-switch__track/)
75
+ assert.match(source, /input\[type="checkbox"\]:focus-visible \+ \.bp-switch__track/)
76
+ })
@@ -0,0 +1,350 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+
5
+ const source = readFileSync(new URL('../src/client.js', import.meta.url), 'utf8')
6
+
7
+ function loadPlugin() {
8
+ let registered
9
+ const window = { __ModuleLoader__: { load: (entry) => { registered = entry } } }
10
+ new Function('window', source)(window)
11
+ return registered.factory(() => { throw new Error('no modules') })
12
+ }
13
+
14
+ function fakeClock() {
15
+ let now = 0
16
+ let nextId = 0
17
+ const intervals = new Map()
18
+ const fireDue = () => {
19
+ for (const [id, timer] of [...intervals]) {
20
+ if (timer.at <= now) {
21
+ timer.at = now + timer.every
22
+ timer.fn()
23
+ }
24
+ }
25
+ }
26
+ return {
27
+ now: () => now,
28
+ setInterval: (fn, ms) => { const id = ++nextId; intervals.set(id, { fn, every: ms, at: now + ms }); return id },
29
+ clearInterval: (id) => { intervals.delete(id) },
30
+ advance(ms) {
31
+ const target = now + ms
32
+ while (true) {
33
+ const due = [...intervals.values()].map((timer) => timer.at).filter((at) => at <= target)
34
+ if (due.length === 0) break
35
+ now = Math.min(...due)
36
+ fireDue()
37
+ }
38
+ now = target
39
+ },
40
+ intervalCount: () => intervals.size,
41
+ }
42
+ }
43
+
44
+ function fakeNode(tag) {
45
+ const node = {
46
+ tag,
47
+ attrs: {},
48
+ children: [],
49
+ listeners: {},
50
+ textContent: '',
51
+ className: '',
52
+ type: '',
53
+ style: {},
54
+ setAttribute(key, value) { node.attrs[key] = String(value) },
55
+ appendChild(child) { node.children.push(child); child.parent = node; return child },
56
+ remove() { const parent = node.parent; if (parent !== undefined) parent.children = parent.children.filter((item) => item !== node) },
57
+ addEventListener(type, fn) { (node.listeners[type] ??= []).push(fn) },
58
+ click() { for (const fn of node.listeners.click ?? []) fn({}) },
59
+ get childElementCount() { return node.children.length },
60
+ hasAttribute(name) { return node.attrs[name] !== undefined },
61
+ getAttribute(name) { return node.attrs[name] ?? null },
62
+ closest(selector) {
63
+ const attribute = selector.startsWith('[') && selector.endsWith(']') ? selector.slice(1, -1) : null
64
+ if (attribute === null) return null
65
+ let current = node
66
+ while (current !== undefined) {
67
+ if (current.attrs?.[attribute] !== undefined) return current
68
+ current = current.parent
69
+ }
70
+ return null
71
+ },
72
+ querySelectorAll(selector) {
73
+ const attribute = selector.startsWith('[') && selector.endsWith(']') ? selector.slice(1, -1) : null
74
+ const found = []
75
+ const walk = (current) => {
76
+ for (const child of current.children ?? []) {
77
+ if (attribute !== null && child.attrs?.[attribute] !== undefined) found.push(child)
78
+ walk(child)
79
+ }
80
+ }
81
+ walk(node)
82
+ return found
83
+ },
84
+ querySelector(selector) {
85
+ if (!selector.startsWith('[') || !selector.endsWith(']')) return null
86
+ const attribute = selector.slice(1, -1)
87
+ const walk = (current) => {
88
+ for (const child of current.children ?? []) {
89
+ if (child.attrs?.[attribute] !== undefined) return child
90
+ const found = walk(child)
91
+ if (found !== null) return found
92
+ }
93
+ return null
94
+ }
95
+ return walk(node)
96
+ },
97
+ allText() { return node.textContent + node.children.map((child) => child.allText()).join('') },
98
+ }
99
+ return node
100
+ }
101
+
102
+ const HINT_TEXT = '载入历史…'
103
+
104
+ function fakePage({ loading = true, hasFlow = true, messages = false } = {}) {
105
+ const head = fakeNode('head')
106
+ const body = fakeNode('body')
107
+ const flow = fakeNode('div')
108
+ const hint = fakeNode('div')
109
+ hint.textContent = HINT_TEXT
110
+ const message = fakeNode('div')
111
+ message.attrs['data-chat-flow-key'] = 'turn-1'
112
+ message.textContent = '为什么一直显示载入历史…'
113
+ const setFlow = (value) => { flow.children = value ? [hint] : (messages ? [message] : []) }
114
+ setFlow(loading)
115
+ const doc = {
116
+ head,
117
+ body,
118
+ querySelector(selector) {
119
+ if (selector === '[data-chat-flow]') return hasFlow ? flow : null
120
+ if (selector.startsWith('style[data-plugin=')) {
121
+ const plugin = selector.match(/data-plugin="([^"]+)"/)?.[1]
122
+ return head.children.find((node) => node.tag === 'style' && node.attrs['data-plugin'] === plugin) ?? null
123
+ }
124
+ return null
125
+ },
126
+ createElement(tag) { return fakeNode(tag) },
127
+ getElementById(id) { return [...body.children, ...head.children].find((node) => node.attrs.id === id) ?? null },
128
+ }
129
+ const reloads = { count: 0 }
130
+ const win = {
131
+ document: doc,
132
+ location: { reload() { reloads.count += 1 } },
133
+ }
134
+ return { doc, win, flow, hint, reloads, setLoading: (value) => setFlow(value) }
135
+ }
136
+
137
+ const OPTIONS = { checkMs: 2000, stallMs: 8000, reconnect: true, maxReconnectAttempts: 3, reconnectIntervalMs: 4000 }
138
+
139
+ function fakeConnection() {
140
+ const calls = []
141
+ return {
142
+ calls,
143
+ reconnect() { calls.push(true) },
144
+ }
145
+ }
146
+
147
+ function install({ options = {}, page = fakePage(), connection = fakeConnection() } = {}) {
148
+ const plugin = loadPlugin()
149
+ const clock = fakeClock()
150
+ const result = plugin.__test.installStuckSession({ win: page.win, options: { ...OPTIONS, ...options }, clock, connection })
151
+ return { plugin, clock, result, page, connection }
152
+ }
153
+
154
+ const noticeOf = (page) => page.doc.getElementById('dsh-bug-perf-stuck')
155
+
156
+ // Given 会话停在加载态, When 未到停滞阈值, Then 不请求重连
157
+ test('停滞_未到阈值_不重连', () => {
158
+ const { clock, connection } = install()
159
+ clock.advance(2000 * 3)
160
+ assert.equal(connection.calls.length, 0)
161
+ })
162
+
163
+ // Given 会话停滞超过阈值, When 轮询, Then 请求宿主重建连接一次
164
+ test('停滞_超过阈值_请求重连', () => {
165
+ const { clock, connection } = install()
166
+ clock.advance(2000 * 5)
167
+ assert.equal(connection.calls.length, 1)
168
+ })
169
+
170
+ // Given 刚请求过重连, When 仍在重连间隔内, Then 不重复请求
171
+ test('停滞_重连间隔内_不重复', () => {
172
+ const { clock, connection } = install()
173
+ clock.advance(2000 * 5)
174
+ assert.equal(connection.calls.length, 1)
175
+ clock.advance(2000)
176
+ assert.equal(connection.calls.length, 1)
177
+ clock.advance(2000 * 2)
178
+ assert.equal(connection.calls.length, 2)
179
+ })
180
+
181
+ // Given 多次重连仍未恢复, When 用尽重连次数, Then 停止重连并显示提示
182
+ test('停滞_用尽次数_显示提示', () => {
183
+ const { clock, page, connection } = install()
184
+ clock.advance(2000 * 40)
185
+ assert.equal(connection.calls.length, 3)
186
+ assert.ok(noticeOf(page) !== null)
187
+ })
188
+
189
+ // Given 提示已出现, When 会话恢复, Then 提示移除且重连计数清零
190
+ test('停滞_恢复_重置并隐藏', () => {
191
+ const { clock, page, connection } = install()
192
+ clock.advance(2000 * 40)
193
+ assert.ok(noticeOf(page) !== null)
194
+ page.setLoading(false)
195
+ clock.advance(2000)
196
+ assert.equal(noticeOf(page), null)
197
+ page.setLoading(true)
198
+ clock.advance(2000 * 5)
199
+ assert.equal(connection.calls.length, 4)
200
+ })
201
+
202
+ // Given 页面正常但主线程被长时间占满, When 阻塞持续超过阈值, Then 同样请求重连
203
+ test('停滞_主线程阻塞_请求重连', () => {
204
+ const page = fakePage({ loading: false, messages: true })
205
+ const plugin = loadPlugin()
206
+ const clock = fakeClock()
207
+ const connection = fakeConnection()
208
+ const diag = {}
209
+ plugin.__test.installStuckSession({ win: page.win, options: OPTIONS, clock, connection, diag })
210
+ clock.advance(2000)
211
+ diag.blockedAt = clock.now()
212
+ clock.advance(2000 * 5)
213
+ assert.equal(connection.calls.length, 1)
214
+ })
215
+
216
+ // Given 没有 connection 服务, When 会话停滞, Then 不重连,直接显示提示
217
+ test('停滞_无连接服务_直接提示', () => {
218
+ const page = fakePage()
219
+ const plugin = loadPlugin()
220
+ const clock = fakeClock()
221
+ plugin.__test.installStuckSession({ win: page.win, options: OPTIONS, clock, connection: undefined })
222
+ clock.advance(2000 * 6)
223
+ assert.ok(noticeOf(page) !== null)
224
+ })
225
+
226
+ // Given 宿主拒绝重连调用, When 会话停滞, Then 不抛出,退化为提示
227
+ test('停滞_重连抛错_退化为提示', () => {
228
+ const page = fakePage()
229
+ const plugin = loadPlugin()
230
+ const clock = fakeClock()
231
+ const connection = { reconnect() { throw new Error('boom') } }
232
+ plugin.__test.installStuckSession({ win: page.win, options: OPTIONS, clock, connection })
233
+ clock.advance(2000 * 6)
234
+ assert.ok(noticeOf(page) !== null)
235
+ })
236
+
237
+ // Given 配置关闭主动重连, When 会话停滞, Then 不调用 reconnect,只显示提示
238
+ test('停滞_关闭重连_只提示', () => {
239
+ const { clock, page, connection } = install({ options: { reconnect: false } })
240
+ clock.advance(2000 * 40)
241
+ assert.equal(connection.calls.length, 0)
242
+ assert.ok(noticeOf(page) !== null)
243
+ })
244
+
245
+ // Given 普通会话已有消息节点, When 正文里出现加载文案的字样, Then 不判定停滞
246
+ test('停滞_正文含文案_不触发', () => {
247
+ const page = fakePage({ loading: false, messages: true })
248
+ const { clock, connection } = install({ page })
249
+ clock.advance(2000 * 60)
250
+ assert.equal(connection.calls.length, 0)
251
+ assert.equal(noticeOf(page), null)
252
+ })
253
+
254
+ // Given 加载态里残留的只是分组行, When 持续超过阈值, Then 仍判定停滞(分组行不算有效消息)
255
+ test('停滞_仅分组行_仍触发', () => {
256
+ const page = fakePage()
257
+ const group = fakeNode('div')
258
+ group.attrs['data-chat-flow-key'] = 'group-1'
259
+ group.attrs['data-chat-group-key'] = 'group-1'
260
+ group.textContent = '第 1 轮'
261
+ page.flow.appendChild(group)
262
+ const { clock, connection } = install({ page })
263
+ clock.advance(2000 * 5)
264
+ assert.equal(connection.calls.length, 1)
265
+ })
266
+
267
+ // Given 加载态里残留的是隐藏行或空行, When 持续超过阈值, Then 仍判定停滞
268
+ test('停滞_隐藏或空行_仍触发', () => {
269
+ const page = fakePage()
270
+ const hidden = fakeNode('div')
271
+ hidden.attrs['data-chat-flow-key'] = 'turn-hidden'
272
+ hidden.attrs.hidden = 'hidden'
273
+ hidden.textContent = '旧消息'
274
+ const empty = fakeNode('div')
275
+ empty.attrs['data-chat-flow-key'] = 'turn-empty'
276
+ page.flow.appendChild(hidden)
277
+ page.flow.appendChild(empty)
278
+ const { clock, connection } = install({ page })
279
+ clock.advance(2000 * 5)
280
+ assert.equal(connection.calls.length, 1)
281
+ })
282
+
283
+ // Given 残留行在隐藏容器里, When 持续超过阈值, Then 仍判定停滞(隐藏子树不算有效消息)
284
+ test('停滞_隐藏祖先内的行_仍触发', () => {
285
+ const page = fakePage()
286
+ const wrapper = fakeNode('div')
287
+ wrapper.attrs.hidden = 'hidden'
288
+ const row = fakeNode('div')
289
+ row.attrs['data-chat-flow-key'] = 'turn-collapsed'
290
+ row.textContent = '折叠起来的旧消息'
291
+ wrapper.appendChild(row)
292
+ page.flow.appendChild(wrapper)
293
+ const { clock, connection } = install({ page })
294
+ clock.advance(2000 * 5)
295
+ assert.equal(connection.calls.length, 1)
296
+ })
297
+
298
+ // Given 非会话视图(没有 chat 容器), When 长时间无加载文案, Then 不插入提示、不重连
299
+ test('停滞_无会话视图_不触发', () => {
300
+ const page = fakePage({ hasFlow: false })
301
+ const { clock, connection } = install({ page })
302
+ clock.advance(2000 * 60)
303
+ assert.equal(noticeOf(page), null)
304
+ assert.equal(connection.calls.length, 0)
305
+ })
306
+
307
+ // Given 补丁已安装, When 再次安装(HMR 重放), Then 拒绝重复安装,不叠加定时器
308
+ test('停滞_重复安装_幂等', () => {
309
+ const page = fakePage()
310
+ const { plugin, clock } = install({ page })
311
+ const second = plugin.__test.installStuckSession({ win: page.win, options: OPTIONS, clock, connection: fakeConnection() })
312
+ assert.equal(second.installed, false)
313
+ assert.equal(second.reason, 'already installed')
314
+ clock.advance(2000 * 5)
315
+ const notices = page.doc.body.children.filter((node) => node.attrs.id === 'dsh-bug-perf-stuck')
316
+ assert.equal(notices.length, 0)
317
+ const styles = page.doc.head.children.filter((node) => node.tag === 'style')
318
+ assert.equal(styles.length, 1)
319
+ })
320
+
321
+ // Given 提示重复轮询, When 页面渲染多次, Then 提示节点只有一个(幂等)
322
+ test('停滞_重复轮询_提示幂等', () => {
323
+ const page = fakePage()
324
+ const { clock } = install({ page, options: { reconnect: false } })
325
+ clock.advance(2000 * 12)
326
+ clock.advance(2000 * 3)
327
+ const notices = page.doc.body.children.filter((node) => node.attrs.id === 'dsh-bug-perf-stuck')
328
+ assert.equal(notices.length, 1)
329
+ })
330
+
331
+ // Given 样式注入, When 安装补丁, Then style 节点带 data-plugin 标记
332
+ test('停滞_样式注入_带插件标记', () => {
333
+ const { page } = install()
334
+ const styles = page.doc.head.children.filter((node) => node.tag === 'style')
335
+ assert.equal(styles.length, 1)
336
+ assert.equal(styles[0].attrs['data-plugin'], '@mzzsfy/dsh-bug-perf')
337
+ })
338
+
339
+ // Given 宿主重建删掉了本插件样式, When 再次安装, Then 样式重新注入且不叠加定时器
340
+ test('停滞_样式被删后重装_重新注入', () => {
341
+ const page = fakePage()
342
+ const { plugin, clock } = install({ page })
343
+ page.doc.head.children = []
344
+ const second = plugin.__test.installStuckSession({ win: page.win, options: OPTIONS, clock, connection: fakeConnection() })
345
+ assert.equal(second.installed, false)
346
+ assert.equal(second.reason, 'already installed')
347
+ const styles = page.doc.head.children.filter((node) => node.tag === 'style')
348
+ assert.equal(styles.length, 1)
349
+ assert.equal(styles[0].attrs['data-plugin'], '@mzzsfy/dsh-bug-perf')
350
+ })