@mzzsfy/dsh-turn-notify 0.7.2 → 0.7.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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/client.js +9 -0
- package/test/flow.test.mjs +66 -0
package/README.md
CHANGED
|
@@ -73,7 +73,7 @@ dsh plugin --profile web add file:./packages/dsh-turn-notify
|
|
|
73
73
|
|
|
74
74
|
## 多窗口只响一次
|
|
75
75
|
|
|
76
|
-
同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage
|
|
76
|
+
同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮仅静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现)。
|
|
77
77
|
|
|
78
78
|
## 设置面板指南
|
|
79
79
|
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -537,6 +537,9 @@ window.__ModuleLoader__.load({
|
|
|
537
537
|
}
|
|
538
538
|
// 版本缺失即异常响应:按失败退避,防游标停滞退化成紧密首拉循环
|
|
539
539
|
if (typeof payload.version !== 'number') return false
|
|
540
|
+
// 冷启动对齐:页面关闭/刷新期间错过的存量通知只补游标与完成标记,不轰炸呈现;
|
|
541
|
+
// 错过即过期的投影设计下,重开逐条回放只剩连环提示与声音,信息价值为零
|
|
542
|
+
const coldStart = projectionCursor === null
|
|
540
543
|
projectionCursor = payload.version
|
|
541
544
|
soundMapping = payload.soundMapping || {}
|
|
542
545
|
sessionHighlightEnabled = readSessionHighlightEnabled()
|
|
@@ -559,6 +562,12 @@ window.__ModuleLoader__.load({
|
|
|
559
562
|
storageState.broken = true
|
|
560
563
|
}
|
|
561
564
|
for (const unit of units) {
|
|
565
|
+
// 冷启动对齐:错过窗口期的存量通知不回放轰炸;审批与提问例外——
|
|
566
|
+
// 它们在等用户动作,静默会把待办吞掉
|
|
567
|
+
if (coldStart && unit.category !== 'approval' && unit.category !== 'ask') {
|
|
568
|
+
try { markDone(unit.id) } catch { /* 存储不可用:标记缺失由内存去重兜底,冷启动本就不发声 */ }
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
562
571
|
if (!claimEvent(unit.id)) continue
|
|
563
572
|
markDone(unit.id)
|
|
564
573
|
const channels = chooseChannels(document.hasFocus(), notificationPermission(), Date.now() - lastActionAt(), readSoundCategories(), unit.category)
|
package/test/flow.test.mjs
CHANGED
|
@@ -57,12 +57,16 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
|
|
|
57
57
|
}
|
|
58
58
|
const defaultFetch = async (path) => {
|
|
59
59
|
if (onFetch) onFetch(path)
|
|
60
|
+
// 首拉返回空投影:真实客户端启动即冷启动对齐,既有"正常消费"语义从第二次轮询起成立
|
|
61
|
+
calls += 1
|
|
62
|
+
if (calls === 1) return { ok: true, json: async () => ({ units: [], soundMapping: {}, version: 0 }) }
|
|
60
63
|
return { ok: true, json: async () => payload }
|
|
61
64
|
}
|
|
62
65
|
const factory = new Function(
|
|
63
66
|
'window', 'require', 'document', 'MutationObserver', 'fetch', 'Notification',
|
|
64
67
|
source + '\n;return null',
|
|
65
68
|
)
|
|
69
|
+
let calls = 0
|
|
66
70
|
factory(
|
|
67
71
|
windowStub,
|
|
68
72
|
requireStub,
|
|
@@ -85,10 +89,12 @@ test('broken localStorage:清理段不抛,降级发声恰好一次,第二轮不
|
|
|
85
89
|
const { mod, shown } = loadClient({ storage: new BrokenStorage(), payload: { units, soundMapping: {}, version: 1 } })
|
|
86
90
|
const { poll, storageState } = mod.__test
|
|
87
91
|
await poll()
|
|
92
|
+
await poll()
|
|
88
93
|
assert.equal(storageState.broken, true)
|
|
89
94
|
// 两事件各发声一次,清理段未抛出控制流到达 claimEvent
|
|
90
95
|
assert.equal(shown.length, units.length)
|
|
91
96
|
await poll()
|
|
97
|
+
await poll()
|
|
92
98
|
// 投影窗口内第二轮 poll 同事件不再发声
|
|
93
99
|
assert.equal(shown.length, units.length)
|
|
94
100
|
})
|
|
@@ -98,11 +104,13 @@ test('正常 localStorage:唯一发声,完成标记写入,残留锁被清理', a
|
|
|
98
104
|
const { mod, shown } = loadClient({ storage, payload: { units, soundMapping: {}, version: 1 } })
|
|
99
105
|
const { poll } = mod.__test
|
|
100
106
|
await poll()
|
|
107
|
+
await poll()
|
|
101
108
|
assert.equal(shown.length, units.length)
|
|
102
109
|
assert.equal(storage.getItem('turn-notify:lock:stale'), null)
|
|
103
110
|
assert.notEqual(storage.getItem('turn-notify:done:u1'), null)
|
|
104
111
|
assert.notEqual(storage.getItem('turn-notify:done:u2'), null)
|
|
105
112
|
await poll()
|
|
113
|
+
await poll()
|
|
106
114
|
// 完成标记生效,第二轮不重复发声
|
|
107
115
|
assert.equal(shown.length, units.length)
|
|
108
116
|
})
|
|
@@ -110,10 +118,55 @@ test('正常 localStorage:唯一发声,完成标记写入,残留锁被清理', a
|
|
|
110
118
|
test('页内通知经公共组件:文案与展示期随事件传入', async () => {
|
|
111
119
|
const { mod, shown } = loadClient({ storage: new FakeStorage(), payload: { units, soundMapping: {}, version: 1 } })
|
|
112
120
|
await mod.__test.poll()
|
|
121
|
+
await mod.__test.poll()
|
|
113
122
|
assert.deepEqual(shown.map((call) => call.text), units.map((unit) => unit.text))
|
|
114
123
|
assert.ok(shown.every((call) => call.opts && call.opts.holdMs === 6 * 1000))
|
|
115
124
|
})
|
|
116
125
|
|
|
126
|
+
test('Given 冷启动首拉带存量通知 When poll Then 静默对齐不呈现且完成标记写入', async () => {
|
|
127
|
+
const storage = new FakeStorage()
|
|
128
|
+
const { mod, shown } = loadClient({
|
|
129
|
+
storage,
|
|
130
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ units, soundMapping: {}, version: 1 }) }),
|
|
131
|
+
})
|
|
132
|
+
await mod.__test.poll()
|
|
133
|
+
await mod.__test.poll()
|
|
134
|
+
assert.equal(shown.length, 0, '冷启动对齐不应轰炸呈现')
|
|
135
|
+
assert.notEqual(storage.getItem('turn-notify:done:u1'), null, '静默对齐仍需写完成标记防其他窗口回放')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('Given 冷启动存量含审批与提问 When poll Then 照常呈现待办', async () => {
|
|
139
|
+
const todoUnits = [
|
|
140
|
+
{ id: 'u-ap', category: 'approval', text: '[dsh] 等待审批: t1' },
|
|
141
|
+
{ id: 'u-ask', category: 'ask', text: '[dsh] AI 提问: t2' },
|
|
142
|
+
]
|
|
143
|
+
const { mod, shown } = loadClient({
|
|
144
|
+
storage: new FakeStorage(),
|
|
145
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ units: todoUnits, soundMapping: {}, version: 1 }) }),
|
|
146
|
+
})
|
|
147
|
+
await mod.__test.poll()
|
|
148
|
+
await mod.__test.poll()
|
|
149
|
+
assert.deepEqual(shown.map((call) => call.text), todoUnits.map((unit) => unit.text), '等待用户动作的通知不因冷启动静默')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('Given 冷启动已对齐 When 新通知到达 Then 正常呈现', async () => {
|
|
153
|
+
let version = 1
|
|
154
|
+
let payloadUnits = units
|
|
155
|
+
const storage = new FakeStorage()
|
|
156
|
+
const { mod, shown } = loadClient({
|
|
157
|
+
storage,
|
|
158
|
+
fetchImpl: async () => ({ ok: true, json: async () => ({ units: payloadUnits, soundMapping: {}, version }) }),
|
|
159
|
+
})
|
|
160
|
+
await mod.__test.poll()
|
|
161
|
+
await mod.__test.poll()
|
|
162
|
+
assert.equal(shown.length, 0)
|
|
163
|
+
version = 2
|
|
164
|
+
payloadUnits = [...units, { id: 'u3', category: 'completed', text: '[dsh] 任务完成: t3' }]
|
|
165
|
+
await mod.__test.poll()
|
|
166
|
+
await mod.__test.poll()
|
|
167
|
+
assert.deepEqual(shown.map((call) => call.text), ['[dsh] 任务完成: t3'])
|
|
168
|
+
})
|
|
169
|
+
|
|
117
170
|
// ---- 会话行高亮:认领后按投影 session 字段在侧边栏定位行并挂类 ----
|
|
118
171
|
|
|
119
172
|
// 最小侧边栏 DOM:列表容器(多标题)→ 多会话行(各单标题),classList 记录变更,click listener 捕获;
|
|
@@ -174,6 +227,7 @@ test('Given 通知带 session 标题 When 认领 Then 会话行挂上高亮类',
|
|
|
174
227
|
document: { title: '会话乙 — DeepSeek Harness', ...dom },
|
|
175
228
|
})
|
|
176
229
|
await mod.__test.poll()
|
|
230
|
+
await mod.__test.poll()
|
|
177
231
|
assert.ok(dom.rows[0].classList.set.has('tn-sess-hl'), '会话行未挂高亮类')
|
|
178
232
|
assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '高亮类缺少分类色')
|
|
179
233
|
})
|
|
@@ -189,6 +243,7 @@ test('Given 通知的是当前查看的会话 When 认领 Then 不挂高亮', as
|
|
|
189
243
|
document: { title: '会话甲 — DeepSeek Harness', ...dom },
|
|
190
244
|
})
|
|
191
245
|
await mod.__test.poll()
|
|
246
|
+
await mod.__test.poll()
|
|
192
247
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '当前查看的会话不应闪烁')
|
|
193
248
|
})
|
|
194
249
|
|
|
@@ -203,6 +258,7 @@ test('Given 当前会话判定不受标题闪烁前缀干扰 When 认领 Then
|
|
|
203
258
|
document: { title: '⏳ 会话甲 — DeepSeek Harness', ...dom },
|
|
204
259
|
})
|
|
205
260
|
await mod.__test.poll()
|
|
261
|
+
await mod.__test.poll()
|
|
206
262
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '闪烁前缀不应破坏当前会话判定')
|
|
207
263
|
})
|
|
208
264
|
|
|
@@ -217,6 +273,7 @@ test('Given 失焦且通知的是本标签页会话 When 认领 Then 挂高亮',
|
|
|
217
273
|
document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
|
|
218
274
|
})
|
|
219
275
|
await mod.__test.poll()
|
|
276
|
+
await mod.__test.poll()
|
|
220
277
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true, '失焦期间的本会话通知应闪烁提醒')
|
|
221
278
|
})
|
|
222
279
|
|
|
@@ -232,6 +289,7 @@ test('Given 失焦期间挂上的当前会话高亮 When 页面重新可见 Then
|
|
|
232
289
|
document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
|
|
233
290
|
})
|
|
234
291
|
await mod.__test.poll()
|
|
292
|
+
await mod.__test.poll()
|
|
235
293
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
|
|
236
294
|
assert.equal(dom.rows[1].classList.set.has('tn-sess-hl'), true)
|
|
237
295
|
mod.__test.start()
|
|
@@ -257,6 +315,7 @@ test('Given 失焦挂上的当前会话高亮 When 窗口重获焦点 Then 提
|
|
|
257
315
|
document: { title: '会话甲 — DeepSeek Harness', hasFocus: () => false, ...dom },
|
|
258
316
|
})
|
|
259
317
|
await mod.__test.poll()
|
|
318
|
+
await mod.__test.poll()
|
|
260
319
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
|
|
261
320
|
mod.__test.start()
|
|
262
321
|
const focusHandlers = (winStub.listeners.focus || []).slice()
|
|
@@ -278,6 +337,7 @@ test('Given 行处于运行状态 When 通知认领 Then 不挂高亮', async ()
|
|
|
278
337
|
document: dom,
|
|
279
338
|
})
|
|
280
339
|
await mod.__test.poll()
|
|
340
|
+
await mod.__test.poll()
|
|
281
341
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '运行中的会话不应闪烁')
|
|
282
342
|
})
|
|
283
343
|
|
|
@@ -292,10 +352,12 @@ test('Given 已挂高亮 When 行进入运行状态 Then 清除闪烁', async ()
|
|
|
292
352
|
document: dom,
|
|
293
353
|
})
|
|
294
354
|
await mod.__test.poll()
|
|
355
|
+
await mod.__test.poll()
|
|
295
356
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), true)
|
|
296
357
|
// 会话又开跑:状态点文案回到行内
|
|
297
358
|
dom.rows[0].textContent = '进行中会话甲 5分钟'
|
|
298
359
|
await mod.__test.poll()
|
|
360
|
+
await mod.__test.poll()
|
|
299
361
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false, '会话运行后应停止闪烁')
|
|
300
362
|
})
|
|
301
363
|
|
|
@@ -310,6 +372,7 @@ test('Given 通知标题字段缺失 When 认领 Then 不挂高亮类且链路
|
|
|
310
372
|
document: dom,
|
|
311
373
|
})
|
|
312
374
|
await mod.__test.poll()
|
|
375
|
+
await mod.__test.poll()
|
|
313
376
|
assert.equal(shown.length, 1)
|
|
314
377
|
assert.equal(dom.rows[0].classList.set.has('tn-sess-hl'), false)
|
|
315
378
|
})
|
|
@@ -330,6 +393,7 @@ test('Given 短标题先于长前缀行匹配 When 挂类 Then 短标题精确
|
|
|
330
393
|
document: dom,
|
|
331
394
|
})
|
|
332
395
|
await mod.__test.poll()
|
|
396
|
+
await mod.__test.poll()
|
|
333
397
|
assert.ok(dom.rows[0].classList.set.has('tn-sess-hl--completed'), '长标题行未挂类')
|
|
334
398
|
assert.ok(dom.rows[1].classList.set.has('tn-sess-hl--ask'), '短标题行未挂到自身行')
|
|
335
399
|
assert.equal(dom.rows[1].classList.set.has('tn-sess-hl--completed'), false, '长标题类误挂到短标题行')
|
|
@@ -351,6 +415,7 @@ test('Given 两行各自高亮 When 点击其中一行 Then 仅该行清除且
|
|
|
351
415
|
document: dom,
|
|
352
416
|
})
|
|
353
417
|
await mod.__test.poll()
|
|
418
|
+
await mod.__test.poll()
|
|
354
419
|
mod.__test.start()
|
|
355
420
|
const clickListeners = dom.listeners.click || []
|
|
356
421
|
assert.ok(clickListeners.length > 0, 'click listener 未挂载')
|
|
@@ -438,6 +503,7 @@ test('页内提示通道独立开关:关闭后投影事件不再弹页内提示'
|
|
|
438
503
|
const { mod, shown } = loadClient({ storage, payload: { units, soundMapping: {}, version: 1 } })
|
|
439
504
|
const { poll } = mod.__test
|
|
440
505
|
await poll()
|
|
506
|
+
await poll()
|
|
441
507
|
assert.equal(shown.length, 0)
|
|
442
508
|
// 完成标记已写:事件被认领消费,仅通道被关
|
|
443
509
|
assert.notEqual(storage.getItem('turn-notify:done:u1'), null)
|