@mzzsfy/dsh-turn-notify 0.8.1 → 0.10.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.
@@ -659,6 +659,8 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
659
659
  minTurnDurationMs: MIN_TURN_MS,
660
660
  rootsOnly: true,
661
661
  suppressSubagentWake: true,
662
+ hostNotify: false,
663
+ hostNotifyFallback: false,
662
664
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
663
665
  soundMapping: { completed: 'snd-1' },
664
666
  imTargets: [],
@@ -670,6 +672,8 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
670
672
  minTurnDurationMs: MIN_TURN_MS,
671
673
  rootsOnly: true,
672
674
  suppressSubagentWake: true,
675
+ hostNotify: false,
676
+ hostNotifyFallback: false,
673
677
  enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
674
678
  soundMapping: {},
675
679
  imTargets: [],
@@ -679,23 +683,26 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
679
683
  assert.equal(resolvedConfig({ suppressSubagentWake: false }).suppressSubagentWake, false)
680
684
  })
681
685
 
682
- test('面板可见配置:webhookUrl 不出主机,仅回是否已配置', () => {
686
+ test('面板可见配置:webhookUrl 原文回显', () => {
683
687
  assert.deepEqual(
684
688
  publicConfig({ webhookUrl: 'https://hook.example/service/xxx', enabled: { completed: false }, soundMapping: { completed: 'snd-1' }, kindRoutes: { ask: ['im'] } }),
685
689
  {
686
690
  minTurnDurationMs: MIN_TURN_MS,
687
691
  rootsOnly: true,
688
692
  suppressSubagentWake: true,
693
+ hostNotify: false,
694
+ hostNotifyFallback: false,
689
695
  enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
690
696
  soundMapping: { completed: 'snd-1' },
691
697
  imTargets: [],
692
698
  kindRoutes: { ask: ['im'] },
693
- webhookConfigured: true,
699
+ webhookUrl: 'https://hook.example/service/xxx',
694
700
  },
695
701
  )
696
- assert.equal(publicConfig({}).webhookConfigured, false)
697
- assert.equal(publicConfig({ webhookUrl: ' ' }).webhookConfigured, false)
698
- assert.equal('webhookUrl' in publicConfig({ webhookUrl: 'https://hook.example' }), false)
702
+ assert.equal(publicConfig({}).webhookUrl, '')
703
+ // 空白串原样回显,不静默归一
704
+ assert.equal(publicConfig({ webhookUrl: ' ' }).webhookUrl, ' ')
705
+ assert.equal('webhookConfigured' in publicConfig({ webhookUrl: 'https://hook.example' }), false)
699
706
  })
700
707
 
701
708
  test('会话事件有界累积:标题提取后封账为字符串,内存不再增长', () => {
@@ -6,13 +6,14 @@ import { EventEmitter } from 'node:events'
6
6
 
7
7
  import { apply } from '../src/index.js'
8
8
 
9
+ // res 桩带事件能力:投影处理器挂 close 事件感知断连,桩须支持 once
9
10
  function makeRes() {
10
- return {
11
- status: null,
12
- body: null,
13
- writeHead(status) { this.status = status },
14
- end(body) { this.body = JSON.parse(body) },
15
- }
11
+ const res = new EventEmitter()
12
+ res.status = null
13
+ res.body = null
14
+ res.writeHead = (status) => { res.status = status }
15
+ res.end = (body) => { res.body = JSON.parse(body) }
16
+ return res
16
17
  }
17
18
 
18
19
  function makeReq(method, payload, headers) {
@@ -0,0 +1,593 @@
1
+ // 宿主桌面通知 BDD:core 纯函数(回环判定 / 派发决策 / 平台命令 / spawn 执行 / 配置键)
2
+ // 与 host 接线集成(stub ctx 装载真实 src/index.js,stub spawn 观测派发)。
3
+ import test from 'node:test'
4
+ import assert from 'node:assert/strict'
5
+ import { EventEmitter } from 'node:events'
6
+
7
+ import { apply, __hostSpawn, CLIENT_PRESENCE_WINDOW_MS } from '../src/index.js'
8
+ import {
9
+ CHANNELS,
10
+ HOST_NOTIFY_TIMEOUT_MS,
11
+ hostNotifyCommand,
12
+ hostNotifyWanted,
13
+ isLocalBrowserPoll,
14
+ isLocalBrowserPresent,
15
+ isLoopbackAddress,
16
+ publicConfig,
17
+ resolvedConfig,
18
+ sendHostNotify,
19
+ validateConfigPatch,
20
+ } from '../src/core.mjs'
21
+
22
+ // ---- isLoopbackAddress:同机判定,回环地址即本机浏览器 ----
23
+
24
+ test('Given 回环形态地址 When 判定 Then 为真', () => {
25
+ assert.equal(isLoopbackAddress('::1'), true)
26
+ assert.equal(isLoopbackAddress('127.0.0.1'), true)
27
+ assert.equal(isLoopbackAddress('127.8.8.8'), true)
28
+ assert.equal(isLoopbackAddress('::ffff:127.0.0.1'), true)
29
+ })
30
+
31
+ test('Given 非回环或非法地址 When 判定 Then 为假', () => {
32
+ assert.equal(isLoopbackAddress('192.168.1.5'), false)
33
+ assert.equal(isLoopbackAddress('::ffff:192.168.1.5'), false)
34
+ assert.equal(isLoopbackAddress('::2'), false)
35
+ assert.equal(isLoopbackAddress(''), false)
36
+ assert.equal(isLoopbackAddress(undefined), false)
37
+ assert.equal(isLoopbackAddress(null), false)
38
+ })
39
+
40
+ // ---- isLocalBrowserPoll:在场记账形态收紧,防伪造在场压制宿主通知 ----
41
+
42
+ test('Given 回环加 cursor 加 fetch 形态或无 Sec-Fetch 头 When 判定 Then 为真', () => {
43
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '127.0.0.1', hasCursor: true, secFetchMode: 'cors' }), true)
44
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '::1', hasCursor: true, secFetchMode: undefined }), true)
45
+ })
46
+
47
+ test('Given 非回环或无 cursor 或 no-cors 形态 When 判定 Then 为假', () => {
48
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '192.168.1.5', hasCursor: true, secFetchMode: 'cors' }), false)
49
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '127.0.0.1', hasCursor: false, secFetchMode: 'cors' }), false)
50
+ assert.equal(isLocalBrowserPoll({ remoteAddress: '127.0.0.1', hasCursor: true, secFetchMode: 'no-cors' }), false)
51
+ assert.equal(isLocalBrowserPoll({ remoteAddress: undefined, hasCursor: true, secFetchMode: 'cors' }), false)
52
+ })
53
+
54
+ test('Given Origin 缺席或与 Host 一致 When 判定 Then 为真;跨源或畸形 Origin Then 为假', () => {
55
+ const base = { remoteAddress: '127.0.0.1', hasCursor: true, secFetchMode: 'cors' }
56
+ assert.equal(isLocalBrowserPoll({ ...base, origin: undefined, host: '127.0.0.1:3080' }), true, '同源 GET fetch 不带 Origin')
57
+ assert.equal(isLocalBrowserPoll({ ...base, origin: 'http://127.0.0.1:3080', host: '127.0.0.1:3080' }), true)
58
+ assert.equal(isLocalBrowserPoll({ ...base, origin: 'https://evil.example', host: '127.0.0.1:3080' }), false, '跨源 fetch 必带且与 Host 不符')
59
+ assert.equal(isLocalBrowserPoll({ ...base, origin: 'not a url', host: '127.0.0.1:3080' }), false, '畸形 Origin 不放行')
60
+ })
61
+
62
+ // ---- hostNotifyWanted:浏览器优先,在场让位、离场补位;重复只发生在边界情况 ----
63
+
64
+ const NOW = 1000 * 1000
65
+ const WINDOW_MS = 2 * 1000
66
+
67
+ test('Given 任一开关开且本机浏览器不在场 When 决策 Then 弹', () => {
68
+ assert.equal(hostNotifyWanted({ hostNotify: true, localBrowserPresent: false }), true)
69
+ assert.equal(hostNotifyWanted({ hostNotifyFallback: true, localBrowserPresent: false }), true)
70
+ assert.equal(hostNotifyWanted({ hostNotify: true, hostNotifyFallback: true, localBrowserPresent: false }), true)
71
+ })
72
+
73
+ test('Given 任一开关开且本机浏览器在场 When 决策 Then 让位不弹(浏览器优先,正常路径零重复)', () => {
74
+ assert.equal(hostNotifyWanted({ hostNotify: true, localBrowserPresent: true }), false)
75
+ assert.equal(hostNotifyWanted({ hostNotifyFallback: true, localBrowserPresent: true }), false)
76
+ assert.equal(hostNotifyWanted({ hostNotify: true, hostNotifyFallback: true, localBrowserPresent: true }), false)
77
+ })
78
+
79
+ test('Given 两开关均关 When 决策 Then 恒不弹', () => {
80
+ assert.equal(hostNotifyWanted({ localBrowserPresent: false }), false)
81
+ assert.equal(hostNotifyWanted({ hostNotify: false, hostNotifyFallback: false, localBrowserPresent: false }), false)
82
+ })
83
+
84
+ // ---- isLocalBrowserPresent:在场 = 有在途长轮询,或宽限窗口内有活动 ----
85
+
86
+ test('Given 在途长轮询存在 When 判定在场 Then 为真(时间戳过期不影响)', () => {
87
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 1, lastSeenAt: NOW - WINDOW_MS - 1, now: NOW, windowMs: WINDOW_MS }), true)
88
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 3, lastSeenAt: 0, now: NOW, windowMs: WINDOW_MS }), true)
89
+ })
90
+
91
+ test('Given 无在途 When 判定在场 Then 最近活动在窗口内为真,超窗或从未为假', () => {
92
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - 1, now: NOW, windowMs: WINDOW_MS }), true)
93
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - WINDOW_MS, now: NOW, windowMs: WINDOW_MS }), true)
94
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: NOW - WINDOW_MS - 1, now: NOW, windowMs: WINDOW_MS }), false)
95
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: 0, now: NOW, windowMs: WINDOW_MS }), false)
96
+ assert.equal(isLocalBrowserPresent({ inFlightPolls: 0, lastSeenAt: undefined, now: NOW, windowMs: WINDOW_MS }), false)
97
+ })
98
+
99
+ // ---- hostNotifyCommand:平台命令构造与文本转义 ----
100
+
101
+ test('Given darwin When 构造 Then osascript 单命令携带转义后的标题与正文', () => {
102
+ const command = hostNotifyCommand('darwin', '标题', '正文')
103
+ assert.equal(command.file, 'osascript')
104
+ assert.deepEqual(command.args.slice(0, 1), ['-e'])
105
+ const script = command.args[1]
106
+ assert.ok(script.includes('display notification'), '应含 display notification')
107
+ assert.ok(script.includes('with title'), '应含 with title')
108
+ assert.ok(script.includes('标题'), '应含标题')
109
+ assert.ok(script.includes('正文'), '应含正文')
110
+ })
111
+
112
+ test('Given darwin 且文本含双引号反斜杠换行 When 构造 Then 转义并压平', () => {
113
+ const script = hostNotifyCommand('darwin', 'a"b\\c\nd', 'x').args[1]
114
+ assert.ok(script.includes('a\\"b\\\\c d'), '双引号反斜杠须转义且换行压平: ' + script)
115
+ assert.ok(!script.includes('\n'), '不得残留换行')
116
+ })
117
+
118
+ test('Given linux When 构造 Then notify-send 选项终结符后标题正文直传', () => {
119
+ const command = hostNotifyCommand('linux', 'T', 'B')
120
+ assert.equal(command.file, 'notify-send')
121
+ assert.deepEqual(command.args, ['--', 'T', 'B'])
122
+ })
123
+
124
+ test('Given linux 且正文以减号开头 When 构造 Then 经选项终结符原样直传', () => {
125
+ const command = hostNotifyCommand('linux', 'T', '- 修改了配置')
126
+ assert.deepEqual(command.args, ['--', 'T', '- 修改了配置'])
127
+ })
128
+
129
+ test('Given linux 且文本含换行 When 构造 Then 换行压平为空格', () => {
130
+ const command = hostNotifyCommand('linux', 'a\nb', 'c\nd')
131
+ assert.deepEqual(command.args, ['--', 'a b', 'c d'])
132
+ })
133
+
134
+ test('Given win32 When 构造 Then powershell 无配置非交互单命令脚本', () => {
135
+ const command = hostNotifyCommand('win32', 'T', 'B')
136
+ assert.equal(command.file, 'powershell.exe')
137
+ assert.deepEqual(command.args.slice(0, 3), ['-NoProfile', '-NonInteractive', '-Command'])
138
+ const script = command.args[3]
139
+ assert.ok(script.includes('ToastNotificationManager'), '应使用 WinRT toast')
140
+ assert.ok(script.includes('ToastText02'), '应使用双行模板')
141
+ assert.ok(script.includes('CreateTextNode'), '文本应经 CreateTextNode 注入')
142
+ })
143
+
144
+ test('Given win32 且文本含单引号 When 构造 Then 单引号加倍且换行压平', () => {
145
+ const script = hostNotifyCommand('win32', "it's\nok", 'B').args[3]
146
+ assert.ok(script.includes("it''s ok"), '单引号须加倍且换行压平: ' + script)
147
+ assert.ok(!script.includes('\n'), '不得残留换行')
148
+ })
149
+
150
+ test('Given 未知平台 When 构造 Then 返回 null', () => {
151
+ assert.equal(hostNotifyCommand('freebsd', 'T', 'B'), null)
152
+ })
153
+
154
+ // ---- sendHostNotify:真实结果返回,依赖注入 ----
155
+
156
+ test('Given spawn 成功 When 发送 Then 返回 ok 且传参含超时与隐藏窗口', async () => {
157
+ const calls = []
158
+ const result = await sendHostNotify({
159
+ title: 'T', body: 'B', platform: 'linux',
160
+ execFileImpl: (file, args, options, callback) => {
161
+ calls.push({ file, args, options })
162
+ callback(null)
163
+ },
164
+ })
165
+ assert.deepEqual(result, { ok: true, detail: '已触发宿主通知' })
166
+ assert.equal(calls.length, 1)
167
+ assert.equal(calls[0].file, 'notify-send')
168
+ assert.equal(calls[0].options.timeout, HOST_NOTIFY_TIMEOUT_MS)
169
+ assert.equal(calls[0].options.windowsHide, true)
170
+ })
171
+
172
+ test('Given spawn 失败 When 发送 Then 返回失败与原因', async () => {
173
+ const result = await sendHostNotify({
174
+ title: 'T', body: 'B', platform: 'linux',
175
+ execFileImpl: (file, args, options, callback) => callback(new Error('boom')),
176
+ })
177
+ assert.equal(result.ok, false)
178
+ assert.equal(result.detail, 'boom')
179
+ })
180
+
181
+ test('Given 不支持平台 When 发送 Then 不触碰 spawn 返回不支持', async () => {
182
+ let touched = false
183
+ const result = await sendHostNotify({
184
+ title: 'T', body: 'B', platform: 'freebsd',
185
+ execFileImpl: () => { touched = true },
186
+ })
187
+ assert.equal(touched, false)
188
+ assert.equal(result.ok, false)
189
+ assert.ok(result.detail.includes('freebsd'))
190
+ })
191
+
192
+ // ---- 配置键:校验与解析 ----
193
+
194
+ test('Given 补丁含两键布尔 When 校验 Then 通过并归一;非布尔 Then 拒绝', () => {
195
+ const good = validateConfigPatch({ hostNotify: true, hostNotifyFallback: false })
196
+ assert.equal(good.ok, true)
197
+ assert.deepEqual(good.patch, { hostNotify: true, hostNotifyFallback: false })
198
+ assert.equal(validateConfigPatch({ hostNotify: 'yes' }).ok, false)
199
+ assert.equal(validateConfigPatch({ hostNotifyFallback: 1 }).ok, false)
200
+ })
201
+
202
+ test('Given 缺省配置 When 解析 Then 两键回 false;CHANNELS 含 host', () => {
203
+ const resolved = resolvedConfig({})
204
+ assert.equal(resolved.hostNotify, false)
205
+ assert.equal(resolved.hostNotifyFallback, false)
206
+ const config = publicConfig({ hostNotify: true })
207
+ assert.equal(config.hostNotify, true)
208
+ assert.equal(config.hostNotifyFallback, false)
209
+ assert.ok(CHANNELS.indexOf('host') >= 0, 'host 应为可路由通道')
210
+ })
211
+
212
+ // ---- host 接线集成:stub spawn 观测派发,presence 经投影路由的来源地址注入 ----
213
+
214
+ // res 桩带事件能力:投影处理器挂 close 事件感知断连,桩须支持 once/emit;
215
+ // end 不自动触发 close,断连时机由测试显式 emit 模拟
216
+ function makeRes() {
217
+ const res = new EventEmitter()
218
+ res.status = null
219
+ res.body = null
220
+ res.writeHead = (status) => { res.status = status }
221
+ res.end = (body) => { res.body = JSON.parse(body) }
222
+ return res
223
+ }
224
+
225
+ function makeReq(method, payload, headers) {
226
+ const req = new EventEmitter()
227
+ req.method = method
228
+ req.url = '/api/turn-notify/config'
229
+ req.headers = { host: '127.0.0.1:3080', ...(headers || {}) }
230
+ const data = payload === undefined ? null : Buffer.from(JSON.stringify(payload))
231
+ process.nextTick(() => {
232
+ if (data !== null) req.emit('data', data)
233
+ req.readableEnded = true
234
+ req.emit('end')
235
+ })
236
+ return req
237
+ }
238
+
239
+ const JSON_HEADERS = { 'content-type': 'application/json' }
240
+
241
+ function makeCtx() {
242
+ const routes = new Map()
243
+ const handlers = new Map()
244
+ const doc = new Map()
245
+ const settingsService = {
246
+ register(ns) {
247
+ if (!doc.has(ns)) doc.set(ns, {})
248
+ return { get: () => doc.get(ns), watch: () => () => {} }
249
+ },
250
+ get: (ns) => doc.get(ns),
251
+ update: async (ns, patch) => { doc.set(ns, { ...(doc.get(ns) ?? {}), ...patch }) },
252
+ }
253
+ const ctx = {
254
+ on(event, fn) { handlers.set(event, fn) },
255
+ get(key) { return key === 'settings' ? settingsService : undefined },
256
+ inject(deps, fn) { fn({ settings: settingsService }) },
257
+ effect(thunk) { thunk() },
258
+ webServer: { register(route) { routes.set(route.path, route.handler) } },
259
+ }
260
+ return { ctx, routes, handlers }
261
+ }
262
+
263
+ const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve))
264
+
265
+ const MAIN = { id: 'M', header: { delegationDepth: 0 } }
266
+ const turnEnd = (kind) => ({ type: 'turn/end', data: { reason: { kind } } })
267
+
268
+ async function postConfig(routes, patch) {
269
+ const res = makeRes()
270
+ await routes.get('/api/turn-notify/config')(makeReq('POST', patch, JSON_HEADERS), res)
271
+ assert.equal(res.status, 200)
272
+ return res
273
+ }
274
+
275
+ async function pollProjection(routes, remoteAddress) {
276
+ const req = makeReq('GET')
277
+ req.url = '/api/turn-notify/projection'
278
+ if (remoteAddress !== undefined) req.socket = { remoteAddress }
279
+ const res = makeRes()
280
+ await routes.get('/api/turn-notify/projection')(req, res)
281
+ assert.equal(res.status, 200)
282
+ return res
283
+ }
284
+
285
+ // 本机浏览器长轮询记账形态:回环 + 带续传 cursor + fetch 形态头;
286
+ // cursor 取超前值立即返回,不挂起
287
+ const FUTURE_CURSOR = 2 ** 40
288
+ async function browserPoll(routes, remoteAddress) {
289
+ const req = makeReq('GET', undefined, { 'sec-fetch-mode': 'cors' })
290
+ req.url = '/api/turn-notify/projection?cursor=' + FUTURE_CURSOR
291
+ req.socket = { remoteAddress }
292
+ const res = makeRes()
293
+ await routes.get('/api/turn-notify/projection')(req, res)
294
+ assert.equal(res.status, 200)
295
+ return res
296
+ }
297
+
298
+ // 伪形态轮询:回环来源但缺 cursor / no-cors / 跨源 Origin,记账须拒绝
299
+ async function rawShapePoll(routes, remoteAddress, secFetchMode, withCursor, origin) {
300
+ const headers = secFetchMode === undefined ? {} : { 'sec-fetch-mode': secFetchMode }
301
+ if (origin !== undefined) headers.origin = origin
302
+ const req = makeReq('GET', undefined, headers)
303
+ req.url = '/api/turn-notify/projection' + (withCursor ? '?cursor=' + FUTURE_CURSOR : '')
304
+ req.socket = { remoteAddress }
305
+ const res = makeRes()
306
+ await routes.get('/api/turn-notify/projection')(req, res)
307
+ assert.equal(res.status, 200)
308
+ return res
309
+ }
310
+
311
+ // spawn 观测缝:替换实现收集调用,测试结束还原
312
+ async function withStubSpawn(run) {
313
+ const spawns = []
314
+ const original = __hostSpawn.impl
315
+ __hostSpawn.impl = (file, args, options, callback) => {
316
+ spawns.push({ file, args })
317
+ callback(null)
318
+ }
319
+ try {
320
+ return await run(spawns)
321
+ } finally {
322
+ __hostSpawn.impl = original
323
+ }
324
+ }
325
+
326
+ async function emitCompletedTurn(handlers) {
327
+ handlers.get('session/event')(MAIN, { type: 'turn/start' })
328
+ handlers.get('session/event')(MAIN, turnEnd('completed'))
329
+ await flushMicrotasks()
330
+ }
331
+
332
+ // 挂起中的长轮询:cursor 等于当前版本(配置 POST 后为 1),服务端挂起等待,
333
+ // 模拟真实客户端的在途请求;返回 pending 承诺与 res 桩(供断连模拟与唤醒等待)
334
+ function hangingPoll(routes, remoteAddress) {
335
+ const req = makeReq('GET', undefined, { 'sec-fetch-mode': 'cors' })
336
+ req.url = '/api/turn-notify/projection?cursor=1'
337
+ req.socket = { remoteAddress }
338
+ const res = makeRes()
339
+ const pending = routes.get('/api/turn-notify/projection')(req, res)
340
+ return { pending, res }
341
+ }
342
+
343
+ test('Given 总开关开且本机长轮询在途 When 回合完成 Then 宿主让位;离场超窗后接管', async () => {
344
+ await withStubSpawn(async (spawns) => {
345
+ const { ctx, routes, handlers } = makeCtx()
346
+ apply(ctx)
347
+ const saved = await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
348
+ assert.equal(saved.body.hostNotify, true)
349
+ const { pending, res } = hangingPoll(routes, '127.0.0.1')
350
+ await flushMicrotasks()
351
+ // 在途请求即在场信号:浏览器优先,宿主让位,正常路径零重复
352
+ await emitCompletedTurn(handlers)
353
+ assert.equal(spawns.length, 0, '本机长轮询在途,宿主让位')
354
+ // 在途请求被回合事件唤醒完成,宽限窗口内仍算在场
355
+ await pending
356
+ await emitCompletedTurn(handlers)
357
+ assert.equal(spawns.length, 0, '长轮询刚完成,宽限窗口内仍由浏览器呈现')
358
+ // 推进时钟越过宽限窗口:本机视为离场,宿主接管(宁可重复不可漏)
359
+ const realNow = Date.now
360
+ Date.now = () => realNow() + CLIENT_PRESENCE_WINDOW_MS + 1
361
+ try {
362
+ await emitCompletedTurn(handlers)
363
+ } finally { Date.now = realNow }
364
+ assert.equal(spawns.length, 1, '离场超窗,宿主接管')
365
+ const projection = await pollProjection(routes, undefined)
366
+ assert.equal(projection.body.units.length, 3, '浏览器呈现通道不受让位影响')
367
+ })
368
+ })
369
+
370
+ test('Given 总开关开且本机长轮询断连 When 回合完成 Then 认可窗口内让位,超窗接管', async () => {
371
+ await withStubSpawn(async (spawns) => {
372
+ const { ctx, routes, handlers } = makeCtx()
373
+ apply(ctx)
374
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
375
+ const { pending, res } = hangingPoll(routes, '127.0.0.1')
376
+ await flushMicrotasks()
377
+ // 浏览器关闭中止请求:close 事件即时出账,最近活动定格于断连时刻
378
+ res.emit('close')
379
+ await emitCompletedTurn(handlers)
380
+ assert.equal(spawns.length, 0, '断连后认可窗口内仍算在场(边界情况宁可不弹)')
381
+ // 推进时钟越过认可窗口:宿主接管
382
+ const realNow = Date.now
383
+ Date.now = () => realNow() + CLIENT_PRESENCE_WINDOW_MS + 1
384
+ try {
385
+ await emitCompletedTurn(handlers)
386
+ } finally { Date.now = realNow }
387
+ assert.equal(spawns.length, 1, '断连超窗,宿主接管')
388
+ // 唤醒挂起的等待事务,防测试尾部悬挂 25 秒定时器
389
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
390
+ await pending
391
+ })
392
+ })
393
+
394
+ test('Given 记账轮询正常完成后再触发 close When 时钟推进越过认可窗 Then 出账幂等不双计', async () => {
395
+ await withStubSpawn(async (spawns) => {
396
+ const { ctx, routes, handlers } = makeCtx()
397
+ apply(ctx)
398
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
399
+ // 全程虚拟时钟:消解真实间隔,配平断言只依赖显式推进
400
+ const realNow = Date.now
401
+ let fake = 1000 * 1000
402
+ Date.now = () => fake
403
+ try {
404
+ const first = await browserPoll(routes, '127.0.0.1')
405
+ // 生产正常完成路径:finally 已出账,响应完成后的 close 再触发须被幂等消化;
406
+ // 若双出账致计数走负,时钟推进后本应让位的在途轮询将误弹
407
+ first.emit('close')
408
+ const second = hangingPoll(routes, '127.0.0.1')
409
+ await flushMicrotasks()
410
+ fake += CLIENT_PRESENCE_WINDOW_MS + 1
411
+ await emitCompletedTurn(handlers)
412
+ assert.equal(spawns.length, 0, '计数未被双出账破坏,在途轮询仍正常让位')
413
+ await second.pending
414
+ fake += CLIENT_PRESENCE_WINDOW_MS + 1
415
+ await emitCompletedTurn(handlers)
416
+ assert.equal(spawns.length, 1, '出账配平正确,超窗接管不受影响')
417
+ } finally { Date.now = realNow }
418
+ })
419
+ })
420
+
421
+ test('Given 双 tab 并发挂起其一断连 When 时钟推进越过认可窗 Then 在途方仍让位,全部出账后接管', async () => {
422
+ await withStubSpawn(async (spawns) => {
423
+ const { ctx, routes, handlers } = makeCtx()
424
+ apply(ctx)
425
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
426
+ const realNow = Date.now
427
+ let fake = 1000 * 1000
428
+ Date.now = () => fake
429
+ try {
430
+ const tabA = hangingPoll(routes, '127.0.0.1')
431
+ const tabB = hangingPoll(routes, '::1')
432
+ await flushMicrotasks()
433
+ // tabA 断连即时出账,tabB 独力承载在途信号
434
+ tabA.res.emit('close')
435
+ await flushMicrotasks()
436
+ fake += CLIENT_PRESENCE_WINDOW_MS + 1
437
+ await emitCompletedTurn(handlers)
438
+ assert.equal(spawns.length, 0, 'tabA 断连出账,tabB 在途,整体在场')
439
+ // 回合事件唤醒两个挂起轮询,出账后计数归零
440
+ await Promise.all([tabA.pending, tabB.pending])
441
+ fake += CLIENT_PRESENCE_WINDOW_MS + 1
442
+ await emitCompletedTurn(handlers)
443
+ assert.equal(spawns.length, 1, '全部出账,计数归零,超窗接管')
444
+ } finally { Date.now = realNow }
445
+ })
446
+ })
447
+
448
+ test('Given 总开关开且仅远程浏览器在线 When 回合完成 Then 宿主弹', async () => {
449
+ await withStubSpawn(async (spawns) => {
450
+ const { ctx, routes, handlers } = makeCtx()
451
+ apply(ctx)
452
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
453
+ await browserPoll(routes, '192.168.1.5')
454
+ await emitCompletedTurn(handlers)
455
+ assert.equal(spawns.length, 1, '远程浏览器不与宿主同机,照常弹')
456
+ assert.ok(spawns[0].args.flat().join(' ').includes('任务完成'), '通知文本应随 spawn 传出')
457
+ })
458
+ })
459
+
460
+ test('Given 总开关开且无浏览器在场 When 回合完成 Then 宿主弹', async () => {
461
+ await withStubSpawn(async (spawns) => {
462
+ const { ctx, routes, handlers } = makeCtx()
463
+ apply(ctx)
464
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
465
+ await emitCompletedTurn(handlers)
466
+ assert.equal(spawns.length, 1)
467
+ })
468
+ })
469
+
470
+ test('Given kindRoutes 名单不含 host When 总开关开 Then 不弹', async () => {
471
+ await withStubSpawn(async (spawns) => {
472
+ const { ctx, routes, handlers } = makeCtx()
473
+ apply(ctx)
474
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0, kindRoutes: { completed: ['webhook'] } })
475
+ await emitCompletedTurn(handlers)
476
+ assert.equal(spawns.length, 0, '路由名单外的宿主通道不送达')
477
+ })
478
+ })
479
+
480
+ test('Given 回退开关开 When 本机长轮询在途 Then 不弹;仅远程在线 Then 弹', async () => {
481
+ await withStubSpawn(async (spawns) => {
482
+ const local = makeCtx()
483
+ apply(local.ctx)
484
+ await postConfig(local.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
485
+ const localPoll = hangingPoll(local.routes, '::1')
486
+ await flushMicrotasks()
487
+ await emitCompletedTurn(local.handlers)
488
+ assert.equal(spawns.length, 0, '本机浏览器在途,回退不触发')
489
+ await postConfig(local.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
490
+ await localPoll.pending
491
+ // 独立实例:本机从未有浏览器,仅远程浏览器轮询过
492
+ const remote = makeCtx()
493
+ apply(remote.ctx)
494
+ await postConfig(remote.routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
495
+ await browserPoll(remote.routes, '10.0.0.8')
496
+ await emitCompletedTurn(remote.handlers)
497
+ assert.equal(spawns.length, 1, '本机无浏览器,回退触发')
498
+ })
499
+ })
500
+
501
+ test('Given 裸 GET 形态(无 cursor 或 no-cors 或跨源)When 回合完成 Then 回退不触发(伪造在场不入账)', async () => {
502
+ await withStubSpawn(async (spawns) => {
503
+ const { ctx, routes, handlers } = makeCtx()
504
+ apply(ctx)
505
+ await postConfig(routes, { hostNotifyFallback: true, minTurnDurationMs: 0 })
506
+ // 回环来源但缺 cursor:手工探测形态
507
+ await rawShapePoll(routes, '127.0.0.1', 'cors', false)
508
+ // 回环来源带 cursor 但 no-cors:同机网页 img 探活形态
509
+ await rawShapePoll(routes, '127.0.0.1', 'no-cors', true)
510
+ // 回环来源带 cursor 且 cors 但跨源 Origin:同机恶意网页标准 fetch 形态
511
+ await rawShapePoll(routes, '127.0.0.1', 'cors', true, 'https://evil.example')
512
+ await emitCompletedTurn(handlers)
513
+ assert.equal(spawns.length, 1, '伪造在场不入账,本机无真实浏览器时回退照常触发')
514
+ })
515
+ })
516
+
517
+ test('Given spawn 失败 When 回合完成 Then 无未处理拒绝且投影照常', async () => {
518
+ const rejections = []
519
+ const onUnhandled = (reason) => rejections.push(reason)
520
+ process.on('unhandledRejection', onUnhandled)
521
+ const original = __hostSpawn.impl
522
+ __hostSpawn.impl = (file, args, options, callback) => callback(new Error('no desktop'))
523
+ try {
524
+ const { ctx, routes, handlers } = makeCtx()
525
+ apply(ctx)
526
+ await postConfig(routes, { hostNotify: true, minTurnDurationMs: 0 })
527
+ handlers.get('session/event')(MAIN, { type: 'turn/start' })
528
+ handlers.get('session/event')(MAIN, turnEnd('completed'))
529
+ await flushMicrotasks()
530
+ await flushMicrotasks()
531
+ assert.equal(rejections.length, 0)
532
+ const projection = await pollProjection(routes, undefined)
533
+ assert.equal(projection.body.units.length, 1)
534
+ } finally {
535
+ process.off('unhandledRejection', onUnhandled)
536
+ __hostSpawn.impl = original
537
+ }
538
+ })
539
+
540
+ test('Given 两开关均关 When 回合完成 Then 不弹', async () => {
541
+ await withStubSpawn(async (spawns) => {
542
+ const { ctx, routes, handlers } = makeCtx()
543
+ apply(ctx)
544
+ await postConfig(routes, { minTurnDurationMs: 0 })
545
+ await emitCompletedTurn(handlers)
546
+ assert.equal(spawns.length, 0)
547
+ })
548
+ })
549
+
550
+ test('Given test-host 路由 When 合法点火 Then 返回真实结果;非法方法与跨源被拒', async () => {
551
+ await withStubSpawn(async (spawns) => {
552
+ const { ctx, routes } = makeCtx()
553
+ apply(ctx)
554
+ const ok = makeRes()
555
+ await routes.get('/api/turn-notify/test-host')(makeReq('POST'), ok)
556
+ assert.equal(ok.status, 200)
557
+ assert.deepEqual(ok.body, { ok: true, detail: '已触发宿主通知' })
558
+ assert.equal(spawns.length, 1)
559
+ assert.ok(spawns[0].args.flat().join(' ').includes('[dsh]'), '测试事件文本应随 spawn 传出')
560
+ const method = makeRes()
561
+ await routes.get('/api/turn-notify/test-host')(makeReq('PUT'), method)
562
+ assert.equal(method.status, 405)
563
+ const cross = makeRes()
564
+ await routes.get('/api/turn-notify/test-host')(makeReq('POST', undefined, { origin: 'https://evil.example' }), cross)
565
+ assert.equal(cross.status, 403)
566
+ assert.equal(spawns.length, 1, '被拒请求不触发 spawn')
567
+ })
568
+ })
569
+
570
+ test('Given 测试点火在途 When 再次点火 Then 429 且不重复 spawn', async () => {
571
+ const releases = []
572
+ const original = __hostSpawn.impl
573
+ __hostSpawn.impl = (file, args, options, callback) => { releases.push(callback) }
574
+ try {
575
+ const { ctx, routes } = makeCtx()
576
+ apply(ctx)
577
+ const first = routes.get('/api/turn-notify/test-host')(makeReq('POST'), makeRes())
578
+ const secondRes = makeRes()
579
+ await routes.get('/api/turn-notify/test-host')(makeReq('POST'), secondRes)
580
+ assert.equal(secondRes.status, 429, '在途期间拒绝并发点火')
581
+ assert.equal(releases.length, 1, '在途期间不重复 spawn')
582
+ releases[0](null)
583
+ await first
584
+ // 释放后互斥复位:再次点火可正常 spawn;spawn 回执由本测试持有,先到账再释放
585
+ const third = makeRes()
586
+ const thirdCall = routes.get('/api/turn-notify/test-host')(makeReq('POST'), third)
587
+ await flushMicrotasks()
588
+ assert.equal(releases.length, 2, '互斥复位后恢复 spawn')
589
+ releases[1](null)
590
+ await thirdCall
591
+ assert.equal(third.status, 200)
592
+ } finally { __hostSpawn.impl = original }
593
+ })