@mzzsfy/dsh-session-manager 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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 + 归档删除 + 回收站还原重挂载。
4
4
 
5
- 历史输入浮层、插话撤回与对话分叉已迁至独立插件 `dsh-context-manager`(会话上下文管理);工作区文件夹运行标记见 `dsh-turn-notify`。
5
+ 历史输入浮层、插话撤回与对话分叉已迁至独立插件 `dsh-context-manager`(会话上下文管理);工作区文件夹运行标记见 `dsh-turn-notify`;会话列表瘦身(列表投影裁剪与缓存)已迁至 `dsh-bug-perf`。旧设置项 `listSlimEnabled` 不再读取,开关改由 `dsh-bug-perf` 的 `listSlim.enabled` 管理(默认开启)。本包 0.10.0 及更早版本仍带丢失调用方上下文的旧列表包装,必须升级到 0.11.0(本次发布);旧包装与新 `dsh-bug-perf` 同装时仍会绕过宿主鉴权,不能只升级对端包。
6
6
 
7
7
  ## 功能
8
8
 
@@ -78,7 +78,7 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
78
78
  npm test
79
79
  ```
80
80
 
81
- 纯逻辑层(评估状态机 / 删除资格与失败矩阵 / 面板投影 / 归档差分 / 空白判定 / 回收站命令构造)以 `node --test` 覆盖,无外部依赖。路由层测试依赖 peer 包可解析,仓库根未安装依赖时自动 skip,`npm install` 后激活。Windows 真实回收站执行测试仅在本平台执行,其余平台自动 skip。周期评估测试经 `mock.timers` 接管 `setInterval` 与 `Date`(显式传 `now`:接管后虚拟时钟从 epoch 起算,夹具时间戳会全成「未来」),timer 服务以 makeCtx 桩模拟激活与缺失两种形态。
81
+ 纯逻辑层(评估状态机 / 删除资格与失败矩阵 / 面板投影 / 归档差分 / 空白判定 / 回收站命令构造)以 `node --test` 覆盖,无外部依赖。路由层测试依赖 peer 包可解析,仓库根未安装依赖时自动 skip(3 个非 skip 用例不受影响,其中 Config 导出契约在 peer 缺失时会失败而非 skip),`npm install` 后激活。Windows 真实回收站执行测试仅在本平台执行,其余平台自动 skip。周期评估测试经 `mock.timers` 接管 `setInterval` 与 `Date`(显式传 `now`:接管后虚拟时钟从 epoch 起算,夹具时间戳会全成「未来」),timer 服务以 makeCtx 桩模拟激活与缺失两种形态。
82
82
 
83
83
  ## 开发安装(仓库工作副本直挂,不经 npm 发布)
84
84
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.10.0",
4
+ "version": "0.11.0",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -123,20 +123,6 @@ const CSS = [
123
123
  ' border:1px solid var(--dsw-alias-border-l2);',
124
124
  ' background:transparent; color:inherit; font:inherit; }',
125
125
  '.sm-cfg__field input:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:0; }',
126
- // 列表瘦身开关:原生 checkbox 保语义与可访问性,视觉由 track/thumb 呈现
127
- '.sm-switch { display:inline-flex; align-items:center; gap:8px; min-height:28px; cursor:pointer;',
128
- ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption); }',
129
- '.sm-switch input[type="checkbox"] { position:absolute; opacity:0; width:0; height:0; }',
130
- '.sm-switch__track { position:relative; flex-shrink:0; width:34px; height:19px; border-radius:999px; box-sizing:border-box;',
131
- ' background:transparent; border:1px solid var(--dsw-alias-border-l2); transition:background 0.15s,border-color 0.15s; }',
132
- '.sm-switch__thumb { position:absolute; top:50%; left:2px; width:13px; height:13px; border-radius:50%;',
133
- ' background:var(--dsw-alias-label-caption); transform:translateY(-50%); transition:left 0.15s,background 0.15s; }',
134
- '.sm-switch:hover .sm-switch__track { border-color:var(--dsw-alias-state-business-primary); }',
135
- '.sm-switch input[type="checkbox"]:checked + .sm-switch__track { background:var(--dsw-alias-state-business-primary); border-color:var(--dsw-alias-state-business-primary); }',
136
- '.sm-switch input[type="checkbox"]:checked + .sm-switch__track .sm-switch__thumb { left:17px; background:#fff; }',
137
- '.sm-switch input[type="checkbox"]:focus-visible + .sm-switch__track { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:2px; }',
138
- '.sm-switch:has(input[type="checkbox"]:disabled) { opacity:0.5; cursor:not-allowed; }',
139
- '@media (prefers-reduced-motion: reduce) { .sm-switch__track, .sm-switch__thumb { transition:none; } }',
140
126
  ].join('\n')
141
127
 
142
128
  const UNARCHIVE_URL = '/api/session-manager/unarchive'
@@ -687,13 +673,11 @@ function SessionManagerApp(props) {
687
673
  const ARCHIVE_OVERVIEW_TITLE = '超过阈值天数未活跃的会话自动移入归档;触发时机为新会话创建、插件启动与周期检查;归档会话在本面板管理,可恢复或删除;恢复后 10 分钟内暂停自动归档,期间发言即恢复正常活跃计时。'
688
674
  const ARCHIVE_DAYS_TITLE = '会话超过该天数未活跃(产物无更新)即自动归档,空白(无消息)会话不参与归档;0 = 关闭自动归档。仅对产物可读的会话评估,产物不可读的跳过以免误归档,已归档的会话不会重复处理。'
689
675
  const ARCHIVE_INTERVAL_TITLE = '周期检查的间隔小时数,到期会话在下一轮检查时归档;0 = 关闭周期检查(新会话创建与插件启动时的检查不受影响);调小最迟等新间隔即可提前触发,调大自下一轮排期起生效。'
690
- const LIST_SLIM_TITLE = '会话列表瘦身:列表接口原本把每个会话的完整投影快照(上下文请求头、回合大纲等)一并下发,会话多时单次响应数十 MB、服务端重建要数十秒,每次打开页面都重来一遍。开启后只下发列表真正用到的字段并缓存结果,重复打开不再重建,响应体积下降九成以上;被裁掉的字段在会话详情里照旧完整返回。关闭即恢复原始行为,不影响归档、删除等其它功能。'
691
676
 
692
677
  // 自动归档配置:阈值天数与检查周期小时数,数值语义,失焦或 Enter 单字段即时提交;
693
678
  // 值存宿主 settings(与评估逻辑同源,0 = 关闭),非法输入前端拒绝不发包,
694
679
  // 服务端失败后回读生效值回滚显示
695
680
  const AUTO_ARCHIVE_URL = '/api/session-manager/auto-archive'
696
- const LIST_SLIM_URL = '/api/session-manager/list-slim'
697
681
 
698
682
  // 提交判定三态(镜像 core.mjs classifyAutoArchiveInput):noop 值未变 /
699
683
  // invalid 非非负整数 / post 可提交;受控输入下显示值随击键同步,提交守卫
@@ -706,11 +690,10 @@ function classifyAutoArchiveInput(committedText, text) {
706
690
  return { action: 'post', value }
707
691
  }
708
692
 
709
- // 面板状态:数值项取字符串供受控输入,开关项取布尔
710
- const configStateOf = (archive, slim) => ({
693
+ // 面板状态:数值项取字符串供受控输入
694
+ const configStateOf = (archive) => ({
711
695
  days: String(archive.days),
712
696
  intervalHours: String(archive.intervalHours),
713
- enabled: slim.enabled !== false,
714
697
  })
715
698
 
716
699
  function AutoArchiveConfig() {
@@ -720,12 +703,11 @@ function AutoArchiveConfig() {
720
703
  useEffect(() => {
721
704
  void refresh()
722
705
  }, [])
723
- // 两个接口合并成一个面板状态:数值项与开关项一次读齐,提交后各自回读
724
706
  function refresh() {
725
- return Promise.all([api(AUTO_ARCHIVE_URL), api(LIST_SLIM_URL)])
726
- .then(([archive, slim]) => {
727
- if (!archive || !slim) return
728
- const next = configStateOf(archive, slim)
707
+ return api(AUTO_ARCHIVE_URL)
708
+ .then((archive) => {
709
+ if (!archive) return
710
+ const next = configStateOf(archive)
729
711
  committedRef.current = next
730
712
  setState(next)
731
713
  })
@@ -757,20 +739,6 @@ function AutoArchiveConfig() {
757
739
  void refresh()
758
740
  })
759
741
  }
760
- // 开关提交:布尔值直接落 settings,成功后按回显值收敛,失败回读生效值
761
- function commitEnabled(next) {
762
- api(LIST_SLIM_URL, { method: 'POST', body: JSON.stringify({ enabled: next }) })
763
- .then((payload) => {
764
- const saved = payload.enabled !== false
765
- committedRef.current = { ...committedRef.current, enabled: saved }
766
- setState((prev) => (prev === null ? prev : { ...prev, enabled: saved }))
767
- toast(saved ? '会话列表瘦身已开启' : '会话列表瘦身已关闭')
768
- })
769
- .catch(() => {
770
- toast('会话列表瘦身开关保存失败', { kind: 'error' })
771
- void refresh()
772
- })
773
- }
774
742
  if (state === null) return null
775
743
  const numberInput = (field) => h('input', {
776
744
  type: 'number', min: 0, step: 1, value: state[field],
@@ -783,16 +751,6 @@ function AutoArchiveConfig() {
783
751
  h('span', { title: ARCHIVE_OVERVIEW_TITLE }, '自动归档'),
784
752
  h('label', { className: 'sm-cfg__field', title: ARCHIVE_DAYS_TITLE }, '阈值', numberInput('days'), '天未活跃(0 关闭)'),
785
753
  h('label', { className: 'sm-cfg__field', title: ARCHIVE_INTERVAL_TITLE }, '检查周期', numberInput('intervalHours'), '小时(0 关闭)'),
786
- h('label', { className: 'sm-switch', title: LIST_SLIM_TITLE },
787
- h('input', {
788
- type: 'checkbox',
789
- checked: state.enabled,
790
- 'aria-label': '会话列表瘦身',
791
- onChange: (event) => commitEnabled(event.target.checked),
792
- }),
793
- h('span', { className: 'sm-switch__track' }, h('span', { className: 'sm-switch__thumb' })),
794
- h('span', { title: LIST_SLIM_TITLE }, '会话列表瘦身'),
795
- ),
796
754
  )
797
755
  }
798
756
 
package/src/index.js CHANGED
@@ -31,7 +31,6 @@ import {
31
31
  updatedAtOf,
32
32
  } from './core.mjs'
33
33
  import { moveToQuarantine, restoreFromQuarantine, trashPath } from './trash.mjs'
34
- import { installListSlim } from './list-slim.mjs'
35
34
 
36
35
  export const name = 'dsh-session-manager'
37
36
 
@@ -98,8 +97,6 @@ const SETTINGS_SCHEMA = schemastery.object({
98
97
  .description('自动归档阈值天数,0 表示关闭;新会话创建、启动与周期评估时生效'),
99
98
  autoArchiveIntervalHours: schemastery.number().min(0).step(1).default(DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
100
99
  .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
101
- listSlimEnabled: schemastery.boolean().default(true)
102
- .description('会话列表瘦身:裁剪 session/list 的投影并缓存,降低列表响应体积与重建耗时;上游结构变更时自动降级为原行为'),
103
100
  })
104
101
 
105
102
  // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
@@ -144,7 +141,6 @@ function readSettings(ctx, config) {
144
141
  return {
145
142
  days: Number.isInteger(days) && days >= 0 ? days : DEFAULT_AUTO_ARCHIVE_DAYS,
146
143
  intervalHours: Number.isInteger(hours) && hours >= 0 ? hours : DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
147
- listSlimEnabled: value?.listSlimEnabled !== false,
148
144
  }
149
145
  }
150
146
 
@@ -269,9 +265,6 @@ function guardWrite(req, res) {
269
265
  /** @param {import('@deepseek-ai/cordis').Context} ctx */
270
266
  export function apply(ctx, config) {
271
267
  let evaluating = false
272
- // 列表瘦身:开关从设置实时读取,设置页改动即时生效;装载失败只关闭该能力,
273
- // 归档与删除等其它功能不受影响
274
- installListSlim(ctx, { isEnabled: () => readSettings(ctx, config).listSlimEnabled })
275
268
  // 取消归档全局宽限起点(进程内,重启失效可接受):恢复会话到用户发出首条
276
269
  // 消息之间的评估窗口保护,首条消息更新产物 mtime 后由阈值自然接管
277
270
  let unarchivedAtMs = 0
@@ -717,32 +710,6 @@ export function apply(ctx, config) {
717
710
  }
718
711
  },
719
712
  },
720
- {
721
- // 会话列表瘦身开关:读回生效值,写经 settings 落 profile 条目;
722
- // 关闭即恢复原始列表语义(不裁剪、不缓存),无需卸载插件
723
- path: '/api/session-manager/list-slim',
724
- handler: async (req, res) => {
725
- try {
726
- if (req.method === 'GET') {
727
- sendJson(res, 200, { enabled: readSettings(ctx, config).listSlimEnabled })
728
- return
729
- }
730
- if (!rejectMethod(req, res, 'POST')) return
731
- if (guardWrite(req, res)) return
732
- let body
733
- try {
734
- body = JSON.parse(await readBody(req))
735
- } catch {
736
- throw new Error(MESSAGES.badJsonBody)
737
- }
738
- if (typeof body?.enabled !== 'boolean') throw new Error('列表瘦身开关须为布尔值')
739
- await persistSettings(ctx, { listSlimEnabled: body.enabled })
740
- sendJson(res, 200, { enabled: readSettings(ctx, config).listSlimEnabled })
741
- } catch (error) {
742
- respondError(ctx, res, error)
743
- }
744
- },
745
- },
746
713
  {
747
714
  path: '/api/session-manager/deleted',
748
715
  handler: async (req, res) => {
@@ -1947,23 +1947,3 @@ test('写路由守卫:跨源 POST 403 / 非 JSON 内容类型 415 / 同源 JSON
1947
1947
  assert.equal(accepted.status, 200)
1948
1948
  assert.deepEqual(accepted.body, { ok: true })
1949
1949
  })
1950
-
1951
- test('列表瘦身开关:GET 回显默认开启,POST 持久,GET 反映新值', skipMissingDeps, async () => {
1952
- const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1953
- const initial = response()
1954
- await handlers.get('/api/session-manager/list-slim')(getRequest2('/api/session-manager/list-slim', 'GET'), initial)
1955
- assert.deepEqual(initial.body, { enabled: true }, '未配置时默认开启')
1956
- const off = await postJson(handlers, '/api/session-manager/list-slim', { enabled: false })
1957
- assert.deepEqual(off.body, { enabled: false })
1958
- const reread = response()
1959
- await handlers.get('/api/session-manager/list-slim')(getRequest2('/api/session-manager/list-slim', 'GET'), reread)
1960
- assert.deepEqual(reread.body, { enabled: false }, 'settings 持久化开关')
1961
- })
1962
-
1963
- test('列表瘦身开关:非布尔载荷一律 400', skipMissingDeps, async () => {
1964
- const { handlers } = makeCtx({ archivedIds: [], headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }], agents: new Map() })
1965
- for (const payload of [{ enabled: 'yes' }, { enabled: 1 }, {}, { enabled: null }]) {
1966
- const rejected = await postJson(handlers, '/api/session-manager/list-slim', payload)
1967
- assert.equal(rejected.status, 400, JSON.stringify(payload))
1968
- }
1969
- })
package/src/list-slim.mjs DELETED
@@ -1,106 +0,0 @@
1
- // 会话列表瘦身:session/list 为每个会话下发完整投影快照,列表行只消费其中少数几项。
2
- // 本模块在 remote 方法返回前按白名单裁剪并缓存结果。任何失败路径都降级为原行为:
3
- // 未装载、形状不符、原方法抛错一律透传,不影响本插件的其它功能。
4
- // sessionController 经 ctx.inject 可选依赖取得,宿主缺该服务时本能力静默关闭。
5
-
6
- // 列表行消费的投影;其余键不下发。被裁掉的投影仍由会话详情接口完整返回。
7
- export const KEEP_PROJECTIONS = ['title', 'sessionListMetadata', 'inbox', 'modelSelection', 'tokenUsage']
8
-
9
- // 缓存有效期:会话增删立即失效,TTL 只兜底标题与运行时状态的漂移。
10
- export const TTL_MS = 15 * 1000
11
-
12
- // 启动后预热延迟:让宿主先完成装载,再让出一次列表重建。
13
- export const WARMUP_DELAY_MS = 10 * 1000
14
-
15
- /**
16
- * 裁剪一条列表项的投影,只保留白名单里的键。
17
- * @param item - 宿主返回的列表项。
18
- * @param keep - 保留的投影键。
19
- * @returns 投影已裁剪的列表项;形状不符时原样返回。
20
- */
21
- export function slimItem(item, keep = KEEP_PROJECTIONS) {
22
- const values = item?.projections?.values
23
- if (values === null || typeof values !== 'object' || Array.isArray(values)) return item
24
- const picked = {}
25
- for (const key of keep) if (key in values) picked[key] = values[key]
26
- return { ...item, projections: { ...item.projections, values: picked } }
27
- }
28
-
29
- /**
30
- * 给 sessionController.list 装上裁剪与缓存。
31
- * @param ctx - 宿主上下文。
32
- * @param options - 开关、白名单、缓存有效期与预热延迟。
33
- * @returns 是否提交了装载回调;sessionController 缺席时回调不执行,宿主行为不变。
34
- */
35
- export function installListSlim(ctx, options = {}) {
36
- const {
37
- enabled = true,
38
- isEnabled,
39
- keep = KEEP_PROJECTIONS,
40
- ttlMs = TTL_MS,
41
- warmupDelayMs = WARMUP_DELAY_MS,
42
- } = options
43
- if (!enabled) return false
44
- const warn = (message) => ctx.logger?.warn?.('session-manager: 列表瘦身 ' + message)
45
- try {
46
- ctx.inject(['sessionController'], (scope) => {
47
- try {
48
- const controller = scope.sessionController
49
- const original = controller?.list
50
- if (typeof original !== 'function') return
51
- let cache
52
- let cachedAt = 0
53
- let inflight
54
-
55
- const fresh = () => cache !== undefined && Date.now() - cachedAt < ttlMs
56
- const invalidate = () => {
57
- cachedAt = 0
58
- }
59
- scope.on('api-session/added', invalidate)
60
- scope.on('api-session/removed', invalidate)
61
-
62
- // 只在调用方这一次请求上构建并缓存:不额外探测一次原方法,形状不符仅本次透传
63
- controller.list = async (request, signal) => {
64
- // 开关每次读取:设置页改动即时生效,无需重载插件
65
- if (isEnabled !== undefined && !isEnabled()) return original.call(controller, request, signal)
66
- if (fresh()) return cache
67
- if (inflight !== undefined) return inflight
68
- inflight = Promise.resolve(original.call(controller, request, signal))
69
- .then((value) => {
70
- if (!Array.isArray(value?.items)) return value
71
- cache = { items: value.items.map((item) => slimItem(item, keep)) }
72
- cachedAt = Date.now()
73
- return cache
74
- })
75
- .finally(() => {
76
- inflight = undefined
77
- })
78
- return inflight
79
- }
80
-
81
- let timer
82
- if (warmupDelayMs >= 0) {
83
- timer = setTimeout(() => {
84
- if (isEnabled !== undefined && !isEnabled()) return
85
- if (fresh() || inflight !== undefined) return
86
- void controller.list({}, undefined).catch(() => {})
87
- }, warmupDelayMs)
88
- timer.unref?.()
89
- }
90
-
91
- // 卸载恢复原方法:本能力退出不改变宿主的列表语义
92
- scope.effect(() => () => {
93
- if (timer !== undefined) clearTimeout(timer)
94
- controller.list = original
95
- })
96
- ctx.logger?.info?.(`session-manager: 列表瘦身已启用 (keep=${keep.join(',')} ttl=${ttlMs}ms)`)
97
- } catch (error) {
98
- warn('装载失败,保持原行为: ' + String(error?.message ?? error))
99
- }
100
- })
101
- return true
102
- } catch (error) {
103
- warn('未启用: ' + String(error?.message ?? error))
104
- return false
105
- }
106
- }
@@ -1,49 +0,0 @@
1
- // 开关形态守卫:布尔偏好一律 switch(track+thumb),原生 checkbox 视觉隐藏保语义与
2
- // 可访问性,状态选择器以 input[type="checkbox"] 锚定,防止组合进含其他 input 的 label 时误伤。
3
- import { test } from 'node:test'
4
- import assert from 'node:assert/strict'
5
- import { readFileSync } from 'node:fs'
6
- import { fileURLToPath } from 'node:url'
7
- import { dirname, join } from 'node:path'
8
-
9
- const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8')
10
-
11
- test('开关结构:label 包原生 checkbox + track + thumb', () => {
12
- assert.match(CLIENT_SOURCE, /className: 'sm-switch'/, '开关 label 存在')
13
- assert.match(CLIENT_SOURCE, /type: 'checkbox'/, '原生 checkbox 保语义')
14
- assert.match(CLIENT_SOURCE, /className: 'sm-switch__track'/, 'track 存在')
15
- assert.match(CLIENT_SOURCE, /className: 'sm-switch__thumb'/, 'thumb 存在')
16
- })
17
-
18
- test('原生 input 被视觉隐藏,不以裸 checkbox 呈现', () => {
19
- assert.match(CLIENT_SOURCE, /\.sm-switch input\[type="checkbox"\] \{ position:absolute; opacity:0;/, 'input 视觉隐藏')
20
- })
21
-
22
- test('开关状态选择器以 input[type="checkbox"] 锚定', () => {
23
- for (const state of [':checked', ':focus-visible', ':disabled']) {
24
- const pattern = new RegExp('\\.sm-switch[^{]*\\' + state + '[^{]*\\{', 'g')
25
- const matches = [...CLIENT_SOURCE.matchAll(pattern)].map((match) => match[0])
26
- assert.ok(matches.length > 0, state + ' 选择器存在')
27
- for (const selector of matches) {
28
- assert.match(selector, /input\[type="checkbox"\]/, state + ' 必须以 input[type="checkbox"] 锚定: ' + selector)
29
- }
30
- }
31
- })
32
-
33
- test('开关样式的 :hover 锚定 label 而非隐藏的 input', () => {
34
- assert.match(CLIENT_SOURCE, /\.sm-switch:hover \.sm-switch__track/, 'hover 锚定 label')
35
- })
36
-
37
- test('开关带悬停说明与无障碍标签', () => {
38
- assert.match(CLIENT_SOURCE, /className: 'sm-switch', title: LIST_SLIM_TITLE/, 'label 带 title 悬停说明')
39
- assert.match(CLIENT_SOURCE, /'aria-label': '会话列表瘦身'/, 'input 带 aria-label')
40
- const matched = CLIENT_SOURCE.match(/const LIST_SLIM_TITLE = '([^']+)'/)
41
- assert.ok(matched, '说明文案常量存在')
42
- assert.ok(matched[1].length >= 40, '说明文案须讲清用途,不少于 40 字')
43
- })
44
-
45
- test('样式类名带本包前缀 sm-', () => {
46
- for (const selector of ['.sm-switch {', '.sm-switch__track', '.sm-switch__thumb']) {
47
- assert.ok(CLIENT_SOURCE.includes(selector), selector + ' 存在')
48
- }
49
- })
@@ -1,174 +0,0 @@
1
- // 列表瘦身的契约:只裁白名单、命中缓存不重建、增删事件失效、任何异常回落到原行为。
2
- import { test } from 'node:test'
3
- import assert from 'node:assert/strict'
4
- import { installListSlim, slimItem } from '../src/list-slim.mjs'
5
-
6
- /** 一条带完整投影的列表项。 */
7
- const item = (id, values) => ({ sessionId: id, updatedAt: 1, projections: { kind: 'cached', asOfSeq: 1, values } })
8
-
9
- /** 造一个只实现本模块用到表面的上下文(inject 立即回调,等价于服务已就绪)。 */
10
- function makeContext(list, { withController = true } = {}) {
11
- const handlers = new Map()
12
- const disposers = []
13
- const controller = withController ? { list } : undefined
14
- const ctx = {
15
- get: (name) => (name === 'sessionController' ? controller : undefined),
16
- on: (event, handler) => handlers.set(event, handler),
17
- logger: { info() {}, warn() {} },
18
- inject: (deps, callback) => {
19
- callback({
20
- sessionController: controller,
21
- on: (event, handler) => handlers.set(event, handler),
22
- effect: (factory) => {
23
- disposers.push(factory())
24
- },
25
- })
26
- },
27
- }
28
- return { ctx, controller, handlers, disposers }
29
- }
30
-
31
- const sample = () => [
32
- item('a', { title: 'A', sessionListMetadata: { blank: false }, contextHeaders: [{ pad: 'x'.repeat(64) }] }),
33
- item('b', { title: 'B', inbox: { 'next-turn': [], 'next-step': [] } }),
34
- ]
35
-
36
- test('裁剪只保留白名单里的投影', () => {
37
- const slim = slimItem(sample()[0], ['title'])
38
- assert.deepEqual(Object.keys(slim.projections.values), ['title'])
39
- assert.equal(slim.projections.kind, 'cached')
40
- assert.equal(slim.sessionId, 'a')
41
- })
42
-
43
- test('形状不符的条目原样返回', () => {
44
- const bare = { sessionId: 'c' }
45
- assert.equal(slimItem(bare, ['title']), bare)
46
- assert.equal(slimItem({ projections: { values: null } }, ['title']).projections.values, null)
47
- const array = { projections: { values: ['x'] } }
48
- assert.equal(slimItem(array, ['title']), array)
49
- })
50
-
51
- test('开关关闭时不接管 controller.list', async () => {
52
- const original = async () => ({ items: sample() })
53
- const { ctx, controller } = makeContext(original)
54
- assert.equal(installListSlim(ctx, { enabled: false }), false)
55
- assert.equal(controller.list, original)
56
- })
57
-
58
- test('宿主没有 sessionController 时不接管任何方法', () => {
59
- const { ctx } = makeContext(undefined, { withController: false })
60
- installListSlim(ctx)
61
- assert.equal(ctx.get('sessionController'), undefined)
62
- })
63
-
64
- test('controller 缺 list 方法时静默放弃', () => {
65
- const { ctx, controller } = makeContext(undefined)
66
- installListSlim(ctx)
67
- assert.equal(controller.list, undefined)
68
- })
69
-
70
- test('第二次调用命中缓存,不重建', async () => {
71
- let built = 0
72
- const { ctx, controller } = makeContext(async () => {
73
- built += 1
74
- return { items: sample() }
75
- })
76
- installListSlim(ctx, { warmupDelayMs: -1 })
77
- await controller.list({}, undefined)
78
- const value = await controller.list({}, undefined)
79
- assert.equal(built, 1)
80
- assert.equal(value.items[0].projections.values.contextHeaders, undefined)
81
- })
82
-
83
- test('过期后重建', async () => {
84
- let built = 0
85
- const { ctx, controller } = makeContext(async () => {
86
- built += 1
87
- return { items: sample() }
88
- })
89
- installListSlim(ctx, { ttlMs: 0, warmupDelayMs: -1 })
90
- await controller.list({}, undefined)
91
- await controller.list({}, undefined)
92
- assert.equal(built, 2)
93
- })
94
-
95
- test('会话增删让缓存失效', async () => {
96
- let built = 0
97
- const { ctx, controller, handlers } = makeContext(async () => {
98
- built += 1
99
- return { items: sample() }
100
- })
101
- installListSlim(ctx, { warmupDelayMs: -1 })
102
- await controller.list({}, undefined)
103
- handlers.get('api-session/added')()
104
- await controller.list({}, undefined)
105
- assert.equal(built, 2)
106
- handlers.get('api-session/removed')()
107
- await controller.list({}, undefined)
108
- assert.equal(built, 3)
109
- })
110
-
111
- test('并发请求只触发一次重建', async () => {
112
- let built = 0
113
- const { ctx, controller } = makeContext(async () => {
114
- built += 1
115
- return { items: sample() }
116
- })
117
- installListSlim(ctx, { warmupDelayMs: -1 })
118
- await Promise.all([controller.list({}, undefined), controller.list({}, undefined), controller.list({}, undefined)])
119
- assert.equal(built, 1)
120
- })
121
-
122
- test('原方法抛错时错误照旧透传给调用方', async () => {
123
- const { ctx, controller } = makeContext(async () => {
124
- throw new Error('宿主故障')
125
- })
126
- installListSlim(ctx, { warmupDelayMs: -1 })
127
- await assert.rejects(() => controller.list({}, undefined), /宿主故障/)
128
- })
129
-
130
- test('响应形状不符时原样透传,不缓存', async () => {
131
- let built = 0
132
- const { ctx, controller } = makeContext(async () => {
133
- built += 1
134
- return { sessions: sample() }
135
- })
136
- installListSlim(ctx, { warmupDelayMs: -1 })
137
- const first = await controller.list({}, undefined)
138
- const second = await controller.list({}, undefined)
139
- assert.deepEqual(Object.keys(first), ['sessions'])
140
- assert.equal(built, 2)
141
- assert.notEqual(second, first)
142
- })
143
-
144
- test('把调用方 request 原样交给原方法', async () => {
145
- const seen = []
146
- const { ctx, controller } = makeContext(async (request) => {
147
- seen.push(request)
148
- return { items: sample() }
149
- })
150
- installListSlim(ctx, { warmupDelayMs: -1 })
151
- const request = { cursor: 'abc' }
152
- await controller.list(request, undefined)
153
- assert.equal(seen[0], request)
154
- })
155
-
156
- test('预热在延迟后构建一次', async () => {
157
- let built = 0
158
- const { ctx } = makeContext(async () => {
159
- built += 1
160
- return { items: sample() }
161
- })
162
- installListSlim(ctx, { warmupDelayMs: 0 })
163
- await new Promise((resolve) => setTimeout(resolve, 20))
164
- assert.equal(built, 1)
165
- })
166
-
167
- test('卸载时恢复原方法', async () => {
168
- const original = async () => ({ items: sample() })
169
- const { ctx, controller, disposers } = makeContext(original)
170
- installListSlim(ctx, { warmupDelayMs: -1 })
171
- assert.notEqual(controller.list, original)
172
- for (const dispose of disposers) dispose()
173
- assert.equal(controller.list, original)
174
- })