@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,165 @@
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
+ return {
19
+ now: () => now,
20
+ setInterval: (fn, ms) => { const id = ++nextId; intervals.set(id, { fn, every: ms, at: now + ms }); return id },
21
+ clearInterval: (id) => { intervals.delete(id) },
22
+ advance(ms) {
23
+ const target = now + ms
24
+ while (true) {
25
+ const due = [...intervals.values()].map((timer) => timer.at).filter((at) => at <= target)
26
+ if (due.length === 0) break
27
+ now = Math.min(...due)
28
+ for (const timer of intervals.values()) {
29
+ if (timer.at <= now) {
30
+ timer.at = now + timer.every
31
+ timer.fn()
32
+ }
33
+ }
34
+ }
35
+ now = target
36
+ },
37
+ intervalCount: () => intervals.size,
38
+ }
39
+ }
40
+
41
+ function fakeWindow(clock) {
42
+ const fetchCalls = []
43
+ const pendingProbes = []
44
+ const win = {
45
+ location: { pathname: '/chat' },
46
+ performance: { memory: { usedJSHeapSize: 64 * 1024 * 1024 } },
47
+ document: { querySelectorAll: () => ({ length: 42 }) },
48
+ fetch: (url, options) => {
49
+ fetchCalls.push({ url, options })
50
+ return Promise.resolve({ ok: true })
51
+ },
52
+ setTimeout: (fn) => { pendingProbes.push({ fn, startedAt: clock.now() }); return pendingProbes.length },
53
+ addEventListener: () => {},
54
+ }
55
+ return { win, fetchCalls, pendingProbes }
56
+ }
57
+
58
+ // Given 默认关闭, When 采样运行五分钟, Then 不发任何请求, 采样与阻塞探测仍工作
59
+ test('诊断_默认关闭_只采样不上报', () => {
60
+ const plugin = loadPlugin()
61
+ const clock = fakeClock()
62
+ const { win, fetchCalls, pendingProbes } = fakeWindow(clock)
63
+ const diag = plugin.__test.createDiag(clock)
64
+ plugin.__test.startDiagSampling({ win, clock, diag, doc: win.document })
65
+ const reporting = plugin.__test.startDiagReporting({
66
+ win,
67
+ clock,
68
+ diag,
69
+ options: { enabled: false, reportEvery: 12 },
70
+ })
71
+ clock.advance(5 * 60 * 1000)
72
+ assert.equal(reporting.enabled, false)
73
+ assert.equal(diag.enabled, false)
74
+ assert.equal(fetchCalls.length, 0)
75
+ assert.ok(diag.samples.length >= 60)
76
+ clock.advance(plugin.__test.DIAG_PROBE_MS)
77
+ const probe = pendingProbes.shift()
78
+ assert.equal(typeof probe?.fn, 'function')
79
+ clock.advance(plugin.__test.DIAG_BLOCK_MS + 1)
80
+ probe.fn()
81
+ assert.equal(typeof diag.blockedAt, 'number')
82
+ assert.ok(diag.blockedMs >= plugin.__test.DIAG_BLOCK_MS)
83
+ })
84
+
85
+ // Given 开启, When 推进一个上报周期, Then 恰好一次请求且 payload 字段完整
86
+ test('诊断_开启_按周期上报', () => {
87
+ const plugin = loadPlugin()
88
+ const clock = fakeClock()
89
+ const { win, fetchCalls } = fakeWindow(clock)
90
+ const diag = plugin.__test.createDiag(clock)
91
+ plugin.__test.startDiagSampling({ win, clock, diag, doc: win.document })
92
+ const reporting = plugin.__test.startDiagReporting({ win, clock, diag, options: { enabled: true, reportEvery: 12 } })
93
+ assert.equal(reporting.enabled, true)
94
+ assert.equal(diag.enabled, true)
95
+ clock.advance(12 * plugin.__test.DIAG_SAMPLE_MS - 1)
96
+ assert.equal(fetchCalls.length, 0)
97
+ clock.advance(1)
98
+ assert.equal(fetchCalls.length, 1)
99
+ const { url, options } = fetchCalls[0]
100
+ assert.ok(url.startsWith('/api/dsh-bug-perf/diag?d='))
101
+ assert.equal(options.keepalive, true)
102
+ const payload = JSON.parse(decodeURIComponent(url.slice(url.indexOf('?d=') + 3)))
103
+ assert.equal(typeof payload.at, 'number')
104
+ assert.equal(payload.path, '/chat')
105
+ assert.equal(typeof payload.totals.opened, 'number')
106
+ assert.ok(Array.isArray(payload.last))
107
+ assert.ok(Array.isArray(payload.longTasks))
108
+ assert.ok(Array.isArray(payload.errors))
109
+ assert.equal('blockedMs' in payload, true)
110
+ assert.ok(payload.last.length > 0)
111
+ })
112
+
113
+ // Given reportEvery 改小, When 推进两个周期, Then 上报次数随周期增加
114
+ test('诊断_上报间隔_随配置变化', () => {
115
+ const plugin = loadPlugin()
116
+ const clock = fakeClock()
117
+ const { win, fetchCalls } = fakeWindow(clock)
118
+ const diag = plugin.__test.createDiag(clock)
119
+ plugin.__test.startDiagSampling({ win, clock, diag, doc: win.document })
120
+ plugin.__test.startDiagReporting({ win, clock, diag, options: { enabled: true, reportEvery: 2 } })
121
+ clock.advance(2 * plugin.__test.DIAG_SAMPLE_MS)
122
+ assert.equal(fetchCalls.length, 1)
123
+ clock.advance(2 * plugin.__test.DIAG_SAMPLE_MS)
124
+ assert.equal(fetchCalls.length, 2)
125
+ })
126
+
127
+ // Given fetch 缺失或抛错, When 到达上报点, Then 静默跳过且不抛
128
+ test('诊断_上报失败_静默', () => {
129
+ const plugin = loadPlugin()
130
+ const clock = fakeClock()
131
+ const missing = fakeWindow(clock)
132
+ delete missing.win.fetch
133
+ const missingDiag = plugin.__test.createDiag(clock)
134
+ plugin.__test.startDiagSampling({ win: missing.win, clock, diag: missingDiag, doc: missing.win.document })
135
+ plugin.__test.startDiagReporting({ win: missing.win, clock, diag: missingDiag, options: { enabled: true, reportEvery: 1 } })
136
+ assert.doesNotThrow(() => clock.advance(plugin.__test.DIAG_SAMPLE_MS))
137
+
138
+ const throwing = fakeWindow(clock)
139
+ throwing.win.fetch = () => { throw new Error('fetch boom') }
140
+ const throwingDiag = plugin.__test.createDiag(clock)
141
+ plugin.__test.startDiagSampling({ win: throwing.win, clock, diag: throwingDiag, doc: throwing.win.document })
142
+ plugin.__test.startDiagReporting({ win: throwing.win, clock, diag: throwingDiag, options: { enabled: true, reportEvery: 1 } })
143
+ assert.doesNotThrow(() => clock.advance(plugin.__test.DIAG_SAMPLE_MS))
144
+ })
145
+
146
+ // Given fetch 返回拒绝或非 2xx, When 到达上报点, Then 不产生未处理异常
147
+ test('诊断_上报异步失败_静默', async () => {
148
+ const plugin = loadPlugin()
149
+ const clock = fakeClock()
150
+ const rejecting = fakeWindow(clock)
151
+ rejecting.win.fetch = () => Promise.reject(new Error('fetch rejected'))
152
+ const rejectingDiag = plugin.__test.createDiag(clock)
153
+ plugin.__test.startDiagSampling({ win: rejecting.win, clock, diag: rejectingDiag, doc: rejecting.win.document })
154
+ plugin.__test.startDiagReporting({ win: rejecting.win, clock, diag: rejectingDiag, options: { enabled: true, reportEvery: 1 } })
155
+ assert.doesNotThrow(() => clock.advance(plugin.__test.DIAG_SAMPLE_MS))
156
+ await Promise.resolve()
157
+
158
+ const notOk = fakeWindow(clock)
159
+ notOk.win.fetch = () => Promise.resolve({ ok: false })
160
+ const notOkDiag = plugin.__test.createDiag(clock)
161
+ plugin.__test.startDiagSampling({ win: notOk.win, clock, diag: notOkDiag, doc: notOk.win.document })
162
+ plugin.__test.startDiagReporting({ win: notOk.win, clock, diag: notOkDiag, options: { enabled: true, reportEvery: 1 } })
163
+ assert.doesNotThrow(() => clock.advance(plugin.__test.DIAG_SAMPLE_MS))
164
+ await Promise.resolve()
165
+ })
@@ -0,0 +1,220 @@
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 fakeJobs() {
15
+ const watched = []
16
+ const released = []
17
+ return {
18
+ watched,
19
+ released,
20
+ watchRows(sessionId) {
21
+ watched.push(sessionId)
22
+ return () => released.push(sessionId)
23
+ },
24
+ }
25
+ }
26
+
27
+ function fakeDoc() {
28
+ const listeners = new Map()
29
+ return {
30
+ addEventListener(type, listener) { listeners.set(type, listener) },
31
+ removeEventListener(type) { listeners.delete(type) },
32
+ clickOn(sessionId) {
33
+ const target = { closest: (selector) => (selector === '[data-row-key]' ? { getAttribute: () => `session:${sessionId}` } : null) }
34
+ listeners.get('click')?.({ target })
35
+ },
36
+ clickOnBlank() {
37
+ listeners.get('click')?.({ target: { closest: () => null } })
38
+ },
39
+ }
40
+ }
41
+
42
+ const fakeSessions = (summaries) => {
43
+ const snapshot = { byId: Object.fromEntries(summaries.map((item) => [item.sessionId, item])) }
44
+ return { list: { getSnapshot: () => snapshot } }
45
+ }
46
+
47
+ // s1..s9 是主会话, c1..c3 是 s1 的子 agent 会话
48
+ const SESSIONS = fakeSessions([
49
+ { sessionId: 's1' },
50
+ { sessionId: 'c1', origin: 'subagent', parentSessionId: 's1' },
51
+ { sessionId: 'c2', origin: 'subagent', parentSessionId: 's1' },
52
+ { sessionId: 'c3', origin: 'subagent', parentSessionId: 's1' },
53
+ { sessionId: 's2' },
54
+ { sessionId: 's3' },
55
+ { sessionId: 's4' },
56
+ { sessionId: 's5' },
57
+ { sessionId: 's6' },
58
+ ])
59
+
60
+ const install = ({ jobs = fakeJobs(), sessions = SESSIONS, doc = fakeDoc(), options = {} } = {}) => {
61
+ const plugin = loadPlugin()
62
+ const result = plugin.__test.installJobStreamLimit({
63
+ jobs,
64
+ sessionsOf: () => sessions,
65
+ doc,
66
+ options: { enabled: true, base: 3, max: 5, ...options },
67
+ })
68
+ return { plugin, jobs, doc, result }
69
+ }
70
+
71
+ // Given 主会话数量未到默认窗口, When 列表逐行订阅, Then 都建立流
72
+ test('任务流_主会话_窗口内放行', () => {
73
+ const { jobs, result } = install()
74
+ for (const id of ['s1', 's2', 's3']) jobs.watchRows(id)
75
+ assert.deepEqual(jobs.watched, ['s1', 's2', 's3'])
76
+ assert.equal(result.state.main, 3)
77
+ assert.equal(result.state.rejected, 0)
78
+ })
79
+
80
+ // Given 默认窗口已满, When 第 4 个主会话订阅, Then 拒绝且不建流
81
+ test('任务流_主会话_超出窗口拒绝', () => {
82
+ const { jobs, result } = install()
83
+ for (const id of ['s1', 's2', 's3', 's4']) jobs.watchRows(id)
84
+ assert.deepEqual(jobs.watched, ['s1', 's2', 's3'])
85
+ assert.equal(result.state.rejected, 1)
86
+ })
87
+
88
+ // Given 默认窗口还有空位, When 子 agent 会话自动订阅, Then 不占用窗口
89
+ test('任务流_子会话_不占窗口', () => {
90
+ const { jobs, result } = install()
91
+ jobs.watchRows('c1')
92
+ jobs.watchRows('s1')
93
+ jobs.watchRows('s2')
94
+ jobs.watchRows('s3')
95
+ assert.deepEqual(jobs.watched, ['s1', 's2', 's3'])
96
+ assert.equal(result.state.subagent, 0)
97
+ assert.equal(result.state.rejected, 1)
98
+ })
99
+
100
+ // Given 点击子 agent 会话, When 窗口已满, Then 仍然为它建立流
101
+ test('任务流_点击子会话_放行', () => {
102
+ const { jobs, doc, result } = install()
103
+ for (const id of ['s1', 's2', 's3']) jobs.watchRows(id)
104
+ doc.clickOn('c1')
105
+ assert.ok(jobs.watched.includes('c1'))
106
+ assert.equal(result.state.subagent, 1)
107
+ assert.equal(result.state.clicked, 1)
108
+ })
109
+
110
+ // Given 点击的主会话不在窗口内, When 点击, Then 同样放行
111
+ test('任务流_点击主会话_放行', () => {
112
+ const { jobs, doc } = install()
113
+ for (const id of ['s1', 's2', 's3']) jobs.watchRows(id)
114
+ doc.clickOn('s6')
115
+ assert.ok(jobs.watched.includes('s6'))
116
+ })
117
+
118
+ // Given 总数已到上限, When 再点击一个会话, Then 淘汰最老的一条, 总数不变
119
+ test('任务流_达到上限_淘汰最老', () => {
120
+ const { jobs, doc, result } = install()
121
+ for (const id of ['s1', 's2', 's3']) jobs.watchRows(id)
122
+ doc.clickOn('c1')
123
+ doc.clickOn('c2')
124
+ assert.equal(result.state.main + result.state.subagent, 5)
125
+ const watchedBefore = jobs.watched.length
126
+ doc.clickOn('c3')
127
+ assert.equal(jobs.watched.length, watchedBefore + 1)
128
+ assert.deepEqual(jobs.released, ['s1'])
129
+ assert.equal(result.state.evicted, 1)
130
+ assert.equal(result.state.main + result.state.subagent, 5)
131
+ })
132
+
133
+ // Given 点击的行已经在窗口内, When 点击, Then 不重复建流, 只标记
134
+ test('任务流_点击已在窗口_不重复建流', () => {
135
+ const { jobs, doc, result } = install()
136
+ jobs.watchRows('s1')
137
+ const before = jobs.watched.length
138
+ doc.clickOn('s1')
139
+ assert.equal(jobs.watched.length, before)
140
+ assert.equal(result.state.clicked, 0)
141
+ })
142
+
143
+ // Given 点击了非会话行, When 触发点击, Then 忽略
144
+ test('任务流_点击非会话行_忽略', () => {
145
+ const { jobs, doc } = install()
146
+ doc.clickOnBlank()
147
+ assert.deepEqual(jobs.watched, [])
148
+ })
149
+
150
+ // Given 行组件卸载, When 调用释放, Then 额度回收, 新的主会话可以进入窗口
151
+ test('任务流_释放后_额度回收', () => {
152
+ const { jobs, result } = install()
153
+ const release = jobs.watchRows('s1')
154
+ jobs.watchRows('s2')
155
+ jobs.watchRows('s3')
156
+ jobs.watchRows('s4')
157
+ assert.equal(result.state.rejected, 1)
158
+ release()
159
+ jobs.watchRows('s4')
160
+ assert.deepEqual(jobs.watched, ['s1', 's2', 's3', 's4'])
161
+ assert.equal(result.state.main, 3)
162
+ })
163
+
164
+ // Given 同一行重复释放, When 调用两次, Then 只扣一次
165
+ test('任务流_重复释放_只扣一次', () => {
166
+ const { jobs, result } = install()
167
+ const release = jobs.watchRows('s1')
168
+ release()
169
+ release()
170
+ assert.equal(result.state.main, 0)
171
+ assert.deepEqual(jobs.released, ['s1'])
172
+ })
173
+
174
+ // Given 包装已安装, When 再次安装, Then 幂等
175
+ test('任务流_重复安装_幂等', () => {
176
+ const { jobs, plugin } = install()
177
+ const second = plugin.__test.installJobStreamLimit({ jobs, sessionsOf: () => SESSIONS, doc: fakeDoc(), options: { enabled: true, base: 3, max: 5 } })
178
+ assert.equal(second.installed, false)
179
+ assert.equal(second.reason, 'already installed')
180
+ })
181
+
182
+ // Given 宿主没有 jobs 服务, When 安装, Then 跳过并记录原因
183
+ test('任务流_服务缺失_跳过', () => {
184
+ const plugin = loadPlugin()
185
+ const result = plugin.__test.installJobStreamLimit({ jobs: undefined, sessionsOf: () => SESSIONS, doc: fakeDoc(), options: { enabled: true, base: 3, max: 5 } })
186
+ assert.equal(result.installed, false)
187
+ assert.equal(result.reason, 'jobs unavailable')
188
+ })
189
+
190
+ // Given 会话摘要尚未就绪, When 订阅, Then 按主会话处理, 不误判为子会话
191
+ test('任务流_摘要缺失_按主会话处理', () => {
192
+ const { jobs, result } = install({ sessions: null })
193
+ jobs.watchRows('s1')
194
+ jobs.watchRows('s2')
195
+ jobs.watchRows('c1')
196
+ jobs.watchRows('s4')
197
+ assert.deepEqual(jobs.watched, ['s1', 's2', 'c1'])
198
+ assert.equal(result.state.rejected, 1)
199
+ })
200
+
201
+ // Given 子会话已被点击放行, When 该会话从摘要里变为普通会话, Then 不重复建流
202
+ test('任务流_重复订阅同一会话_只建一次', () => {
203
+ const { jobs, doc } = install()
204
+ doc.clickOn('c1')
205
+ jobs.watchRows('c1')
206
+ jobs.watchRows('c1')
207
+ assert.deepEqual(jobs.watched, ['c1'])
208
+ })
209
+
210
+ // Given 补丁卸载, When 调用 dispose, Then 所有流被释放且点击监听移除
211
+ test('任务流_卸载_释放全部流', () => {
212
+ const { jobs, doc, result } = install()
213
+ jobs.watchRows('s1')
214
+ jobs.watchRows('s2')
215
+ doc.clickOn('c1')
216
+ result.dispose()
217
+ assert.deepEqual(jobs.released.sort(), ['c1', 's1', 's2'])
218
+ doc.clickOn('s5')
219
+ assert.equal(jobs.watched.length, 3)
220
+ })
@@ -0,0 +1,227 @@
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 fakeWebSocketClass() {
45
+ return class FakeSocket {
46
+ static CONNECTING = 0
47
+ static OPEN = 1
48
+ static CLOSING = 2
49
+ static CLOSED = 3
50
+ constructor(url) {
51
+ this.url = String(url)
52
+ this.readyState = 1
53
+ this.sent = []
54
+ this.listeners = new Map()
55
+ }
56
+ send(data) { this.sent.push(String(data)) }
57
+ addEventListener(type, fn) {
58
+ const list = this.listeners.get(type) ?? []
59
+ list.push(fn)
60
+ this.listeners.set(type, list)
61
+ }
62
+ removeEventListener(type, fn) {
63
+ const list = this.listeners.get(type) ?? []
64
+ this.listeners.set(type, list.filter((item) => item !== fn))
65
+ }
66
+ dispatch(type, event) {
67
+ for (const fn of this.listeners.get(type) ?? []) fn(event)
68
+ }
69
+ }
70
+ }
71
+
72
+ function install({ intervalMs = 1500, enabled = true } = {}) {
73
+ const plugin = loadPlugin()
74
+ const FakeSocket = fakeWebSocketClass()
75
+ const win = { WebSocket: FakeSocket, location: { href: 'https://dsh.example/' } }
76
+ const clock = fakeClock()
77
+ const disabled = []
78
+ const result = plugin.__test.installSocketKeepAlive({
79
+ win,
80
+ options: { enabled, intervalMs },
81
+ clock,
82
+ onDisabled: (reason) => disabled.push(reason),
83
+ })
84
+ return { plugin, win, clock, result, Native: FakeSocket, disabled }
85
+ }
86
+
87
+ const keepaliveFrames = (socket) => socket.sent.filter((raw) => raw.includes('dsh-bug-perf-keepalive'))
88
+
89
+ // Given 页面创建 /api/remote.mux 连接, When 空闲到达保活间隔, Then 发出一条合法 item 帧
90
+ test('保活_空闲_发出合法item帧', () => {
91
+ const { win, clock } = install()
92
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
93
+ assert.equal(socket.sent.length, 0)
94
+ clock.advance(1500)
95
+ const frames = keepaliveFrames(socket)
96
+ assert.equal(frames.length, 1)
97
+ const frame = JSON.parse(frames[0])
98
+ assert.equal(frame.type, 'item')
99
+ assert.equal(typeof frame.streamId, 'string')
100
+ assert.ok(frame.streamId.length > 0)
101
+ })
102
+
103
+ // Given 保活已生效, When 继续空闲多个间隔, Then 每个间隔各一条,持续保活
104
+ test('保活_持续_按间隔重复发送', () => {
105
+ const { win, clock } = install()
106
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
107
+ clock.advance(1500)
108
+ clock.advance(1500)
109
+ clock.advance(1500)
110
+ const frames = keepaliveFrames(socket)
111
+ assert.equal(frames.length, 3)
112
+ assert.equal(new Set(frames.map((raw) => JSON.parse(raw).streamId)).size, 3)
113
+ })
114
+
115
+ // Given 连接上客户端自己刚发过上行帧, When 尚未到保活间隔, Then 不补保活帧
116
+ test('保活_有上行活动_不补帧', () => {
117
+ const { win, clock } = install()
118
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
119
+ socket.send('{"type":"open"}')
120
+ clock.advance(1000)
121
+ assert.equal(keepaliveFrames(socket).length, 0)
122
+ clock.advance(1000)
123
+ assert.equal(keepaliveFrames(socket).length, 1)
124
+ })
125
+
126
+ // Given 配置改了保活间隔, When 连接已存在, Then 既有连接按新间隔发送,不需要重建
127
+ test('保活_间隔重配_既有连接生效', () => {
128
+ const { win, clock, result } = install()
129
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
130
+ result.setIntervalMs(4000)
131
+ clock.advance(2500)
132
+ assert.equal(keepaliveFrames(socket).length, 0)
133
+ clock.advance(2000)
134
+ assert.equal(keepaliveFrames(socket).length, 1)
135
+ })
136
+
137
+ // Given 连接不在 OPEN 态, When 到达检查点, Then 不发送
138
+ test('保活_非OPEN_不发送', () => {
139
+ const { win, clock } = install()
140
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
141
+ socket.readyState = 3
142
+ clock.advance(1500)
143
+ assert.equal(socket.sent.length, 0)
144
+ })
145
+
146
+ // Given 非 remote.mux 连接, When 空闲到达间隔, Then 不发送(只补会话流)
147
+ test('保活_其他连接_不发送', () => {
148
+ const { win, clock } = install()
149
+ const socket = new win.WebSocket('wss://dsh.example/api/bus/ws')
150
+ clock.advance(1500)
151
+ assert.equal(socket.sent.length, 0)
152
+ })
153
+
154
+ // Given 开关关闭, When 创建连接, Then 全局 WebSocket 不被包装且无任何帧
155
+ test('保活_关闭_不包装', () => {
156
+ const { win, clock, Native } = install({ enabled: false })
157
+ assert.equal(win.WebSocket, Native)
158
+ clock.advance(5000)
159
+ })
160
+
161
+ // Given 连接关闭, When 连接已断, Then 保活定时器随连接清理,不再发送
162
+ test('保活_连接关闭_清理定时器', () => {
163
+ const { win, clock, result } = install()
164
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
165
+ socket.dispatch('close', { code: 1000 })
166
+ clock.advance(5000)
167
+ assert.equal(socket.sent.length, 0)
168
+ assert.equal(result.state.timers.size, 0)
169
+ })
170
+
171
+ // Given 普通断线与主动关闭, When 连续发生多次, Then 不计入熔断,补丁继续工作
172
+ test('保活_普通断线_不熔断', () => {
173
+ const { win, clock, Native, disabled } = install()
174
+ for (const code of [1000, 1001, 1006, 1006]) {
175
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
176
+ clock.advance(1500)
177
+ socket.dispatch('close', { code })
178
+ }
179
+ assert.equal(win.WebSocket === Native, false)
180
+ assert.deepEqual(disabled, [])
181
+ })
182
+
183
+ // Given 宿主以协议错误码关闭连接, When 连续命中熔断阈值, Then 补丁自行禁用并恢复原始构造器
184
+ test('保活_协议关闭_熔断', () => {
185
+ const { win, clock, Native, disabled } = install()
186
+ for (let round = 0; round < 2; round++) {
187
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
188
+ clock.advance(1500)
189
+ assert.equal(keepaliveFrames(socket).length, 1)
190
+ socket.dispatch('close', { code: 1008 })
191
+ }
192
+ assert.equal(win.WebSocket, Native)
193
+ assert.equal(disabled.length, 1)
194
+ clock.advance(9000)
195
+ })
196
+
197
+ // Given 连接从未发过保活帧, When 以协议错误码关闭, Then 不计入熔断(与应用自身协议错误区分)
198
+ test('保活_未发保活帧_协议关闭不计数', () => {
199
+ const { win, clock, Native } = install()
200
+ for (let round = 0; round < 3; round++) {
201
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
202
+ socket.dispatch('close', { code: 1008 })
203
+ }
204
+ assert.equal(win.WebSocket === Native, false)
205
+ clock.advance(5000)
206
+ })
207
+
208
+ // Given 连接健康存活超过恢复窗口, When 之后才发生协议关闭, Then 历史累计已清零
209
+ test('保活_健康存活_清零累计', () => {
210
+ const { win, clock, Native } = install()
211
+ const first = new win.WebSocket('wss://dsh.example/api/remote.mux')
212
+ clock.advance(1500)
213
+ first.dispatch('close', { code: 1008 })
214
+ const second = new win.WebSocket('wss://dsh.example/api/remote.mux')
215
+ clock.advance(20000)
216
+ second.dispatch('close', { code: 1008 })
217
+ assert.equal(win.WebSocket === Native, false)
218
+ })
219
+
220
+ // Given 已被包装的连接, When 查看全局构造器, Then 仍是 WebSocket 语义且实例可判定
221
+ test('保活_包装后_保持实例语义', () => {
222
+ const { win, Native } = install()
223
+ const socket = new win.WebSocket('wss://dsh.example/api/remote.mux')
224
+ assert.ok(socket instanceof Native)
225
+ assert.ok(socket instanceof win.WebSocket)
226
+ assert.equal(win.WebSocket.OPEN, 1)
227
+ })