@mzzsfy/dsh-turn-notify 0.10.0 → 0.11.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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/client.js +170 -1
- package/src/core.mjs +7 -1
- package/src/index.js +1 -0
- package/test/client-fold.test.mjs +405 -0
- package/test/core.test.mjs +3 -0
- package/test/flow.test.mjs +15 -5
package/README.md
CHANGED
|
@@ -97,6 +97,10 @@ turn-notify:
|
|
|
97
97
|
|
|
98
98
|
通知实际送达时,侧边栏会话列表中对应会话行以背景脉冲闪烁强调,六类各一色(完成绿/出错红/提问蓝/中断橙/审批黄/上限紫),点击该会话行即停止。开关在面板"偏好"页首项(独立开关,本机生效,各浏览器独立),默认开启。并行多会话时用于快速定位状态刚更新的会话:运行中的会话不闪烁(与 dsh 原生状态点分工——状态点表达运行中,闪烁表达已更新待查看),会话重新进入运行状态时闪烁自动让位;聚焦时正在查看的会话不闪烁,失焦期间本会话的通知照常闪烁,返回窗口即自动消失(回来即已读,焦点与页面可见双通道)。行定位按会话标题文本匹配,标题默认与侧边栏行同源(session-title 投影);该服务缺失或不可用(如伪会话无事件日志)时回落会话事件内存储的标题,可能与侧边栏不同步。会话行未渲染(列表折叠/懒加载未滚动到)时该次高亮静默跳过。重开页面的冷启动静默对齐同样挂高亮(见"多窗口只响一次"),重开即见哪些会话有动静。
|
|
99
99
|
|
|
100
|
+
## 工作区文件夹运行标记
|
|
101
|
+
|
|
102
|
+
侧边栏会话列表按工作区分组(官方 WorkspaceBrowser 分组模式)时,有运行中会话的工作区文件夹组头显示脉冲运行点并给文件夹图标着色,游离(未归属任何工作区)的可见运行中会话使末位「未分组」组头同样标记——折叠与展开同样生效。归属与运行态一律取服务快照投影,DOM 仅按下标对位官方组容器;组容器数漂移(单列表模式/搜索态/结构变更)即清除标记不误标。图标着色让位官方 folderActive(展开且含当前会话)态。开关在面板"偏好"页"会话列表"卡(存宿主配置,各浏览器共用),默认开启,变更刷新页面生效。
|
|
103
|
+
|
|
100
104
|
## 多窗口只响一次
|
|
101
105
|
|
|
102
106
|
同一地址(同源)打开的多个窗口共享一次呈现:各窗口以长轮询挂起在 host 的通知队列上(事件产生或配置变更即时返回,空闲期单次挂起至多数十秒,失败按指数退避重连),经 localStorage 协调,仅一个窗口抢先呈现——发声并弹出页内提示、系统弹窗、高亮会话行,其余窗口全部跳过,不止声音。通知在产生后的下一次唤醒即送达,通常亚秒级;不同浏览器或不同地址打开的窗口各自发声互不感知。窗口关闭/刷新期间错过的通知不做重开回放:启动首轮静默对齐游标与完成标记,不逐条补发提示与声音(等待用户动作的审批与提问例外,照常呈现);未读会话行高亮不在静默范围,照常挂上。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -864,6 +864,55 @@ window.__ModuleLoader__.load({
|
|
|
864
864
|
row.className = row.className.split(' ').filter((name) => name !== SESSION_HL_CLASS && name.indexOf(SESSION_HL_CLASS + '--') !== 0).join(' ')
|
|
865
865
|
}
|
|
866
866
|
|
|
867
|
+
// ---- 工作区文件夹运行标记 ----
|
|
868
|
+
|
|
869
|
+
// 官方组容器 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定)与自家标记属性;
|
|
870
|
+
// 扫描合并窗口覆盖聊天流式输出的高频 DOM mutation
|
|
871
|
+
const FOLD_SECTION_SELECTOR = 'div[class$="_groupSection"]'
|
|
872
|
+
const FOLD_RUNNING_FLAG = 'data-tn-folder-running'
|
|
873
|
+
const FOLD_SCAN_DELAY_MS = 50
|
|
874
|
+
|
|
875
|
+
// 运行标记状态:官方 dsh-client-ui-workspace 分组语义的投影——
|
|
876
|
+
// 归属走 workspace 账本 sessionIds 一级映射(官方 owningGroupKey 同构,无 cwd 回退),
|
|
877
|
+
// 游离会话入末位未分组桶;可见性镜像官方 sessionVisible(subagent / 已归档 /
|
|
878
|
+
// 空白且非当前排除),组内任一可见会话 running 即该工作区运行中。
|
|
879
|
+
// ids 保工作区登记序(官方组渲染序 = 快照 items 序),供 DOM 组容器按下标对位
|
|
880
|
+
function folderRunningState(sessionsState, workspacesState) {
|
|
881
|
+
const byId = (sessionsState && sessionsState.byId) || {}
|
|
882
|
+
const ids = (sessionsState && sessionsState.ids) || []
|
|
883
|
+
const current = sessionsState ? sessionsState.current : undefined
|
|
884
|
+
const workspaces = workspacesState && Array.isArray(workspacesState.items) ? workspacesState.items : []
|
|
885
|
+
const archived = new Set((workspacesState && workspacesState.archivedSessionIds) || [])
|
|
886
|
+
const visible = (row) => Boolean(row)
|
|
887
|
+
&& row.origin !== 'subagent'
|
|
888
|
+
&& !archived.has(row.id)
|
|
889
|
+
&& (!row.blank || row.id === current)
|
|
890
|
+
const running = new Set()
|
|
891
|
+
const accounted = new Set()
|
|
892
|
+
const workspaceIds = []
|
|
893
|
+
for (const workspace of workspaces) {
|
|
894
|
+
// 主键不做防御过滤,与官方 groupByWorkspace 同构:items 长度即组容器对位基准,
|
|
895
|
+
// 过滤会让 ids 短于容器数,恰差一时错位成「末位未分组桶」对位
|
|
896
|
+
workspaceIds.push(workspace.workspaceId)
|
|
897
|
+
const sessionIds = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []
|
|
898
|
+
for (const sessionId of sessionIds) {
|
|
899
|
+
const row = byId[sessionId]
|
|
900
|
+
if (!row) continue
|
|
901
|
+
accounted.add(sessionId)
|
|
902
|
+
if (row.running && visible(row)) running.add(workspace.workspaceId)
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
let ungrouped = false
|
|
906
|
+
for (const sessionId of ids) {
|
|
907
|
+
if (accounted.has(sessionId)) continue
|
|
908
|
+
const row = byId[sessionId]
|
|
909
|
+
if (row && row.running && visible(row)) ungrouped = true
|
|
910
|
+
}
|
|
911
|
+
return { ids: workspaceIds, running, ungrouped }
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const FOLD_SWITCH_TITLE = '侧边栏会话列表按工作区分组时,有运行中会话的文件夹组头显示运行点并给文件夹图标着色,文件夹折叠与展开同样生效;游离会话所在的「未分组」组同样标记;单列表模式下无此标记。停用即全部隐藏,刷新页面生效。'
|
|
915
|
+
|
|
867
916
|
// ---- 点击直达会话:页内卡片与系统弹窗共用 ----
|
|
868
917
|
|
|
869
918
|
// 按通知单元激活会话:聚焦窗口并模拟点击侧边栏会话行(官方行点击即切换会话);
|
|
@@ -1019,6 +1068,7 @@ window.__ModuleLoader__.load({
|
|
|
1019
1068
|
suppressSubagentWake: true,
|
|
1020
1069
|
hostNotify: false,
|
|
1021
1070
|
hostNotifyFallback: false,
|
|
1071
|
+
folderRunningEnabled: true,
|
|
1022
1072
|
enabled: Object.fromEntries(CATEGORIES.map((key) => [key, true])),
|
|
1023
1073
|
imTargets: [],
|
|
1024
1074
|
}
|
|
@@ -1136,6 +1186,18 @@ window.__ModuleLoader__.load({
|
|
|
1136
1186
|
'@media (prefers-reduced-motion: reduce) {',
|
|
1137
1187
|
' .tn-sess-hl { animation:none; background-color:color-mix(in srgb, var(--tn-sess-color, #4c8dff) 50%, var(--dsw-alias-bg-base, #202020) 50%); }',
|
|
1138
1188
|
'}',
|
|
1189
|
+
// 工作区文件夹运行标记:官方组容器自带 position:relative,运行点绝对定位于
|
|
1190
|
+
// 组容器左缘、垂直对齐组头行中线(行高 34px 之半即 top 17px,落于组头行左内边距
|
|
1191
|
+
// 区),零布局位移;图标着色让位官方 folderActive(展开含当前)态;色值取官方
|
|
1192
|
+
// ongoing 运行点的实际色值(--dsh-state-ongoing 在官方仅定义于其状态点组件局部,
|
|
1193
|
+
// 组容器上下文读不到,直接引用其回退源)
|
|
1194
|
+
'div[class$="_groupSection"][data-tn-folder-running] { --tn-fold-running-color: var(--dsw-static-deepseek-450); }',
|
|
1195
|
+
'div[class$="_groupSection"][data-tn-folder-running]::before { content:""; position:absolute; left:0; top:17px; width:5px; height:5px;',
|
|
1196
|
+
' margin-top:-2.5px; border-radius:999px; background:var(--tn-fold-running-color);',
|
|
1197
|
+
' animation:tn-fold-running-pulse 1.2s ease-in-out infinite; }',
|
|
1198
|
+
'div[class$="_groupSection"][data-tn-folder-running] span[class$="_folder"]:not([class$="_folderActive"]) { color:var(--tn-fold-running-color); }',
|
|
1199
|
+
'@keyframes tn-fold-running-pulse { 0%, 100% { opacity:1; } 50% { opacity:.35; } }',
|
|
1200
|
+
'@media (prefers-reduced-motion: reduce) { div[class$="_groupSection"][data-tn-folder-running]::before { animation:none; } }',
|
|
1139
1201
|
].join('\n')
|
|
1140
1202
|
|
|
1141
1203
|
function h(type, props) {
|
|
@@ -1408,6 +1470,21 @@ window.__ModuleLoader__.load({
|
|
|
1408
1470
|
})
|
|
1409
1471
|
}
|
|
1410
1472
|
|
|
1473
|
+
// 文件夹运行标记开关:乐观回填,服务端失败回滚;变更刷新页面生效(标记
|
|
1474
|
+
// 注入在插件激活时按配置装配)
|
|
1475
|
+
async function toggleFolderRunning(checked) {
|
|
1476
|
+
const previous = config.folderRunningEnabled
|
|
1477
|
+
setConfig({ ...config, folderRunningEnabled: checked })
|
|
1478
|
+
try {
|
|
1479
|
+
const res = await api('/api/turn-notify/config', { method: 'POST', body: JSON.stringify({ folderRunningEnabled: checked }) })
|
|
1480
|
+
setConfig({ ...DEFAULT_CONFIG, ...res })
|
|
1481
|
+
patch('工作区文件夹运行标记已' + (res.folderRunningEnabled !== false ? '启用' : '停用') + ',刷新页面后生效')
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
setConfig((prev) => ({ ...prev, folderRunningEnabled: previous }))
|
|
1484
|
+
patch('切换失败:' + (error && error.message ? error.message : String(error)), 'error')
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1411
1488
|
async function requestPermission() {
|
|
1412
1489
|
try {
|
|
1413
1490
|
const next = await Notification.requestPermission()
|
|
@@ -1794,6 +1871,20 @@ window.__ModuleLoader__.load({
|
|
|
1794
1871
|
' 弹窗不可用时以标题闪烁替代'),
|
|
1795
1872
|
]),
|
|
1796
1873
|
) : null,
|
|
1874
|
+
activeTab === '偏好' ? h('div', { className: 'tn-card' },
|
|
1875
|
+
h('div', { className: 'tn-card__head' },
|
|
1876
|
+
h('span', { className: 'tn-card__title' }, '会话列表'),
|
|
1877
|
+
h('span', { className: 'tn-card__sub' }, '存宿主配置,各浏览器共用'),
|
|
1878
|
+
),
|
|
1879
|
+
field('运行标记', [
|
|
1880
|
+
h('label', { className: 'tn-meta tn-switch', title: FOLD_SWITCH_TITLE },
|
|
1881
|
+
...switchToggle({
|
|
1882
|
+
checked: config.folderRunningEnabled !== false,
|
|
1883
|
+
onChange: (e) => void toggleFolderRunning(e.target.checked),
|
|
1884
|
+
}),
|
|
1885
|
+
' 有运行中会话的文件夹显示运行点'),
|
|
1886
|
+
], '侧边栏按工作区分组时,运行中的文件夹组头显示脉冲运行点并给图标着色,折叠与展开同样生效;变更刷新页面生效。'),
|
|
1887
|
+
) : null,
|
|
1797
1888
|
activeTab === '音效' ? [
|
|
1798
1889
|
h('div', { className: 'tn-card' },
|
|
1799
1890
|
h('div', { className: 'tn-card__head' },
|
|
@@ -2001,17 +2092,95 @@ window.__ModuleLoader__.load({
|
|
|
2001
2092
|
}
|
|
2002
2093
|
|
|
2003
2094
|
return {
|
|
2004
|
-
inject: ['slots'],
|
|
2095
|
+
inject: ['slots', 'sessions', 'workspaces'],
|
|
2005
2096
|
apply(ctx) {
|
|
2097
|
+
const sessions = ctx.get('sessions')
|
|
2098
|
+
const workspaces = ctx.get('workspaces')
|
|
2006
2099
|
// 样式挂载宿主文档级:通知栈在面板未打开时也要有完整样式
|
|
2007
2100
|
ctx.effect(() => {
|
|
2008
2101
|
const style = document.createElement('style')
|
|
2102
|
+
// 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
|
|
2103
|
+
style.setAttribute('data-plugin', '@mzzsfy/dsh-turn-notify')
|
|
2009
2104
|
style.textContent = CSS
|
|
2010
2105
|
document.head.appendChild(style)
|
|
2011
2106
|
return () => style.remove()
|
|
2012
2107
|
}, 'turn-notify styles')
|
|
2013
2108
|
// 激活即轮询:通知链路不依赖设置面板是否打开过
|
|
2014
2109
|
start()
|
|
2110
|
+
// 工作区文件夹运行标记:服务快照投影 + DOM 组容器下标对位,状态变化经
|
|
2111
|
+
// 订阅与 DOM 变更(合并窗口)双通道驱动;开关读宿主配置,变更刷新页面生效
|
|
2112
|
+
let foldEnabled = null
|
|
2113
|
+
ctx.effect(() => {
|
|
2114
|
+
if (typeof MutationObserver === 'undefined' || typeof document.querySelectorAll !== 'function') return
|
|
2115
|
+
let disposed = false
|
|
2116
|
+
let timer = null
|
|
2117
|
+
function clearAll() {
|
|
2118
|
+
document.querySelectorAll('[' + FOLD_RUNNING_FLAG + ']').forEach((section) => {
|
|
2119
|
+
section.removeAttribute(FOLD_RUNNING_FLAG)
|
|
2120
|
+
})
|
|
2121
|
+
}
|
|
2122
|
+
function scan() {
|
|
2123
|
+
const sections = document.querySelectorAll(FOLD_SECTION_SELECTOR)
|
|
2124
|
+
if (sections.length === 0) {
|
|
2125
|
+
clearAll()
|
|
2126
|
+
return
|
|
2127
|
+
}
|
|
2128
|
+
// 开关未决时仅清除:终态正确且停用用户刷新页面不闪现标记
|
|
2129
|
+
if (foldEnabled === null) {
|
|
2130
|
+
clearAll()
|
|
2131
|
+
return
|
|
2132
|
+
}
|
|
2133
|
+
const state = foldEnabled
|
|
2134
|
+
? folderRunningState(sessions.list.getSnapshot(), workspaces.list.getSnapshot())
|
|
2135
|
+
: { ids: [], running: new Set(), ungrouped: false }
|
|
2136
|
+
const expected = state.ids.length
|
|
2137
|
+
// 数量不变量:组容器数 = 工作区数 或 工作区数+1(末位未分组桶);
|
|
2138
|
+
// 漂移(单列表模式 / 搜索态 / 官方结构变更)即清除标记,不误标
|
|
2139
|
+
const ungroupedLast = sections.length === expected + 1
|
|
2140
|
+
if (sections.length !== expected && !ungroupedLast) {
|
|
2141
|
+
clearAll()
|
|
2142
|
+
return
|
|
2143
|
+
}
|
|
2144
|
+
sections.forEach((section, index) => {
|
|
2145
|
+
const running = index < expected ? state.running.has(state.ids[index]) : ungroupedLast && state.ungrouped
|
|
2146
|
+
// 同值跳过:流式输出期间扫描高频,重复写属性触发无谓的 CSS 重匹配
|
|
2147
|
+
if (running === section.hasAttribute(FOLD_RUNNING_FLAG)) return
|
|
2148
|
+
if (running) section.setAttribute(FOLD_RUNNING_FLAG, '')
|
|
2149
|
+
else section.removeAttribute(FOLD_RUNNING_FLAG)
|
|
2150
|
+
})
|
|
2151
|
+
}
|
|
2152
|
+
let scheduled = false
|
|
2153
|
+
const schedule = () => {
|
|
2154
|
+
if (disposed || scheduled) return
|
|
2155
|
+
scheduled = true
|
|
2156
|
+
timer = setTimeout(() => {
|
|
2157
|
+
scheduled = false
|
|
2158
|
+
timer = null
|
|
2159
|
+
if (!disposed) scan()
|
|
2160
|
+
}, FOLD_SCAN_DELAY_MS)
|
|
2161
|
+
}
|
|
2162
|
+
api('/api/turn-notify/config')
|
|
2163
|
+
.then((payload) => {
|
|
2164
|
+
foldEnabled = payload ? payload.folderRunningEnabled !== false : true
|
|
2165
|
+
})
|
|
2166
|
+
.catch(() => {
|
|
2167
|
+
foldEnabled = true
|
|
2168
|
+
})
|
|
2169
|
+
.finally(schedule)
|
|
2170
|
+
const unsubscribeSessions = sessions.list.subscribe(schedule)
|
|
2171
|
+
const unsubscribeWorkspaces = workspaces.list.subscribe(schedule)
|
|
2172
|
+
const observer = new MutationObserver(schedule)
|
|
2173
|
+
observer.observe(document.documentElement, { childList: true, subtree: true })
|
|
2174
|
+
schedule()
|
|
2175
|
+
return () => {
|
|
2176
|
+
disposed = true
|
|
2177
|
+
if (timer !== null) clearTimeout(timer)
|
|
2178
|
+
observer.disconnect()
|
|
2179
|
+
unsubscribeSessions()
|
|
2180
|
+
unsubscribeWorkspaces()
|
|
2181
|
+
clearAll()
|
|
2182
|
+
}
|
|
2183
|
+
}, 'turn-notify folder running mark')
|
|
2015
2184
|
ctx.slots.inject('settings.section', () =>
|
|
2016
2185
|
ctx.slots.register(
|
|
2017
2186
|
{ name: 'settings.section', id: 'turn-notify', order: 41, label: '消息通知' },
|
package/src/core.mjs
CHANGED
|
@@ -641,7 +641,7 @@ export function imBoundBotIds(list) {
|
|
|
641
641
|
// 返回归一化后的补丁。
|
|
642
642
|
export function validateConfigPatch(patch) {
|
|
643
643
|
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) return { ok: false, reason: '补丁须为对象' }
|
|
644
|
-
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback']
|
|
644
|
+
const known = ['webhookUrl', 'minTurnDurationMs', 'rootsOnly', 'suppressSubagentWake', 'enabled', 'imTargets', 'kindRoutes', 'hostNotify', 'hostNotifyFallback', 'folderRunningEnabled']
|
|
645
645
|
for (const key of Object.keys(patch)) {
|
|
646
646
|
if (known.indexOf(key) < 0) return { ok: false, reason: '未知配置项: ' + key }
|
|
647
647
|
}
|
|
@@ -702,6 +702,10 @@ export function validateConfigPatch(patch) {
|
|
|
702
702
|
if (typeof patch.hostNotifyFallback !== 'boolean') return { ok: false, reason: 'hostNotifyFallback 须为布尔' }
|
|
703
703
|
next.hostNotifyFallback = patch.hostNotifyFallback
|
|
704
704
|
}
|
|
705
|
+
if ('folderRunningEnabled' in patch) {
|
|
706
|
+
if (typeof patch.folderRunningEnabled !== 'boolean') return { ok: false, reason: 'folderRunningEnabled 须为布尔' }
|
|
707
|
+
next.folderRunningEnabled = patch.folderRunningEnabled
|
|
708
|
+
}
|
|
705
709
|
if ('enabled' in patch) {
|
|
706
710
|
const enabled = patch.enabled
|
|
707
711
|
if (enabled === null || typeof enabled !== 'object' || Array.isArray(enabled)) return { ok: false, reason: 'enabled 须为对象' }
|
|
@@ -767,6 +771,7 @@ export function resolvedConfig(settings) {
|
|
|
767
771
|
suppressSubagentWake: source.suppressSubagentWake !== false,
|
|
768
772
|
hostNotify: source.hostNotify === true,
|
|
769
773
|
hostNotifyFallback: source.hostNotifyFallback === true,
|
|
774
|
+
folderRunningEnabled: source.folderRunningEnabled !== false,
|
|
770
775
|
enabled: Object.fromEntries(CATEGORIES.map((key) => [key, enabled[key] !== false])),
|
|
771
776
|
soundMapping,
|
|
772
777
|
imTargets: normalizeImTargets(source.imTargets),
|
|
@@ -783,6 +788,7 @@ export function publicConfig(settings) {
|
|
|
783
788
|
suppressSubagentWake: resolved.suppressSubagentWake,
|
|
784
789
|
hostNotify: resolved.hostNotify,
|
|
785
790
|
hostNotifyFallback: resolved.hostNotifyFallback,
|
|
791
|
+
folderRunningEnabled: resolved.folderRunningEnabled,
|
|
786
792
|
enabled: resolved.enabled,
|
|
787
793
|
soundMapping: resolved.soundMapping,
|
|
788
794
|
imTargets: resolved.imTargets,
|
package/src/index.js
CHANGED
|
@@ -94,6 +94,7 @@ const SETTINGS_SCHEMA = z.object({
|
|
|
94
94
|
suppressSubagentWake: z.boolean().default(true).description('子代理相关回合不通知(仅任务完成类):后台委托未收尾的回合与收尾唤醒的回合'),
|
|
95
95
|
hostNotify: z.boolean().default(false).description('宿主机桌面通知:通知触发时由宿主进程弹系统级通知(osascript/notify-send/PowerShell toast);浏览器优先,本机浏览器在场(2 秒内有在途长轮询)时由浏览器呈现宿主不重复弹,离场时宿主补位'),
|
|
96
96
|
hostNotifyFallback: z.boolean().default(false).description('宿主通知回退:本机浏览器在场时由浏览器呈现,离场(2 秒内无在途长轮询)时宿主补位;与总开关判定一致,任一开启即生效'),
|
|
97
|
+
folderRunningEnabled: z.boolean().default(true).description('工作区文件夹运行标记:侧边栏分组模式下,有运行中会话的文件夹组头显示运行点并给图标着色;关闭后标记整体不渲染,变更刷新页面生效'),
|
|
97
98
|
enabled: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.boolean().default(true)]))).description('六类事件独立开关:完成/出错/被中断/等待审批/AI 提问/达到上限'),
|
|
98
99
|
soundMapping: z.object(Object.fromEntries(CATEGORIES.map((key) => [key, z.string().default('')]))).description('每类事件的声音映射,空为内置默认,非空为内置音名或上传音效 id'),
|
|
99
100
|
imTargets: z.array(z.object({ botId: z.string().default(''), targetId: z.string().default('') })).default([]).description('dsh-im 推送目标列表,空数组禁用 IM 通道'),
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
// 工作区文件夹运行标记:纯函数投影切片 + effect 扫描接线测试。
|
|
2
|
+
// 扫描逻辑闭包在 apply 的 effect 内,经捕获 effect 函数 + DOM 桩驱动,
|
|
3
|
+
// 锁定数量不变量守卫、下标对位、开关兜底与卸载清理。
|
|
4
|
+
import test from 'node:test'
|
|
5
|
+
import assert from 'node:assert/strict'
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { mock } from 'node:test'
|
|
10
|
+
|
|
11
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
12
|
+
const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
13
|
+
|
|
14
|
+
// ── 纯函数切片:常量与 folderRunningState(无外部依赖) ──
|
|
15
|
+
|
|
16
|
+
const PURE_START = CLIENT_SRC.indexOf('const FOLD_SECTION_SELECTOR')
|
|
17
|
+
const PURE_END = CLIENT_SRC.indexOf('// ---- 点击直达会话')
|
|
18
|
+
assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 文件夹运行标记纯函数切片定位失败')
|
|
19
|
+
const pure = new Function(
|
|
20
|
+
CLIENT_SRC.slice(PURE_START, PURE_END)
|
|
21
|
+
+ '; return { folderRunningState: folderRunningState, sectionSelector: FOLD_SECTION_SELECTOR, runningFlag: FOLD_RUNNING_FLAG, scanDelayMs: FOLD_SCAN_DELAY_MS }',
|
|
22
|
+
)()
|
|
23
|
+
|
|
24
|
+
const WS = (id, sessionIds) => ({ workspaceId: id, sessionIds })
|
|
25
|
+
const ROW = (id, extra) => ({ id, ...extra })
|
|
26
|
+
|
|
27
|
+
test('folderRunningState 主路径:运行中会话所在工作区入集合,非运行中不入', () => {
|
|
28
|
+
const sessions = {
|
|
29
|
+
ids: ['a', 'b'],
|
|
30
|
+
byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }) },
|
|
31
|
+
}
|
|
32
|
+
const workspaces = { items: [WS('w1', ['a']), WS('w2', ['b'])], archivedSessionIds: [] }
|
|
33
|
+
const state = pure.folderRunningState(sessions, workspaces)
|
|
34
|
+
assert.deepEqual(state.ids, ['w1', 'w2'])
|
|
35
|
+
assert.deepEqual([...state.running], ['w1'])
|
|
36
|
+
assert.equal(state.ungrouped, false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('folderRunningState 未分组:游离运行中会话使末位未分组桶标记', () => {
|
|
40
|
+
const sessions = {
|
|
41
|
+
ids: ['a', 'stray'],
|
|
42
|
+
byId: { a: ROW('a', { running: false }), stray: ROW('stray', { running: true }) },
|
|
43
|
+
}
|
|
44
|
+
const workspaces = { items: [WS('w1', ['a'])], archivedSessionIds: [] }
|
|
45
|
+
const state = pure.folderRunningState(sessions, workspaces)
|
|
46
|
+
assert.equal(state.running.has('w1'), false)
|
|
47
|
+
assert.equal(state.ungrouped, true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('folderRunningState 排除项:subagent、已归档、空白非当前不标记,空白当前例外', () => {
|
|
51
|
+
const sessions = {
|
|
52
|
+
ids: ['sub', 'arch', 'blank', 'cur'],
|
|
53
|
+
byId: {
|
|
54
|
+
sub: ROW('sub', { running: true, origin: 'subagent' }),
|
|
55
|
+
arch: ROW('arch', { running: true }),
|
|
56
|
+
blank: ROW('blank', { running: true, blank: true }),
|
|
57
|
+
cur: ROW('cur', { running: true, blank: true }),
|
|
58
|
+
},
|
|
59
|
+
current: 'cur',
|
|
60
|
+
}
|
|
61
|
+
const workspaces = {
|
|
62
|
+
items: [WS('w1', ['sub']), WS('w2', ['arch']), WS('w3', ['blank']), WS('w4', ['cur'])],
|
|
63
|
+
archivedSessionIds: ['arch'],
|
|
64
|
+
}
|
|
65
|
+
const state = pure.folderRunningState(sessions, workspaces)
|
|
66
|
+
assert.equal(state.running.has('w1'), false, 'subagent 运行不标记')
|
|
67
|
+
assert.equal(state.running.has('w2'), false, '已归档运行不标记')
|
|
68
|
+
assert.equal(state.running.has('w3'), false, '空白非当前运行不标记')
|
|
69
|
+
assert.equal(state.running.has('w4'), true, '空白且为当前会话标记')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('folderRunningState 缺省守卫:快照缺失或账本悬空不抛错', () => {
|
|
73
|
+
assert.deepEqual(pure.folderRunningState(undefined, undefined), { ids: [], running: new Set(), ungrouped: false })
|
|
74
|
+
const state = pure.folderRunningState({ ids: ['gone'], byId: {} }, { items: [WS('w1', ['gone'])] })
|
|
75
|
+
assert.deepEqual([...state.running], [])
|
|
76
|
+
assert.equal(state.ungrouped, false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ── effect 扫描接线:DOM 桩 + 捕获 effect 函数驱动 ──
|
|
80
|
+
|
|
81
|
+
const SCAN_SETTLE_MS = pure.scanDelayMs * 4
|
|
82
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
83
|
+
|
|
84
|
+
// 组容器桩:属性集合语义与 Element 同构
|
|
85
|
+
function sectionStub() {
|
|
86
|
+
const attrs = new Set()
|
|
87
|
+
return {
|
|
88
|
+
attrs,
|
|
89
|
+
setAttribute: (name) => attrs.add(name),
|
|
90
|
+
removeAttribute: (name) => attrs.delete(name),
|
|
91
|
+
hasAttribute: (name) => attrs.has(name),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 加载 client.js 并执行 apply(mock 最小服务面),effect 函数捕获不执行;
|
|
96
|
+
// MutationObserver 经工厂形参注入(源码自由变量),document 桩承载组容器查询;
|
|
97
|
+
// 通知长轮询经挂起 fetch 静默(测试只驱动 fold effect,不消费投影)
|
|
98
|
+
function loadClient() {
|
|
99
|
+
const modules = []
|
|
100
|
+
const effects = []
|
|
101
|
+
const windowStub = {
|
|
102
|
+
__ModuleLoader__: { load: (module) => modules.push(module) },
|
|
103
|
+
addEventListener: () => {},
|
|
104
|
+
removeEventListener: () => {},
|
|
105
|
+
}
|
|
106
|
+
const reactStub = {
|
|
107
|
+
useState: (value) => [value, () => {}],
|
|
108
|
+
useEffect: () => {},
|
|
109
|
+
useSyncExternalStore: () => [],
|
|
110
|
+
useRef: (value) => ({ current: value }),
|
|
111
|
+
createElement: () => null,
|
|
112
|
+
}
|
|
113
|
+
const requireStub = (name) => {
|
|
114
|
+
if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
|
|
115
|
+
return reactStub
|
|
116
|
+
}
|
|
117
|
+
const sections = []
|
|
118
|
+
const documentStub = {
|
|
119
|
+
documentElement: {},
|
|
120
|
+
createElement: () => ({ style: {}, remove() {} }),
|
|
121
|
+
head: { appendChild: () => {} },
|
|
122
|
+
body: { appendChild: () => {} },
|
|
123
|
+
addEventListener: () => {},
|
|
124
|
+
removeEventListener: () => {},
|
|
125
|
+
querySelectorAll: (selector) => {
|
|
126
|
+
if (selector === pure.sectionSelector) return [...sections]
|
|
127
|
+
if (selector === '[' + pure.runningFlag + ']') return sections.filter((section) => section.hasAttribute(pure.runningFlag))
|
|
128
|
+
return []
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
class MutationObserverStub {
|
|
132
|
+
observe() {}
|
|
133
|
+
disconnect() {}
|
|
134
|
+
}
|
|
135
|
+
// 不传 fetch 工厂参数:源码内裸 fetch 解析到 globalThis.fetch,由 withFetch 显式 mock;
|
|
136
|
+
// 传参数会把 fetch 绑定进沙箱,mock 永远打不到
|
|
137
|
+
const factory = new Function('window', 'require', 'document', 'MutationObserver', CLIENT_SRC + '\n;return null')
|
|
138
|
+
factory(windowStub, requireStub, documentStub, MutationObserverStub)
|
|
139
|
+
assert.equal(modules.length, 1, 'client.js 模块未被捕获')
|
|
140
|
+
const mod = modules[0].factory(requireStub)
|
|
141
|
+
|
|
142
|
+
// 快照源桩:监听器捕获 + emit,供测试模拟服务变更触发的重扫(生产由订阅驱动)
|
|
143
|
+
let sessionsState = { ids: [], byId: {} }
|
|
144
|
+
let workspacesState = undefined
|
|
145
|
+
function sourceStub(getSnapshot) {
|
|
146
|
+
const listeners = new Set()
|
|
147
|
+
return {
|
|
148
|
+
list: {
|
|
149
|
+
subscribe: (cb) => {
|
|
150
|
+
listeners.add(cb)
|
|
151
|
+
return () => listeners.delete(cb)
|
|
152
|
+
},
|
|
153
|
+
getSnapshot,
|
|
154
|
+
},
|
|
155
|
+
emit: () => {
|
|
156
|
+
for (const cb of [...listeners]) cb()
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const sessions = { binding: () => undefined, ...sourceStub(() => sessionsState) }
|
|
161
|
+
const workspaces = sourceStub(() => workspacesState)
|
|
162
|
+
const ctx = {
|
|
163
|
+
get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
|
|
164
|
+
effect: (fn, tag) => effects.push({ fn, tag }),
|
|
165
|
+
slots: { inject: () => {}, register: () => {} },
|
|
166
|
+
}
|
|
167
|
+
mod.apply(ctx)
|
|
168
|
+
const entry = effects.find((item) => item.tag === 'turn-notify folder running mark')
|
|
169
|
+
assert.ok(entry, 'apply 未挂文件夹运行标记 effect')
|
|
170
|
+
return {
|
|
171
|
+
sections,
|
|
172
|
+
runEffect: entry.fn,
|
|
173
|
+
emitSessions: sessions.emit,
|
|
174
|
+
setSessions: (next) => { sessionsState = next },
|
|
175
|
+
setWorkspaces: (next) => { workspacesState = next },
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 两工作区一运行一的基准快照
|
|
180
|
+
function baseSnapshots(client) {
|
|
181
|
+
client.setSessions({
|
|
182
|
+
ids: ['a', 'b'],
|
|
183
|
+
byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }) },
|
|
184
|
+
})
|
|
185
|
+
client.setWorkspaces({ items: [WS('w1', ['a']), WS('w2', ['b'])], archivedSessionIds: [] })
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function withFetch(implOrPayload, run) {
|
|
189
|
+
// mock.method 返回被 mock 的函数,还原须经 .mock.restore(),不可直接调用返回值;
|
|
190
|
+
// 传函数即显式 fetch 实现(构造拉取失败),传对象即固定响应,undefined 即固定
|
|
191
|
+
// 成功响应 {folderRunningEnabled:true}——默认路径显式 mock,不依赖运行环境对相对
|
|
192
|
+
// URL 的 fetch 行为(隐式环境耦合会随 Node 升级漂移);
|
|
193
|
+
// 通知长轮询路径恒返回挂起 Promise,测试期间不产生投影循环
|
|
194
|
+
const impl = typeof implOrPayload === 'function'
|
|
195
|
+
? async (path) => (String(path).indexOf('/api/turn-notify/projection') === 0 ? new Promise(() => {}) : implOrPayload(path))
|
|
196
|
+
: async (path) => {
|
|
197
|
+
if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
|
|
198
|
+
return { ok: true, json: async () => (implOrPayload === undefined ? { folderRunningEnabled: true } : implOrPayload) }
|
|
199
|
+
}
|
|
200
|
+
const mocked = mock.method(globalThis, 'fetch', impl)
|
|
201
|
+
try {
|
|
202
|
+
await run()
|
|
203
|
+
} finally {
|
|
204
|
+
mocked.mock.restore()
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
test('Given 两工作区组容器与快照同序, When 扫描, Then 运行中工作区组容器带标记', async () => {
|
|
209
|
+
await withFetch(undefined, async () => {
|
|
210
|
+
const client = loadClient()
|
|
211
|
+
baseSnapshots(client)
|
|
212
|
+
const [s1, s2] = [sectionStub(), sectionStub()]
|
|
213
|
+
client.sections.push(s1, s2)
|
|
214
|
+
client.runEffect()
|
|
215
|
+
await sleep(SCAN_SETTLE_MS)
|
|
216
|
+
assert.ok(s1.hasAttribute(pure.runningFlag), '运行中工作区组容器应带标记')
|
|
217
|
+
assert.equal(s2.hasAttribute(pure.runningFlag), false, '空闲工作区组容器不带标记')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('Given 末位未分组桶存在, When 扫描, Then 组容器数 = 工作区数+1 且游离运行中落末位', async () => {
|
|
222
|
+
await withFetch(undefined, async () => {
|
|
223
|
+
const client = loadClient()
|
|
224
|
+
baseSnapshots(client)
|
|
225
|
+
client.setSessions({
|
|
226
|
+
ids: ['a', 'b', 'stray'],
|
|
227
|
+
byId: { a: ROW('a', { running: true }), b: ROW('b', { running: false }), stray: ROW('stray', { running: true }) },
|
|
228
|
+
})
|
|
229
|
+
const [s1, s2, s3] = [sectionStub(), sectionStub(), sectionStub()]
|
|
230
|
+
client.sections.push(s1, s2, s3)
|
|
231
|
+
client.runEffect()
|
|
232
|
+
await sleep(SCAN_SETTLE_MS)
|
|
233
|
+
assert.ok(s1.hasAttribute(pure.runningFlag), '首工作区(运行中)带标记')
|
|
234
|
+
assert.equal(s2.hasAttribute(pure.runningFlag), false, '次工作区(空闲)不带标记')
|
|
235
|
+
assert.ok(s3.hasAttribute(pure.runningFlag), '末位未分组桶(游离运行中)带标记')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
test('Given 组容器数与不变量漂移(单列表/搜索态/结构变更), When 扫描, Then 清除全部标记不误标', async () => {
|
|
240
|
+
await withFetch(undefined, async () => {
|
|
241
|
+
const client = loadClient()
|
|
242
|
+
baseSnapshots(client)
|
|
243
|
+
const [s1, s2] = [sectionStub(), sectionStub()]
|
|
244
|
+
client.sections.push(s1, s2)
|
|
245
|
+
client.runEffect()
|
|
246
|
+
await sleep(SCAN_SETTLE_MS)
|
|
247
|
+
assert.ok(s1.hasAttribute(pure.runningFlag), '前置:正常对位时首组带标记')
|
|
248
|
+
// 漂移:两工作区只剩一个组容器(或多余两个)均不匹配不变量
|
|
249
|
+
client.sections.length = 0
|
|
250
|
+
client.sections.push(s1)
|
|
251
|
+
client.emitSessions()
|
|
252
|
+
await sleep(SCAN_SETTLE_MS)
|
|
253
|
+
assert.equal(s1.hasAttribute(pure.runningFlag), false, '数量漂移后标记被清除')
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('Given 开关关闭(拉取返回 folderRunningEnabled:false), When 扫描, Then 不标记且既有标记清除', async () => {
|
|
258
|
+
await withFetch({ folderRunningEnabled: false }, async () => {
|
|
259
|
+
const client = loadClient()
|
|
260
|
+
baseSnapshots(client)
|
|
261
|
+
const s1 = sectionStub()
|
|
262
|
+
client.sections.push(s1)
|
|
263
|
+
client.runEffect()
|
|
264
|
+
await sleep(SCAN_SETTLE_MS)
|
|
265
|
+
assert.equal(s1.hasAttribute(pure.runningFlag), false, '停用后不新增标记')
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('Given 开关拉取失败, When 扫描, Then 按启用兜底照常标记', async () => {
|
|
270
|
+
await withFetch(async (path) => {
|
|
271
|
+
if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
|
|
272
|
+
throw new Error('network down')
|
|
273
|
+
}, async () => {
|
|
274
|
+
const client = loadClient()
|
|
275
|
+
baseSnapshots(client)
|
|
276
|
+
const [s1, s2] = [sectionStub(), sectionStub()]
|
|
277
|
+
client.sections.push(s1, s2)
|
|
278
|
+
client.runEffect()
|
|
279
|
+
await sleep(SCAN_SETTLE_MS)
|
|
280
|
+
assert.ok(s1.hasAttribute(pure.runningFlag), '拉取失败按启用兜底(默认启用)')
|
|
281
|
+
assert.equal(s2.hasAttribute(pure.runningFlag), false, '兜底不改变运行态判定')
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
test('Given 运行态变化经服务订阅到达(折叠组无 DOM 变更), When 快照更新, Then 标记跟随', async () => {
|
|
286
|
+
await withFetch(undefined, async () => {
|
|
287
|
+
const client = loadClient()
|
|
288
|
+
baseSnapshots(client)
|
|
289
|
+
const [s1, s2] = [sectionStub(), sectionStub()]
|
|
290
|
+
client.sections.push(s1, s2)
|
|
291
|
+
client.runEffect()
|
|
292
|
+
await sleep(SCAN_SETTLE_MS)
|
|
293
|
+
assert.ok(s1.hasAttribute(pure.runningFlag), '前置:首工作区运行中')
|
|
294
|
+
// 唯一运行会话转入另一工作区(模拟会话被归档/挂到他处后的快照差)
|
|
295
|
+
client.setSessions({
|
|
296
|
+
ids: ['a', 'b'],
|
|
297
|
+
byId: { a: ROW('a', { running: false }), b: ROW('b', { running: true }) },
|
|
298
|
+
})
|
|
299
|
+
client.emitSessions()
|
|
300
|
+
await sleep(SCAN_SETTLE_MS)
|
|
301
|
+
assert.equal(s1.hasAttribute(pure.runningFlag), false, '原运行工作区标记消除')
|
|
302
|
+
assert.ok(s2.hasAttribute(pure.runningFlag), '新运行工作区获得标记')
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('Given effect 卸载, When 清理执行, Then 标记移除且资源退订', async () => {
|
|
307
|
+
await withFetch(undefined, async () => {
|
|
308
|
+
const client = loadClient()
|
|
309
|
+
baseSnapshots(client)
|
|
310
|
+
const [s1, s2] = [sectionStub(), sectionStub()]
|
|
311
|
+
client.sections.push(s1, s2)
|
|
312
|
+
const cleanup = client.runEffect()
|
|
313
|
+
await sleep(SCAN_SETTLE_MS)
|
|
314
|
+
assert.ok(s1.hasAttribute(pure.runningFlag), '前置:标记已应用')
|
|
315
|
+
cleanup()
|
|
316
|
+
assert.equal(s1.hasAttribute(pure.runningFlag), false, '卸载清除运行中工作区的标记')
|
|
317
|
+
assert.equal(s2.hasAttribute(pure.runningFlag), false, '卸载不残留其他标记')
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
test('Given 无工作区且游离会话运行, When 扫描, Then 末位未分组桶正确标记', async () => {
|
|
322
|
+
await withFetch(undefined, async () => {
|
|
323
|
+
const client = loadClient()
|
|
324
|
+
client.setSessions({ ids: ['stray'], byId: { stray: ROW('stray', { running: true }) } })
|
|
325
|
+
client.setWorkspaces({ items: [], archivedSessionIds: [] })
|
|
326
|
+
const s1 = sectionStub()
|
|
327
|
+
client.sections.push(s1)
|
|
328
|
+
client.runEffect()
|
|
329
|
+
await sleep(SCAN_SETTLE_MS)
|
|
330
|
+
assert.ok(s1.hasAttribute(pure.runningFlag), '零工作区时组容器数 1 = 工作区数 0 + 1,末位按未分组桶标记')
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('Given 组容器数多于不变量上界, When 扫描, Then 清除全部标记不误标', async () => {
|
|
335
|
+
await withFetch(undefined, async () => {
|
|
336
|
+
const client = loadClient()
|
|
337
|
+
baseSnapshots(client)
|
|
338
|
+
// 数量漂移「变多」:两工作区出现四个组容器(> 工作区数+1)
|
|
339
|
+
const sections = [sectionStub(), sectionStub(), sectionStub(), sectionStub()]
|
|
340
|
+
client.sections.push(...sections)
|
|
341
|
+
client.runEffect()
|
|
342
|
+
await sleep(SCAN_SETTLE_MS)
|
|
343
|
+
for (const [index, section] of sections.entries()) {
|
|
344
|
+
assert.equal(section.hasAttribute(pure.runningFlag), false, '容器 ' + index + ' 数量漂移时不带标记')
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
test('Given 拉取未完成即卸载, When 后续拉取完成并触发调度, Then 标记不复活', async () => {
|
|
350
|
+
// 拉取挂起(永不resolve),标记先经拉取完成前的唯一路径产生:
|
|
351
|
+
// 未决期 scan 仅清除,故先注入「已启用 + 已标记」状态再卸载
|
|
352
|
+
let release
|
|
353
|
+
await withFetch((path) => {
|
|
354
|
+
if (String(path).indexOf('/api/turn-notify/projection') === 0) return new Promise(() => {})
|
|
355
|
+
return new Promise((resolve) => { release = resolve })
|
|
356
|
+
}, async () => {
|
|
357
|
+
const client = loadClient()
|
|
358
|
+
baseSnapshots(client)
|
|
359
|
+
const s1 = sectionStub()
|
|
360
|
+
client.sections.push(s1)
|
|
361
|
+
const cleanup = client.runEffect()
|
|
362
|
+
await sleep(SCAN_SETTLE_MS)
|
|
363
|
+
assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取未决期间不产生标记')
|
|
364
|
+
// 模拟标记已在(如卸载前最后一次 scan 的残留):卸载后拉取完成不得复活
|
|
365
|
+
s1.setAttribute(pure.runningFlag, '')
|
|
366
|
+
cleanup()
|
|
367
|
+
release({ ok: true, json: async () => ({ folderRunningEnabled: true }) })
|
|
368
|
+
await sleep(SCAN_SETTLE_MS)
|
|
369
|
+
assert.equal(s1.hasAttribute(pure.runningFlag), false, '卸载后拉取完成不复活标记')
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test('Given 开关停用, When 拉取完成前的首次扫描, Then 不产生标记(未决期仅清除)', async () => {
|
|
374
|
+
// 拉取延迟释放(3 个扫描窗口后返回停用),未决期内的扫描不得标记
|
|
375
|
+
await withFetch((path) => new Promise((resolve) => {
|
|
376
|
+
if (String(path).indexOf('/api/turn-notify/projection') === 0) return
|
|
377
|
+
setTimeout(() => resolve({ ok: true, json: async () => ({ folderRunningEnabled: false }) }), pure.scanDelayMs * 3)
|
|
378
|
+
}), async () => {
|
|
379
|
+
const client = loadClient()
|
|
380
|
+
baseSnapshots(client)
|
|
381
|
+
const s1 = sectionStub()
|
|
382
|
+
client.sections.push(s1)
|
|
383
|
+
client.runEffect()
|
|
384
|
+
await sleep(SCAN_SETTLE_MS)
|
|
385
|
+
assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取未决期首次扫描不标记')
|
|
386
|
+
await sleep(SCAN_SETTLE_MS * 2)
|
|
387
|
+
assert.equal(s1.hasAttribute(pure.runningFlag), false, '拉取返回停用后维持无标记')
|
|
388
|
+
})
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
// ── 源码契约:CSS 规则、面板开关挂载、选择器与标记属性 ──
|
|
392
|
+
|
|
393
|
+
test('源码契约:运行点与图标着色规则、官方组容器锚点、面板开关挂载齐备', () => {
|
|
394
|
+
assert.ok(CLIENT_SRC.includes('div[class$="_groupSection"][data-tn-folder-running]'), '缺组容器标记 CSS 锚点')
|
|
395
|
+
assert.ok(CLIENT_SRC.includes('var(--dsw-static-deepseek-450)'), '运行点色源应为官方 ongoing 实际色值(--dsh-state-ongoing 仅存在于官方状态点组件局部,组容器读不到)')
|
|
396
|
+
assert.ok(!CLIENT_SRC.includes('--tn-fold-running-color: var(--dsh-state-ongoing'), '死变量引用:组容器上下文读不到 --dsh-state-ongoing,恒走回退源,不得引入永不生效的引用')
|
|
397
|
+
assert.ok(CLIENT_SRC.includes('[class$="_folderActive"]'), '图标着色应让位官方 folderActive 态')
|
|
398
|
+
assert.ok(CLIENT_SRC.includes('tn-fold-running-pulse'), '缺运行点脉冲动画')
|
|
399
|
+
assert.ok(CLIENT_SRC.includes('prefers-reduced-motion'), '动画应尊重减少动态偏好')
|
|
400
|
+
assert.ok(CLIENT_SRC.includes('toggleFolderRunning'), '面板缺运行标记开关处理函数')
|
|
401
|
+
assert.ok(CLIENT_SRC.includes('title: FOLD_SWITCH_TITLE'), '开关行缺悬停说明挂载')
|
|
402
|
+
assert.ok(CLIENT_SRC.includes('const FOLD_SWITCH_TITLE'), '缺悬停说明常量')
|
|
403
|
+
// 开关经既有 config 路由读写:不新增路由,host 与 client 字面量一致
|
|
404
|
+
assert.ok(!CLIENT_SRC.includes("'/api/turn-notify/folder-running"), '运行标记开关不得另开路由(经 config 路由读写)')
|
|
405
|
+
})
|
package/test/core.test.mjs
CHANGED
|
@@ -661,6 +661,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
|
|
|
661
661
|
suppressSubagentWake: true,
|
|
662
662
|
hostNotify: false,
|
|
663
663
|
hostNotifyFallback: false,
|
|
664
|
+
folderRunningEnabled: true,
|
|
664
665
|
enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
|
|
665
666
|
soundMapping: { completed: 'snd-1' },
|
|
666
667
|
imTargets: [],
|
|
@@ -674,6 +675,7 @@ test('配置解析:enabled 缺省键按开补全,字段类型回退默认', () =
|
|
|
674
675
|
suppressSubagentWake: true,
|
|
675
676
|
hostNotify: false,
|
|
676
677
|
hostNotifyFallback: false,
|
|
678
|
+
folderRunningEnabled: true,
|
|
677
679
|
enabled: Object.fromEntries(CATEGORIES.map((name) => [name, true])),
|
|
678
680
|
soundMapping: {},
|
|
679
681
|
imTargets: [],
|
|
@@ -692,6 +694,7 @@ test('面板可见配置:webhookUrl 原文回显', () => {
|
|
|
692
694
|
suppressSubagentWake: true,
|
|
693
695
|
hostNotify: false,
|
|
694
696
|
hostNotifyFallback: false,
|
|
697
|
+
folderRunningEnabled: true,
|
|
695
698
|
enabled: { completed: false, error: true, interrupted: true, approval: true, ask: true, 'max-tokens': true },
|
|
696
699
|
soundMapping: { completed: 'snd-1' },
|
|
697
700
|
imTargets: [],
|
package/test/flow.test.mjs
CHANGED
|
@@ -45,14 +45,15 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
|
|
|
45
45
|
listeners: windowListeners,
|
|
46
46
|
localStorage: storage,
|
|
47
47
|
}
|
|
48
|
+
const styleTags = []
|
|
48
49
|
const documentStub = {
|
|
49
50
|
hasFocus: () => true,
|
|
50
51
|
// 默认桩:聚焦窗口恒可见(浏览器不变式);聚焦静默判定为可见+焦点双条件
|
|
51
52
|
hidden: false,
|
|
52
53
|
title: 'dsh',
|
|
53
|
-
createElement: () => ({ style: {}, remove() {} }),
|
|
54
|
+
createElement: () => ({ style: {}, attrs: {}, setAttribute(name, value) { this.attrs[name] = value }, remove() {} }),
|
|
54
55
|
body: { appendChild: () => {} },
|
|
55
|
-
head: { appendChild: () => {} },
|
|
56
|
+
head: { appendChild: (node) => { styleTags.push(node) } },
|
|
56
57
|
...documentOverride,
|
|
57
58
|
}
|
|
58
59
|
const reactStub = { useState: (value) => [value, () => {}], useEffect: () => {}, useSyncExternalStore: () => [] }
|
|
@@ -85,7 +86,7 @@ function loadClient({ storage, payload, onFetch, fetchImpl, document: documentOv
|
|
|
85
86
|
)
|
|
86
87
|
assert.equal(modules.length, 1, 'client.js 模块未被捕获')
|
|
87
88
|
// load({id, factory}) 结构:再调 factory(require) 得到真正的模块对象
|
|
88
|
-
return { mod: modules[0].factory(requireStub), shown, window: windowStub, document: documentStub }
|
|
89
|
+
return { mod: modules[0].factory(requireStub), shown, window: windowStub, document: documentStub, styleTags }
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
const units = [
|
|
@@ -718,18 +719,23 @@ test('Given 页内映射存储损坏或死链 When 解析 Then 容错回落且
|
|
|
718
719
|
|
|
719
720
|
test('激活即启动轮询:apply 注册设置分区且 start 已执行', async () => {
|
|
720
721
|
const fetched = []
|
|
721
|
-
const { mod } = loadClient({
|
|
722
|
+
const { mod, styleTags } = loadClient({
|
|
722
723
|
storage: new FakeStorage(),
|
|
723
724
|
payload: { units: [], soundMapping: {}, version: 1 },
|
|
724
725
|
onFetch: (path) => { fetched.push(path) },
|
|
725
726
|
})
|
|
726
727
|
const injected = []
|
|
727
728
|
const effects = []
|
|
729
|
+
// sessions/workspaces 服务桩:文件夹运行标记的注入面(订阅桩不做任何事;
|
|
730
|
+
// 本测试 document 桩无 querySelectorAll,fold effect 装配即干净退出)
|
|
731
|
+
const serviceStub = { list: { subscribe: () => () => {}, getSnapshot: () => ({}) } }
|
|
728
732
|
mod.apply({
|
|
733
|
+
get: (name) => (name === 'sessions' || name === 'workspaces' ? serviceStub : undefined),
|
|
729
734
|
slots: { inject: (name, fn) => { injected.push([name, fn]) } },
|
|
730
735
|
effect: (fn) => { effects.push(fn()) },
|
|
731
736
|
})
|
|
732
|
-
assert.equal(effects.length,
|
|
737
|
+
assert.equal(effects.length, 2, '文档级样式与文件夹运行标记两个 effect 均已挂载')
|
|
738
|
+
assert.equal(styleTags[0].attrs['data-plugin'], '@mzzsfy/dsh-turn-notify', '样式自带 data-plugin,防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
|
|
733
739
|
assert.deepEqual(injected.map(([name]) => name), ['settings.section'], '页内通知展示已移交 dsh-toast,不再注入 shell.overlay')
|
|
734
740
|
// start 已执行:音效清单被首拉;轮询定时器 unref,不阻止测试进程退出
|
|
735
741
|
await new Promise((resolve) => { setTimeout(resolve, 0) })
|
|
@@ -799,7 +805,11 @@ test('分类通知开关提交失败:报错并以权威配置纠偏', async () =
|
|
|
799
805
|
})
|
|
800
806
|
|
|
801
807
|
function activate(mod) {
|
|
808
|
+
// sessions/workspaces 桩:apply 的注入面声明;fold effect 在无 querySelectorAll
|
|
809
|
+
// 的 document 桩上装配即退出,不触碰订阅
|
|
810
|
+
const serviceStub = { list: { subscribe: () => () => {}, getSnapshot: () => ({}) } }
|
|
802
811
|
mod.apply({
|
|
812
|
+
get: (name) => (name === 'sessions' || name === 'workspaces' ? serviceStub : undefined),
|
|
803
813
|
slots: { inject: () => {} },
|
|
804
814
|
effect: (fn) => { fn() },
|
|
805
815
|
})
|