@mzzsfy/dsh-session-manager 0.9.1 → 0.9.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 CHANGED
@@ -89,4 +89,4 @@ node scripts/dev-link.mjs dsh-session-manager # 仓库根执行:归一 profile
89
89
 
90
90
  ## dsh 版本兼容
91
91
 
92
- 三版本全部通过:会话树渲染、归档入口、激活 live、diagnostics 0 findings。
92
+ 0.1.2-rc.1 / 0.1.5-rc.3 通过:归档入口、激活 live、diagnostics 0 findings。0.1.7-rc.1 上宿主服务拓扑变化(workspaceRegistry 激活链)导致本包七项 inject 未满足、HTTP 面 404,功能待宿主 rc 线定型后专项对齐(详见包内 兼容性测试.md 与仓库兼容性进度文档 FIND-RC1-1)。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.9.1",
4
+ "version": "0.9.3",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -55,8 +55,10 @@ const CSS = [
55
55
  '.sm-row__path { grid-column:2 / -1; font:12px/16px var(--ds-font-family-code, monospace); color:var(--dsw-alias-label-caption);',
56
56
  ' white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
57
57
  '.sm-deleted { display:flex; flex-direction:column; gap:8px; min-width:0; }',
58
- '.sm-row__actions { display:flex; gap:2px; justify-content:flex-end; opacity:0; transition:opacity 0.12s ease; }',
59
- '.sm-row:hover .sm-row__actions, .sm-row:focus-within .sm-row__actions, .sm-row--armed .sm-row__actions { opacity:1; }',
58
+ // 归档行:恢复常显;已删除区保持整组操作悬停显现(删除按钮显隐规则在 .sm-btn 块后)
59
+ '.sm-row__actions { display:flex; gap:2px; justify-content:flex-end; }',
60
+ '.sm-deleted .sm-row__actions { opacity:0; transition:opacity 0.12s ease; }',
61
+ '.sm-deleted .sm-row:hover .sm-row__actions, .sm-deleted .sm-row:focus-within .sm-row__actions { opacity:1; }',
60
62
  '.sm-btn { border:0; background:transparent; cursor:pointer; padding:2px 8px; border-radius:6px;',
61
63
  ' font:var(--dsw-font-xxs-strong-12); color:var(--dsw-alias-label-tertiary); }',
62
64
  '.sm-btn:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
@@ -67,6 +69,10 @@ const CSS = [
67
69
  '.sm-btn:disabled { opacity:.45; cursor:default; background:transparent; color:var(--dsw-alias-label-tertiary); }',
68
70
  '.sm-btn--confirm:disabled { color:var(--dsw-alias-state-error-primary); }',
69
71
  '.sm-btn:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:1px; }',
72
+ // 删除按钮显隐:隐藏规则必须位于 .sm-btn:disabled 之后——同特异度后出者胜,
73
+ // 否则 busy 禁用态被禁用半透明规则压过而幽灵显形;显现规则特异度更高,不受顺序约束
74
+ '.sm-btn--danger, .sm-btn--danger:disabled { opacity:0; transition:opacity 0.12s ease; }',
75
+ '.sm-row:hover .sm-btn--danger, .sm-row:focus-within .sm-btn--danger { opacity:1; }',
70
76
  // 删除确认弹窗:遮罩 + 居中卡片;高 z-index 压过设置面板层,点击遮罩不关闭(强制显式选择)
71
77
  '.sm-dialog { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center;',
72
78
  ' background:rgba(0,0,0,.45); }',
@@ -108,7 +114,7 @@ const CSS = [
108
114
  '.sm-more:hover { background:var(--dsw-alias-interactive-bg-hover); color:var(--dsw-alias-label-primary); }',
109
115
  '.sm-more:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary); outline-offset:-1px; }',
110
116
  '@media (prefers-reduced-motion: reduce) { .sm-group__chev { transition:none; } }',
111
- '@media (prefers-reduced-motion: reduce) { .sm-row__actions { transition:none; } }',
117
+ '@media (prefers-reduced-motion: reduce) { .sm-btn--danger, .sm-deleted .sm-row__actions { transition:none; } }',
112
118
  // 自动归档配置行:数值输入内联呈现,窄面板可换行
113
119
  '.sm-cfg { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-top:10px;',
114
120
  ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
@@ -340,8 +346,8 @@ function ArchiveRow(props) {
340
346
  h('button', { key: 'cancel', className: 'sm-btn', disabled: busy, onClick: props.onDisarm }, '取消'),
341
347
  ]
342
348
  : [
343
- h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
344
349
  h('button', { key: 'delete', className: 'sm-btn sm-btn--danger', disabled: busy, title: DELETE_BUTTON_TITLE, onClick: props.onDelete }, '删除'),
350
+ h('button', { key: 'restore', className: 'sm-btn sm-btn--restore', disabled: busy, onClick: props.onUnarchive }, '恢复'),
345
351
  ]
346
352
  return h('div', {
347
353
  className: 'sm-row' + (armed ? ' sm-row--armed' : '') + (busy ? ' sm-row--busy' : ''),
package/src/index.js CHANGED
@@ -98,6 +98,21 @@ const SETTINGS_SCHEMA = schemastery.object({
98
98
  .description('周期评估间隔小时数,0 表示关闭周期评估;变更经周期 tick 对账,关闭即时暂停、重启用最迟下个 tick 生效'),
99
99
  })
100
100
 
101
+ // 0.1.7 宿主以静态 Config 导出生成设置节表单(maintain 同构);legacy 宿主经
102
+ // settings.register 注册同名命名空间。根级 volatile 包装:0.1.7 下整节为 live
103
+ // 表单,节写经 loader 原地热更;旧宿主 schemastery 无 volatile 方法,特性检测原样返回
104
+ export const Config = volatileWrap(SETTINGS_SCHEMA)
105
+
106
+ function volatileWrap(schema) {
107
+ return typeof schema.volatile === 'function' ? schema.volatile() : schema
108
+ }
109
+
110
+ // volatile ref 动态解包(get 协议):0.1.7 下 apply 入参 config 为整节单 ref;
111
+ // legacy 宿主 config 为普通对象原样透传
112
+ function unwrapVolatile(value) {
113
+ return typeof value?.get === 'function' ? value.get() : value
114
+ }
115
+
101
116
  function sendJson(res, status, payload) {
102
117
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
103
118
  res.end(JSON.stringify(payload))
@@ -114,9 +129,12 @@ function respondError(ctx, res, error) {
114
129
  sendJson(res, 400, { error: message })
115
130
  }
116
131
 
117
- function readSettings(ctx) {
132
+ function readSettings(ctx, config) {
118
133
  const settings = ctx.get('settings')
119
- const value = settings ? settings.get(NAMESPACE) : undefined
134
+ // 双形读(maintain 同构):legacy settings 命名空间 get;0.1.7+ apply 入参
135
+ // config 整节解包。方法面守卫兼防:服务在但缺 get(宿主升级变更面)时回落默认值,
136
+ // 防 timer 回调抛错崩进程
137
+ const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : unwrapVolatile(config)
120
138
  const days = Number(value && value.autoArchiveDays)
121
139
  const hours = Number(value && value.autoArchiveIntervalHours)
122
140
  return {
@@ -228,7 +246,7 @@ export function apply(ctx, config) {
228
246
  // 活跃会话零 IO。返回是否真正启动:被门闩/关闭挡下的调用方(周期 tick)保持到期态
229
247
  // 由下个 tick 重试,不得据此重排周期
230
248
  function evaluateArchives(cwd) {
231
- const { days } = readSettings(ctx)
249
+ const { days } = readSettings(ctx, config)
232
250
  if (days === 0 || evaluating) return false
233
251
  evaluating = true
234
252
  let timeoutGuard
@@ -624,7 +642,7 @@ export function apply(ctx, config) {
624
642
  handler: async (req, res) => {
625
643
  try {
626
644
  if (req.method === 'GET') {
627
- const current = readSettings(ctx)
645
+ const current = readSettings(ctx, config)
628
646
  sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
629
647
  return
630
648
  }
@@ -636,11 +654,20 @@ export function apply(ctx, config) {
636
654
  throw new Error(MESSAGES.badJsonBody)
637
655
  }
638
656
  const patch = readAutoArchivePatch(body)
657
+ // 双形写(maintain 同构,判定延迟到使用点):legacy 方法面以 register 为准
658
+ // (0.1.7 的 settings.update 名义在场但按命名空间找条目必败,不可作判据);
659
+ // 0.1.7+ 走 configEditor.edit 落 profile 条目(fiber.entry,与命名空间解耦)。
660
+ // await 保证回显为新值,persist 失败走 respondError
639
661
  const settings = ctx.get('settings')
640
- if (!settings) throw new Error('宿主设置服务不可用')
641
- // update 异步落盘后才提交新值:await 保证回显为新值,persist 失败走 respondError
642
- await settings.update(NAMESPACE, patch)
643
- const current = readSettings(ctx)
662
+ if (typeof settings?.register === 'function' && typeof settings?.update === 'function') {
663
+ await settings.update(NAMESPACE, patch)
664
+ } else {
665
+ const editor = ctx.get('configEditor')
666
+ const entry = ctx.fiber?.entry
667
+ if (!editor || typeof editor.edit !== 'function' || !entry) throw new Error('宿主设置服务不可用')
668
+ await editor.edit(entry, (current) => ({ ...current, ...patch }))
669
+ }
670
+ const current = readSettings(ctx, config)
644
671
  sendJson(res, 200, { days: current.days, intervalHours: current.intervalHours })
645
672
  } catch (error) {
646
673
  respondError(ctx, res, error)
@@ -735,13 +762,13 @@ export function apply(ctx, config) {
735
762
  let nextDueAt = null
736
763
  let lastArmedAt = 0
737
764
  const scheduleNext = () => {
738
- const { intervalHours } = readSettings(ctx)
765
+ const { intervalHours } = readSettings(ctx, config)
739
766
  lastArmedAt = Date.now()
740
767
  nextDueAt = intervalHours > 0 ? lastArmedAt + intervalHours * HOUR_MS : null
741
768
  }
742
769
  try {
743
770
  const dispose = timerCtx.interval(() => {
744
- const { intervalHours } = readSettings(ctx)
771
+ const { intervalHours } = readSettings(ctx, config)
745
772
  // 设置变更对账:0 即时暂停;缺失 due(0 重启用)时补排,防周期轮静默死亡;
746
773
  // 缩短间隔时以 lastArmedAt 锚定前移,最长等新间隔而非等满旧周期
747
774
  if (intervalHours === 0) {
@@ -769,6 +796,15 @@ export function apply(ctx, config) {
769
796
  })
770
797
 
771
798
  ctx.inject(['settings'], (settingsCtx) => {
799
+ // 方法面守卫:settings 服务在但缺 register(宿主升级变更面)时跳过注册,
800
+ // 周期轮与启动补扫按 readSettings 的默认值降级运行,不因服务形变炸 fiber;
801
+ // 0.1.7 关闭原生自动设置页,设置面由本包自带分区承担(maintain 同构)
802
+ if (typeof settingsCtx.settings.register !== 'function') {
803
+ if (typeof settingsCtx.settings?.configure === 'function') {
804
+ settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber))
805
+ }
806
+ return
807
+ }
772
808
  settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA, { base: config })
773
809
  // 启动补扫不依赖定时服务:settings 就绪即评估一轮,清掉停机期间积压的超期会话
774
810
  evaluateArchives()
@@ -0,0 +1,46 @@
1
+ // 归档行操作按钮显隐与顺序契约:恢复常显,删除跟随行悬停,删除在恢复之前。
2
+ // 文案与行为同源锁定,防重构把恢复键重新藏进行悬停。
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
+
9
+ const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
10
+
11
+ // Given 非悬停归档行, When 渲染, Then 恢复常显、删除隐藏;行悬停或键盘聚焦时删除显现
12
+ test('源码契约:恢复常显,删除跟随行悬停与聚焦显现', () => {
13
+ // 操作容器基础常显:不带整体 opacity:0(整体隐藏会让子级无法单独显形)
14
+ assert.ok(CLIENT_SRC.includes('.sm-row__actions { display:flex; gap:2px; justify-content:flex-end; }'),
15
+ '操作容器未改为常显基础形态')
16
+ // 删除按钮默认隐藏;隐藏规则须位于 .sm-btn:disabled 之后(同特异度后出者胜),
17
+ // 否则 busy 禁用态被禁用半透明规则压过而幽灵显形
18
+ assert.ok(/\.sm-btn--danger[^{]*\{ opacity:0/.test(CLIENT_SRC), '删除按钮未默认隐藏')
19
+ // 锚点取规则体而非选择器名,对提及选择器的注释文本免疫
20
+ assert.ok(CLIENT_SRC.indexOf('.sm-btn--danger:disabled { opacity:0') > CLIENT_SRC.indexOf('.sm-btn:disabled { opacity'),
21
+ '删除隐藏规则未置于 .sm-btn:disabled 之后,禁用态会被半透明压过')
22
+ // 行悬停与键盘聚焦显现删除按钮
23
+ assert.ok(CLIENT_SRC.includes('.sm-row:hover .sm-btn--danger'), '行悬停未显现删除按钮')
24
+ assert.ok(CLIENT_SRC.includes('.sm-row:focus-within .sm-btn--danger'), '键盘聚焦未显现删除按钮')
25
+ })
26
+
27
+ // Given 归档行动作渲染, When 构建按钮序列, Then 删除在恢复之前(换位后恢复贴最右)
28
+ test('源码契约:删除按钮先于恢复按钮', () => {
29
+ // 断言限定在 ArchiveRow 片段内,避免文件级首次命中漂移到其他行的同名按钮
30
+ const rowSrc = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function ArchiveRow'), CLIENT_SRC.indexOf('function DeleteDialog'))
31
+ const dangerAt = rowSrc.indexOf("className: 'sm-btn sm-btn--danger'")
32
+ const restoreAt = rowSrc.indexOf("className: 'sm-btn sm-btn--restore'")
33
+ assert.ok(dangerAt >= 0 && restoreAt >= 0, '缺删除或恢复按钮')
34
+ assert.ok(dangerAt < restoreAt, '删除按钮未换位到恢复按钮之前')
35
+ })
36
+
37
+ // Given 已删除区与减弱动效环境, When 渲染, Then 已删除行操作保持悬停显现且动效可关
38
+ test('源码契约:已删除区保持悬停显现且减弱动效覆盖新选择器', () => {
39
+ // 已删除区行操作悬停显现(作用域规则,不回归容器整体隐藏)
40
+ assert.ok(CLIENT_SRC.includes('.sm-deleted .sm-row__actions { opacity:0'), '已删除区未保持悬停显现')
41
+ assert.ok(CLIENT_SRC.includes('.sm-deleted .sm-row:hover .sm-row__actions'), '已删除区缺行悬停显现规则')
42
+ assert.ok(CLIENT_SRC.includes('.sm-deleted .sm-row:focus-within .sm-row__actions'), '已删除区缺键盘聚焦显现规则')
43
+ // 减弱动效覆盖删除按钮与已删除区两处过渡
44
+ assert.ok(/prefers-reduced-motion: reduce.*\{ \.sm-btn--danger, \.sm-deleted \.sm-row__actions \{ transition:none/.test(CLIENT_SRC),
45
+ '减弱动效未覆盖删除按钮与已删除区过渡')
46
+ })
@@ -118,16 +118,20 @@ function makeCtx({
118
118
  openRejected,
119
119
  workspaces,
120
120
  settingsValue,
121
+ settingsService: settingsServiceOverride,
121
122
  timerAvailable,
122
123
  readSessions,
124
+ configRef,
125
+ configEditor,
123
126
  }) {
124
127
  const routes = []
125
128
  const eventHandlers = {}
126
129
  const pendingInjects = []
130
+ const disposers = []
127
131
  const ledgerDomain = ledger === undefined ? makeLedgerDomain([]) : ledger
128
132
  const workspaceList = workspaces || []
129
133
  const settingsState = { value: settingsValue }
130
- const settingsService = {
134
+ const settingsService = settingsServiceOverride ?? {
131
135
  get: () => settingsState.value,
132
136
  register: () => ({ resolved: undefined }),
133
137
  // 宿主 update 为异步串行:合并进微任务队列后生效,读旧值发生在 flush 前
@@ -173,7 +177,12 @@ function makeCtx({
173
177
  const base = {
174
178
  effect: (fn) => fn(),
175
179
  inject: (_deps, fn) => { pendingInjects.push(fn) },
176
- get: (name) => ({ agents, sessionPersistence, settings: settingsService }[name]),
180
+ // fiber 桩:0.1.7 configure 装配以本条目 fiber 为 owner
181
+ fiber: { entry: { options: { id: 'session-manager', name: '@mzzsfy/dsh-session-manager' } } },
182
+ get: (name) => {
183
+ if (name === 'configEditor') return configEditor
184
+ return { agents, sessionPersistence, settings: settingsService }[name]
185
+ },
177
186
  on: (event, handler) => { eventHandlers[event] = handler },
178
187
  // 日志桩:partial 降级点(logger.warn)在测试中可执行且可断言,不再被 undefined 短路
179
188
  logger: { warns: [], warn(message) { this.warns.push(message) }, infos: [], info(message) { this.infos.push(message) } },
@@ -188,7 +197,7 @@ function makeCtx({
188
197
  return services[prop]
189
198
  },
190
199
  })
191
- apply(ctx, undefined)
200
+ apply(ctx, configRef)
192
201
  return {
193
202
  handlers: new Map(routes.map((route) => [route.path, route.handler])),
194
203
  eventHandlers,
@@ -199,7 +208,15 @@ function makeCtx({
199
208
  readCounts,
200
209
  // 模拟宿主 settings 与 timer 服务激活:触发 inject 回调(注册 + 启动补扫 + 周期武装)
201
210
  activateSettings: () => {
202
- const injected = { settings: settingsService, interval: timerAvailable === false ? undefined : intervalStub }
211
+ const injected = {
212
+ settings: settingsService,
213
+ interval: timerAvailable === false ? undefined : intervalStub,
214
+ // 注入上下文的 effect 桩:0.1.7 分支经它注册 configure 装配
215
+ effect(fn) {
216
+ const disposer = fn()
217
+ if (typeof disposer === 'function') disposers.push(disposer)
218
+ },
219
+ }
203
220
  while (pendingInjects.length > 0) pendingInjects.shift()(injected)
204
221
  },
205
222
  }
@@ -812,6 +829,42 @@ test('周期评估:间隔设置运行时变更经 tick 对账(0 重启用 / 关
812
829
  }
813
830
  })
814
831
 
832
+ test('settings 方法面缺失(0.1.7 SettingsForms 形态):tick 与 status 路由不抛错,配置回落默认', skipMissingDeps, async () => {
833
+ const dir = await mkdtemp(path.join(tmpdir(), 'sm-methodface-'))
834
+ // 宿主 0.1.7-alpha.1 将 settings 服务换形为 SettingsForms:缺 get/register,仅剩 update。
835
+ // 旧形态直接 settings.get 会在 timer 回调抛 TypeError 崩进程(实测事故),守卫后应回落默认值
836
+ const DAY_MS = 24 * 60 * 60 * 1000
837
+ const TICK_MS = 60 * 1000
838
+ mock.timers.enable({ apis: ['setInterval', 'Date'], now: Date.now() })
839
+ try {
840
+ const artifact = path.join(dir, 'a.jsonl')
841
+ await writeFile(artifact, '{"header":1}\n{"event":0}\n')
842
+ const stale = Date.now() - 30 * DAY_MS
843
+ await utimes(artifact, stale / 1000, stale / 1000)
844
+ const settingsForms = { update: async () => {} }
845
+ const { activateSettings, handlers, registry } = makeCtx({
846
+ archivedIds: [],
847
+ headers: [{ id: 'a', cwd: 'C:\\x', createdAt: stale }],
848
+ agents: new Map(),
849
+ settingsService: settingsForms,
850
+ sessionPersistence: { locate: () => ({ path: artifact }) },
851
+ })
852
+ activateSettings()
853
+ // 守卫生效:配置读不到回落默认(days=7,intervalHours=24),30 天超期夹具到期即按默认轮归档
854
+ mock.timers.tick(DAY_MS + TICK_MS)
855
+ await waitFor(() => registry.archiveCalls.includes('a'))
856
+ assert.deepEqual(registry.archiveCalls, ['a'], '配置缺失按默认间隔正常评估,不因服务形变停摆')
857
+ // status 路由读设置不抛:回默认 days/intervalHours
858
+ const statusHandler = handlers.get('/api/session-manager/status')
859
+ const res = response()
860
+ await statusHandler(request('s1', 'GET'), res)
861
+ assert.equal(res.status, 200)
862
+ } finally {
863
+ mock.timers.reset()
864
+ await rm(dir, { recursive: true, force: true })
865
+ }
866
+ })
867
+
815
868
  test('周期评估:缩短间隔经 earliest 钳制前移,不等满旧周期', skipMissingDeps, async () => {
816
869
  const dir = await mkdtemp(path.join(tmpdir(), 'sm-shorten-'))
817
870
  const TICK_MS = 60 * 1000
@@ -1728,3 +1781,103 @@ test('自动归档配置提交判定:client 与 core 镜像同源,提交守卫
1728
1781
  assert.match(clientSource, /classifyAutoArchiveInput\(committed\[field\], text\)/, 'commit 必须以 committed[field] 为守卫基准')
1729
1782
  })
1730
1783
 
1784
+
1785
+ // --- 0.1.7 settings 面双形适配(maintain a913c2c 同构) ---
1786
+
1787
+ test('Config 导出契约:根级 volatile 包装,validate 产整节单 ref,默认值对拍', () => {
1788
+ // Given 静态 Config 导出(0.1.7 节表单事实源)
1789
+ assert.ok(indexModule.Config, 'Config 导出必须在场')
1790
+ // When 空配置校验
1791
+ const resolved = indexModule.Config['~standard'].validate({})
1792
+ // Then 产整节单 ref(get 协议),字段为 schema 默认值
1793
+ assert.equal(resolved.issues, undefined)
1794
+ const section = resolved.value.get()
1795
+ assert.equal(section.autoArchiveDays, DEFAULT_AUTO_ARCHIVE_DAYS)
1796
+ assert.equal(section.autoArchiveIntervalHours, DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS)
1797
+ // When 显式值校验
1798
+ // Then 值透传
1799
+ const provided = indexModule.Config['~standard'].validate({ autoArchiveDays: 3 }).value.get()
1800
+ assert.equal(provided.autoArchiveDays, 3)
1801
+ })
1802
+
1803
+ test('0.1.7 形态:settings 无 get/register 时读回落 config ref 节值', skipMissingDeps, async () => {
1804
+ // Given settingsForms 只有 update(0.1.7 真实方法面)+ config ref 携带用户值
1805
+ const settingsForms = { update: async () => {} }
1806
+ const configRef = { get: () => ({ autoArchiveDays: 3, autoArchiveIntervalHours: 12 }) }
1807
+ const { handlers } = makeCtx({
1808
+ archivedIds: [],
1809
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1810
+ agents: new Map(),
1811
+ settingsService: settingsForms,
1812
+ configRef,
1813
+ })
1814
+ // When GET auto-archive
1815
+ const res = response()
1816
+ await handlers.get('/api/session-manager/auto-archive')(getRequest2('/api/session-manager/auto-archive', 'GET'), res)
1817
+ // Then 返回 config 节值而非默认
1818
+ assert.deepEqual(res.body, { days: 3, intervalHours: 12 })
1819
+ })
1820
+
1821
+ test('0.1.7 形态:auto-archive POST 经 configEditor.edit 落 fiber.entry 并回显新值', skipMissingDeps, async () => {
1822
+ // Given settingsForms 只有 update + configEditor 桩(合并写回 configStore)
1823
+ const settingsForms = { update: async () => {} }
1824
+ const configStore = { autoArchiveDays: 7 }
1825
+ const editCalls = []
1826
+ const configRef = { get: () => ({ ...configStore }) }
1827
+ const { handlers } = makeCtx({
1828
+ archivedIds: [],
1829
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1830
+ agents: new Map(),
1831
+ settingsService: settingsForms,
1832
+ configRef,
1833
+ configEditor: {
1834
+ async edit(entry, change) {
1835
+ editCalls.push(entry)
1836
+ const next = change({ ...configStore }, {})
1837
+ for (const [key, value] of Object.entries(next)) configStore[key] = value
1838
+ },
1839
+ },
1840
+ })
1841
+ // When POST 写 days
1842
+ const changed = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3 })
1843
+ // Then edit 定位本条目,合并写回,响应回显新值
1844
+ assert.deepEqual(changed.body, { days: 3, intervalHours: DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS })
1845
+ assert.equal(editCalls.length, 1)
1846
+ assert.equal(editCalls[0].options.id, 'session-manager')
1847
+ assert.equal(configStore.autoArchiveDays, 3)
1848
+ })
1849
+
1850
+ test('0.1.7 形态:configEditor 缺失即 400 拒写,配置不变', skipMissingDeps, async () => {
1851
+ // Given settingsForms 只有 update 且无 configEditor
1852
+ const settingsForms = { update: async () => {} }
1853
+ const configRef = { get: () => ({}) }
1854
+ const { handlers } = makeCtx({
1855
+ archivedIds: [],
1856
+ headers: [{ id: 's1', cwd: 'C:\\x', createdAt: 0 }],
1857
+ agents: new Map(),
1858
+ settingsService: settingsForms,
1859
+ configRef,
1860
+ })
1861
+ // When POST 写 days
1862
+ const denied = await postJson(handlers, '/api/session-manager/auto-archive', { days: 3 })
1863
+ // Then 400 错误响应
1864
+ assert.equal(denied.status, 400)
1865
+ assert.ok(denied.body.error)
1866
+ })
1867
+
1868
+ test('0.1.7 形态:settings.configure 在场即关闭原生自动页', skipMissingDeps, () => {
1869
+ // Given settingsForms 带 configure 无 register
1870
+ const configureCalls = []
1871
+ const settingsForms = { configure: (presentation, owner) => { configureCalls.push({ presentation, owner }); return () => {} } }
1872
+ const { activateSettings } = makeCtx({
1873
+ archivedIds: [],
1874
+ headers: [],
1875
+ agents: new Map(),
1876
+ settingsService: settingsForms,
1877
+ })
1878
+ // When 激活 settings 注入
1879
+ activateSettings()
1880
+ // Then 关闭自动页
1881
+ assert.equal(configureCalls.length, 1)
1882
+ assert.deepEqual(configureCalls[0].presentation, { auto: false })
1883
+ })