@mzzsfy/dsh-context-manager 0.1.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 +61 -0
- package/cordis.patch.yml +12 -0
- package/package.json +49 -0
- package/src/client.js +1362 -0
- package/src/core.mjs +200 -0
- package/src/history-cache.mjs +128 -0
- package/src/index.js +578 -0
- package/test/client-fork-enhance.test.mjs +60 -0
- package/test/client-fork-family.test.mjs +48 -0
- package/test/client-fork.test.mjs +251 -0
- package/test/client-help.test.mjs +58 -0
- package/test/client-id.test.mjs +24 -0
- package/test/client-scope.test.mjs +54 -0
- package/test/client-steer.test.mjs +265 -0
- package/test/client-switch-guard.test.mjs +33 -0
- package/test/core-family.test.mjs +122 -0
- package/test/core.test.mjs +223 -0
- package/test/history-cache.test.mjs +135 -0
- package/test/index.test.mjs +790 -0
- package/test/parity.test.mjs +94 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
|
|
7
|
+
|
|
8
|
+
// F1 家族版本环 的客户端契约(源码切片锁定):
|
|
9
|
+
// - 数据源是 sessions.list 快照行(parentSessionId),零新 RPC
|
|
10
|
+
// - 单成员家族/快照缺失不注入
|
|
11
|
+
// - 箭头跳转经 sessions.open(纯打开,不 fork)
|
|
12
|
+
// - 计数器防重复注入,停用 fork 时随 removeAll 一并清除
|
|
13
|
+
|
|
14
|
+
test('源码契约:家族环数据源为 sessions.list 快照(零新 RPC)', () => {
|
|
15
|
+
assert.ok(CLIENT_SRC.includes('sessions.list.getSnapshot()'), '家族投影应读会话列表快照')
|
|
16
|
+
assert.ok(CLIENT_SRC.includes('parent.parentId'), '投影应按快照行 parentId 建链(快照字段名与 wire 不同)')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('源码契约:快照行主键为 id(与服务 wire 字段名不同源)', () => {
|
|
20
|
+
const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function sessionFamilyMap'), CLIENT_SRC.indexOf('function familyRing'))
|
|
21
|
+
assert.ok(slice.includes('item.id'), '快照行主键应取 id')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('源码契约:编排派生会话(subagent)剔除出家族投影', () => {
|
|
25
|
+
const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function sessionFamilyMap'), CLIENT_SRC.indexOf('function familyRing'))
|
|
26
|
+
assert.ok(slice.includes("item.origin === 'subagent'"), 'subagent 会话不是用户的 fork 版本,应整行剔除')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('源码契约:单成员家族与快照缺失不注入计数器', () => {
|
|
30
|
+
const slice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('// 家族版本环'), CLIENT_SRC.indexOf('function jumpFamilyMember'))
|
|
31
|
+
assert.ok(slice.includes('ring && ring.total >= 2'), '成员不足 2 不注入')
|
|
32
|
+
assert.ok(slice.includes('actionsRow.querySelector'), '注入前应查重')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('源码契约:箭头跳转经 sessions.open 且不 fork', () => {
|
|
36
|
+
const jump = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function jumpFamilyMember'), CLIENT_SRC.indexOf('if (typeof MutationObserver'))
|
|
37
|
+
assert.ok(jump.includes('openFn(target)'), '跳转应调用 open 通道')
|
|
38
|
+
assert.ok(jump.includes('target !== current.sessionId'), '目标为自身时不切换')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('源码契约:家族环标记与清除齐备(停用开关一并移除)', () => {
|
|
42
|
+
assert.ok(CLIENT_SRC.includes("const FAMILY_RING_FLAG = 'data-cx-family-ring'"), '应有家族环标记常量')
|
|
43
|
+
assert.ok(CLIENT_SRC.includes("querySelectorAll('[' + FORK_BTN_FLAG + ']')"), '清除逻辑存在(环与按钮同排,按钮清除即整排消失)')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('源码契约:loadFamily 通道旧宿主降级返回 null', () => {
|
|
47
|
+
assert.ok(CLIENT_SRC.includes('typeof sessions.list.getSnapshot'), '快照面应以能力探测守门')
|
|
48
|
+
})
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// 对话 fork 接线测试:加载真实 src/client.js,断言 dock 注册条件、锚点映射构建、
|
|
2
|
+
// 注入契约与 fork 服务组装。锚点通道:remote.session.follow 开场帧(自带回溯窗口
|
|
3
|
+
// 内的事件与 seq)建立轮号→{ 结束 seq, 首问文本 } 映射;namespace 就绪由点分
|
|
4
|
+
// inject 门控。
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
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
|
+
// follow 开场帧桩:async-iterable,首个 snapshot 帧后即挂起(break 触发 return,
|
|
15
|
+
// 桩记录退订以证明「取到开场即断开」)
|
|
16
|
+
function followStub(records) {
|
|
17
|
+
const state = { returned: false }
|
|
18
|
+
const frames = (async function* generate() {
|
|
19
|
+
try {
|
|
20
|
+
yield { type: 'snapshot', cursor: 99, records }
|
|
21
|
+
await new Promise(() => {})
|
|
22
|
+
} finally {
|
|
23
|
+
state.returned = true
|
|
24
|
+
}
|
|
25
|
+
})()
|
|
26
|
+
return { frames, state }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadClient({ sessions, remoteSession } = {}) {
|
|
30
|
+
const modules = []
|
|
31
|
+
const registered = []
|
|
32
|
+
const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
|
|
33
|
+
const reactStub = {
|
|
34
|
+
useState: (value) => [value, () => {}],
|
|
35
|
+
useEffect: () => {},
|
|
36
|
+
useRef: (value) => ({ current: value }),
|
|
37
|
+
createElement: () => null,
|
|
38
|
+
}
|
|
39
|
+
const requireStub = (name) => {
|
|
40
|
+
if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
|
|
41
|
+
return reactStub
|
|
42
|
+
}
|
|
43
|
+
const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
|
|
44
|
+
factory(
|
|
45
|
+
windowStub,
|
|
46
|
+
requireStub,
|
|
47
|
+
{ createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
|
|
48
|
+
)
|
|
49
|
+
const mod = modules[0].factory(requireStub)
|
|
50
|
+
const sessionsStub = sessions || { binding: () => undefined, fork: () => Promise.resolve('child-1'), open: () => {} }
|
|
51
|
+
const ctx = {
|
|
52
|
+
get: (name) => (name === 'sessions' ? sessionsStub : {}),
|
|
53
|
+
effect: () => {},
|
|
54
|
+
slots: {
|
|
55
|
+
inject: (name, register) => register(),
|
|
56
|
+
register: (options, component) => registered.push({ options, component }),
|
|
57
|
+
},
|
|
58
|
+
remote: { session: remoteSession },
|
|
59
|
+
}
|
|
60
|
+
mod.apply(ctx)
|
|
61
|
+
return { registered }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function findForkEntry({ sessions, remoteSession } = {}) {
|
|
65
|
+
const { registered } = loadClient({ sessions, remoteSession })
|
|
66
|
+
const entries = registered.filter((item) => item.options.name === 'conversation.input.dock')
|
|
67
|
+
const entry = entries.find((item) => item.options.id === 'context-manager-fork')
|
|
68
|
+
return { entry, entries }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EMPTY_FOLLOW = { follow: () => followStub([]).frames }
|
|
72
|
+
|
|
73
|
+
test('inject 点分声明 remote.namespace(规约:禁止 apply 内同步探测 remote 面)', () => {
|
|
74
|
+
const declared = CLIENT_SRC.match(/inject: \[([^\]]*)\]/)
|
|
75
|
+
assert.ok(declared, 'client.js 缺 inject 声明')
|
|
76
|
+
const names = declared[1].split(',').map((item) => item.trim().replace(/'/g, '')).filter(Boolean)
|
|
77
|
+
assert.ok(names.includes('remote'), 'inject 必须声明 remote')
|
|
78
|
+
assert.ok(names.includes('remote.session'), 'inject 必须点分声明 remote.session(cordis 门控 namespace 就绪)')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('Given sessions 服务与 remote.session.follow 齐备, When apply, Then 注册 fork dock 条目', () => {
|
|
82
|
+
const { entry } = findForkEntry({ remoteSession: EMPTY_FOLLOW })
|
|
83
|
+
assert.ok(entry, 'fork dock 条目未注册')
|
|
84
|
+
assert.equal(entry.options.order, 24, 'fork 条目应排在官方队列(20)/撤回(25)邻近位')
|
|
85
|
+
assert.equal(typeof entry.component, 'function', 'fork dock 条目缺组件')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('Given sessions 服务缺 fork/open(旧宿主), When apply, Then fork 条目不注册且其余保留', () => {
|
|
89
|
+
const { entry, entries } = findForkEntry({
|
|
90
|
+
sessions: { binding: () => undefined },
|
|
91
|
+
remoteSession: EMPTY_FOLLOW,
|
|
92
|
+
})
|
|
93
|
+
assert.equal(entry, undefined, 'sessions 无 fork 时 fork 条目不应注册')
|
|
94
|
+
assert.ok(entries.some((item) => item.options.id === 'context-manager-steer'), '撤回条目不应被 fork 门控阻塞')
|
|
95
|
+
assert.ok(entries.some((item) => item.options.id === 'context-manager-history'), '历史条目不应被 fork 门控阻塞')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('Given dock inject, Then 返回 forkSession(loadTurnEnds 通道)且按会话寻址', async () => {
|
|
99
|
+
const forkCalls = []
|
|
100
|
+
const openCalls = []
|
|
101
|
+
const { entry } = findForkEntry({
|
|
102
|
+
sessions: { binding: () => undefined, fork: (opts) => { forkCalls.push(opts); return Promise.resolve('child-9') }, open: (id) => { openCalls.push(id) } },
|
|
103
|
+
remoteSession: EMPTY_FOLLOW,
|
|
104
|
+
})
|
|
105
|
+
const props = entry.options.inject('s1')
|
|
106
|
+
assert.equal(typeof props.forkSession, 'function', 'inject 未返回 forkSession')
|
|
107
|
+
assert.equal(typeof props.loadTurnEnds, 'function', 'inject 未返回 loadTurnEnds')
|
|
108
|
+
const childId = await props.forkSession({ sessionId: 's1', atSeq: 7, increaseTitle: true })
|
|
109
|
+
assert.deepEqual(forkCalls, [{ sessionId: 's1', atSeq: 7, increaseTitle: true }])
|
|
110
|
+
assert.deepEqual(openCalls, ['child-9'], 'fork 成功后应 open 子会话')
|
|
111
|
+
assert.equal(childId, 'child-9', 'forkSession 应解析出子会话 id')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('Given open 失败, When forkSession, Then 分叉成功事实不被推翻(childId 照常解析)', async () => {
|
|
115
|
+
const forkCalls = []
|
|
116
|
+
const { entry } = findForkEntry({
|
|
117
|
+
sessions: {
|
|
118
|
+
binding: () => undefined,
|
|
119
|
+
fork: (opts) => { forkCalls.push(opts); return Promise.resolve('child-7') },
|
|
120
|
+
open: () => Promise.reject(new Error('open down')),
|
|
121
|
+
},
|
|
122
|
+
remoteSession: EMPTY_FOLLOW,
|
|
123
|
+
})
|
|
124
|
+
const props = entry.options.inject('s1')
|
|
125
|
+
const childId = await props.forkSession({ sessionId: 's1', atSeq: 3 })
|
|
126
|
+
assert.equal(childId, 'child-7', 'open 失败不得把已成功的分叉上报为失败')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('Given loadTurnEnds, Then 轮映射按 data.turn 登记结束 seq 与该轮首条用户文本,缺失按序计数', async () => {
|
|
130
|
+
const records = [
|
|
131
|
+
{ event: { type: 'user/message', seq: 1, data: {} } },
|
|
132
|
+
{ event: { type: 'turn/start', seq: 2, data: { turn: 0 } } },
|
|
133
|
+
{ event: { type: 'user/message', seq: 3, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第一问' }] } } },
|
|
134
|
+
{ event: { type: 'turn/end', seq: 4, data: { turn: 0 } } },
|
|
135
|
+
{ event: { type: 'assistant/message', seq: 5, data: {} } },
|
|
136
|
+
{ event: { type: 'turn/start', seq: 6, data: { turn: 1 } } },
|
|
137
|
+
{ event: { type: 'user/message', seq: 7, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第二问' }] } } },
|
|
138
|
+
{ event: { type: 'turn/end', seq: 9, data: {} } },
|
|
139
|
+
{ event: { type: 'turn/end', seq: 20, data: { turn: 5 } } },
|
|
140
|
+
]
|
|
141
|
+
const follow = followStub(records)
|
|
142
|
+
const { entry } = findForkEntry({ remoteSession: { follow: () => follow.frames } })
|
|
143
|
+
const props = entry.options.inject('s1')
|
|
144
|
+
const map = await props.loadTurnEnds()
|
|
145
|
+
assert.ok(map instanceof Map)
|
|
146
|
+
assert.deepEqual(map.get(0), { seq: 4, text: '第一问', open: false })
|
|
147
|
+
assert.deepEqual(map.get(1), { seq: 9, text: '第二问', open: false }, 'data 无 turn 号按出现顺序计数')
|
|
148
|
+
assert.deepEqual(map.get(5), { seq: 20, text: null, open: false }, '无用户文本的轮 text 为 null')
|
|
149
|
+
assert.equal(map.size, 3)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('Given 轮内插话(user 消息后又有 user 消息), When loadTurnEnds, Then 取本轮首条', async () => {
|
|
153
|
+
const records = [
|
|
154
|
+
{ event: { type: 'turn/start', seq: 1, data: { turn: 0 } } },
|
|
155
|
+
{ event: { type: 'user/message', seq: 2, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '首问' }] } } },
|
|
156
|
+
{ event: { type: 'user/message', seq: 3, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '插话' }] } } },
|
|
157
|
+
{ event: { type: 'turn/end', seq: 4, data: { turn: 0 } } },
|
|
158
|
+
]
|
|
159
|
+
const { entry } = findForkEntry({ remoteSession: { follow: () => followStub(records).frames } })
|
|
160
|
+
const map = await entry.options.inject('s1').loadTurnEnds()
|
|
161
|
+
assert.equal(map.get(0).text, '首问')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test('Given 开场帧取得, When loadTurnEnds 完成, Then follow 流已断开(不消费 live 帧)', async () => {
|
|
165
|
+
const follow = followStub([{ event: { type: 'turn/end', seq: 2, data: { turn: 0 } } }])
|
|
166
|
+
const { entry } = findForkEntry({ remoteSession: { follow: () => follow.frames } })
|
|
167
|
+
const props = entry.options.inject('s1')
|
|
168
|
+
const map = await props.loadTurnEnds()
|
|
169
|
+
assert.equal(map.get(0).seq, 2)
|
|
170
|
+
// 退订经微任务链收敛:loadTurnEnds 解析时 for-await break 已触发 iterator.return
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
172
|
+
assert.ok(follow.state.returned, '取到开场帧后应断开 follow 流')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('Given follow 失败, When loadTurnEnds, Then 异常向上传播(组件侧降级不注入)', async () => {
|
|
176
|
+
const { entry } = findForkEntry({ remoteSession: { follow: () => { throw new Error('gateway down') } } })
|
|
177
|
+
const props = entry.options.inject('s1')
|
|
178
|
+
await assert.rejects(props.loadTurnEnds(), /gateway down/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// 注入契约(源码级守卫):锚点属性/按钮标记/官方类名克隆/清理/点分门控必须存在
|
|
182
|
+
test('ForkDock 注入契约:轮号锚点属性、按钮标记、防重标记与卸载清理齐备', () => {
|
|
183
|
+
assert.ok(CLIENT_SRC.includes("TURN_ATTR = 'data-chat-turn'"), 'fork 锚点必须使用官方轮号标记')
|
|
184
|
+
assert.ok(CLIENT_SRC.includes('data-cx-fork'), '应有自家按钮标记防重复注入')
|
|
185
|
+
assert.ok(CLIENT_SRC.includes("querySelectorAll('[' + TURN_ATTR + ']')"), '应按轮号标记扫描消息气泡')
|
|
186
|
+
assert.ok(CLIENT_SRC.includes("FLOW_KIND_ATTR = 'data-chat-flow-kind'"), '应识别官方气泡语义类型标记')
|
|
187
|
+
assert.ok(CLIENT_SRC.includes("getAttribute(FLOW_KIND_ATTR) !== 'user'"), '只注入用户输入气泡(官方分支按钮驻留轮尾,语义互补不重叠)')
|
|
188
|
+
assert.ok(CLIENT_SRC.includes('increaseTitle: true'), 'fork 请求应递增子会话标题')
|
|
189
|
+
assert.ok(CLIENT_SRC.includes('observer.disconnect()'), '卸载应断开观察')
|
|
190
|
+
assert.ok(CLIENT_SRC.includes("remote.follow({ address: { kind: 'session', sessionId }"), '锚点必须按会话寻址 follow 开场帧')
|
|
191
|
+
assert.ok(CLIENT_SRC.includes("frame.type === 'snapshot'"), '映射构建必须取自 follow 开场帧')
|
|
192
|
+
// 图标与官方同源:fill 细路径单 path 形态(官方 IconBranchOutline16)
|
|
193
|
+
const forkSvgSlice = CLIENT_SRC.slice(CLIENT_SRC.indexOf('function forkSvg('), CLIENT_SRC.indexOf('// 注入按钮标记'))
|
|
194
|
+
assert.ok(forkSvgSlice.includes("path.setAttribute('fill-rule', 'evenodd')"), 'fork 图标应为官方 fill 细路径形态')
|
|
195
|
+
assert.ok(forkSvgSlice.includes("svg.setAttribute('stroke'") === false, 'fork 图标不应是自绘描边形态')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// 重试语义:分叉到该轮之前(锚点 = 前一轮 turn/end seq),该轮用户输入回填子会话输入框
|
|
199
|
+
test('ForkDock 重试契约:锚点前移取前一轮、pending 草稿通道、子会话挂载回填齐备', () => {
|
|
200
|
+
assert.ok(CLIENT_SRC.includes("boundary.seq + 1") === false, '不应复刻宿主实现细节')
|
|
201
|
+
assert.ok(CLIENT_SRC.includes('atSeq: previousEntry.seq'), 'fork 请求锚点必须是前一轮(重试该轮本身不带入)')
|
|
202
|
+
assert.ok(CLIENT_SRC.includes('pendingForkDrafts.set(childId, { text: currentEntry.text, autoSubmit: autoResendOn })'), 'fork resolve 后必须按子会话 id 登记该轮首问(autoSubmit 跟随开关)')
|
|
203
|
+
assert.ok(CLIENT_SRC.includes('pendingForkDrafts.get(sessionId)'), '子会话挂载应按会话 id 消费草稿')
|
|
204
|
+
assert.ok(CLIENT_SRC.includes('pendingForkDrafts.delete(sessionId)'), '草稿消费后应清除')
|
|
205
|
+
assert.ok(CLIENT_SRC.includes('inputActions.setDraft(text)'), '回填必须经宿主 inputActions.setDraft')
|
|
206
|
+
assert.ok(CLIENT_SRC.includes('forkRetryText'), '文本提取必须与 core 镜像同源')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('Given fork 成功, When 点击重试, Then 请求锚点为前一轮 seq 且草稿登记到子会话', async () => {
|
|
210
|
+
const forkCalls = []
|
|
211
|
+
const setDraftCalls = []
|
|
212
|
+
const records = [
|
|
213
|
+
{ event: { type: 'turn/start', seq: 1, data: { turn: 0 } } },
|
|
214
|
+
{ event: { type: 'user/message', seq: 2, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第一问' }] } } },
|
|
215
|
+
{ event: { type: 'turn/end', seq: 3, data: { turn: 0 } } },
|
|
216
|
+
{ event: { type: 'turn/start', seq: 4, data: { turn: 1 } } },
|
|
217
|
+
{ event: { type: 'user/message', seq: 5, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第二问' }] } } },
|
|
218
|
+
{ event: { type: 'turn/end', seq: 6, data: { turn: 1 } } },
|
|
219
|
+
]
|
|
220
|
+
const { entry } = findForkEntry({
|
|
221
|
+
sessions: { binding: () => undefined, fork: (opts) => { forkCalls.push(opts); return Promise.resolve('child-1') }, open: () => {} },
|
|
222
|
+
remoteSession: { follow: () => followStub(records).frames },
|
|
223
|
+
})
|
|
224
|
+
const props = entry.options.inject('s1')
|
|
225
|
+
const map = await props.loadTurnEnds()
|
|
226
|
+
// 锚点前移:重试轮 1 → boundary 取轮 0 的 turn/end seq(不含轮 1)
|
|
227
|
+
assert.equal(map.get(1).seq, 6, '轮 1 结束 seq')
|
|
228
|
+
assert.equal(map.get(0).seq, 3, '前一轮(轮 0)结束 seq——重试轮 1 的 fork 锚点')
|
|
229
|
+
assert.equal(map.get(1).text, '第二问', '重试文本取本轮首问')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('Given fork 失败(未知 code), When 点击重试, Then toast 透传映射文案且不登记草稿', async () => {
|
|
233
|
+
const toasts = []
|
|
234
|
+
const forkCalls = []
|
|
235
|
+
const records = [
|
|
236
|
+
{ event: { type: 'turn/start', seq: 1, data: { turn: 0 } } },
|
|
237
|
+
{ event: { type: 'user/message', seq: 2, data: { source: { kind: 'user' }, content: [{ type: 'text', text: '第一问' }] } } },
|
|
238
|
+
{ event: { type: 'turn/end', seq: 3, data: { turn: 0 } } },
|
|
239
|
+
]
|
|
240
|
+
const { entry } = findForkEntry({
|
|
241
|
+
sessions: {
|
|
242
|
+
binding: () => undefined,
|
|
243
|
+
fork: (opts) => { forkCalls.push(opts); return Promise.reject({ code: 'gateway/internal', message: 'gateway/internal: boom' }) },
|
|
244
|
+
open: () => {},
|
|
245
|
+
},
|
|
246
|
+
remoteSession: { follow: () => followStub(records).frames },
|
|
247
|
+
})
|
|
248
|
+
const props = entry.options.inject('s1')
|
|
249
|
+
await props.loadTurnEnds()
|
|
250
|
+
assert.ok(forkCalls.length === 0, '未点击不得发起 fork')
|
|
251
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const CLIENT_SRC = readFileSync(fileURLToPath(new URL('../src/client.js', import.meta.url)), 'utf8')
|
|
7
|
+
|
|
8
|
+
// 设置项悬停说明契约:说明常量必须存在且逐项挂到对应开关(开关行工厂调用位)。
|
|
9
|
+
// 断言以工厂调用为锚:行文本与说明常量必须出现在同一 switchRow(...) 调用内,
|
|
10
|
+
// 防常量互换挂载或挂错行仍通过
|
|
11
|
+
test('源码契约:三项设置的悬停说明齐备且挂到对应控件', () => {
|
|
12
|
+
const TITLES = {
|
|
13
|
+
HISTORY_SWITCH_TITLE: '历史输入浮层开关',
|
|
14
|
+
STEER_SWITCH_TITLE: '插话撤回开关',
|
|
15
|
+
FORK_SWITCH_TITLE: '对话 fork 开关',
|
|
16
|
+
FORK_AUTO_RESEND_SWITCH_TITLE: '分叉后自动重发开关',
|
|
17
|
+
}
|
|
18
|
+
for (const [name, label] of Object.entries(TITLES)) {
|
|
19
|
+
assert.ok(CLIENT_SRC.includes('const ' + name + ' ='), '缺悬停说明常量 ' + name)
|
|
20
|
+
}
|
|
21
|
+
const rowAnchors = [
|
|
22
|
+
{ constant: 'HISTORY_SWITCH_TITLE', text: "'历史输入浮层(Alt+↑)'", label: TITLES.HISTORY_SWITCH_TITLE },
|
|
23
|
+
{ constant: 'STEER_SWITCH_TITLE', text: "'插话撤回'", label: TITLES.STEER_SWITCH_TITLE },
|
|
24
|
+
{ constant: 'FORK_SWITCH_TITLE', text: "'对话 fork'", label: TITLES.FORK_SWITCH_TITLE },
|
|
25
|
+
{ constant: 'FORK_AUTO_RESEND_SWITCH_TITLE', text: "'分叉后自动重发'", label: TITLES.FORK_AUTO_RESEND_SWITCH_TITLE },
|
|
26
|
+
]
|
|
27
|
+
for (const { constant, text, label } of rowAnchors) {
|
|
28
|
+
// 限定长度的跨行窗口而非单行 [^\n]*:合法的多行格式化(prettier)不误报,
|
|
29
|
+
// 常量与行文本漂移出同一调用(>200 字符)仍会被检出
|
|
30
|
+
const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
31
|
+
const mounted = new RegExp('switchRow\\([\\s\\S]{0,200}' + escaped + '[\\s\\S]{0,200}' + constant).test(CLIENT_SRC)
|
|
32
|
+
assert.ok(mounted, label + ' 未挂对应悬停说明(常量与行文本不在同一工厂调用内)')
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// 说明文案与行为同源的抽样锁定:核心语义缺失即为文案回归
|
|
37
|
+
test('源码契约:悬停说明覆盖各设置的关键行为语义', () => {
|
|
38
|
+
assert.ok(CLIENT_SRC.includes('←/→ 切换范围'), '历史说明缺范围切换语义')
|
|
39
|
+
assert.ok(CLIENT_SRC.includes('刷新页面生效'), '开关说明缺刷新生效提示')
|
|
40
|
+
assert.ok(CLIENT_SRC.includes('含附件的插话不可撤回'), '撤回说明缺附件限制')
|
|
41
|
+
assert.ok(CLIENT_SRC.includes('进行中的轮(回复尚未完成)'), 'fork 说明缺进行中轮可分叉语义')
|
|
42
|
+
assert.ok(CLIENT_SRC.includes('停止本会话该轮未完成的回复'), 'fork 说明缺进行中轮分叉后止损语义')
|
|
43
|
+
assert.ok(CLIENT_SRC.includes('无文本输入的轮'), 'fork 说明缺纯图等无文本轮限制')
|
|
44
|
+
assert.ok(CLIENT_SRC.includes('回填子会话输入框'), 'fork 说明缺重试回填语义')
|
|
45
|
+
assert.ok(CLIENT_SRC.includes('尾号递增'), 'fork 说明缺标题递增语义')
|
|
46
|
+
assert.ok(CLIENT_SRC.includes('重生成语义'), '自动重发说明缺重生成语义')
|
|
47
|
+
assert.ok(CLIENT_SRC.includes('家族版本'), '计数器说明缺家族版本语义')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// 搜索功能源码契约:query 双写 + 组合输入守卫 + 命中计数 + 两级 Esc
|
|
51
|
+
test('源码契约:历史浮层搜索的实现要素齐备', () => {
|
|
52
|
+
assert.ok(CLIENT_SRC.includes('onCompositionStart'), '搜索框应处理 IME 组合输入开始')
|
|
53
|
+
assert.ok(CLIENT_SRC.includes('onCompositionEnd'), '搜索框应处理 IME 组合输入结束')
|
|
54
|
+
assert.ok(CLIENT_SRC.includes('composingRef'), '组合输入期间应经 ref 守卫跳过过滤')
|
|
55
|
+
assert.ok(CLIENT_SRC.includes('cx-hist__count'), '应有命中计数样式锚点')
|
|
56
|
+
assert.ok(CLIENT_SRC.includes('filterHistoryInputs(raw, next)'), '过滤应作用于原始条目集')
|
|
57
|
+
assert.ok(CLIENT_SRC.includes('queryRef.current'), 'query 应双写 ref 供键盘层读取')
|
|
58
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// client.js 注册 id 守卫:loader 按 graph row id(完整包名)匹配注册,短名即加载失败。
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
|
+
const { name } = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
10
|
+
|
|
11
|
+
test('client.js 注册 id 为完整包名', () => {
|
|
12
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
13
|
+
const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
|
|
14
|
+
assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
|
|
15
|
+
assert.equal(match[1], name)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('client.js 样式注入自带 data-plugin', () => {
|
|
19
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
20
|
+
const injections = source.split("createElement('style')").length - 1
|
|
21
|
+
assert.ok(injections > 0, 'client.js 无样式注入点')
|
|
22
|
+
const marked = source.split(`setAttribute('data-plugin', '${name}')`).length - 1
|
|
23
|
+
assert.equal(marked, injections, '每个样式注入点都必须自带 data-plugin(值=注册包名),防宿主 claimStyles 误归属后随他插件 HMR 整批误删')
|
|
24
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// client.js 求值形态守卫:宿主以经典 script 整源求值,顶层词法声明落页面全局词法环境,
|
|
2
|
+
// 跨 bundle 同名即整脚本 SyntaxError 拒载。本包 client.js 以单条 __ModuleLoader__.load
|
|
3
|
+
// 语句承载全部代码(声明均在 factory 作用域内),本守卫锁定"顶层零词法声明"不变量,
|
|
4
|
+
// 防未来声明误置顶层。vm.runInContext 与浏览器经典 script 同语义(全局词法环境跨脚本共享)。
|
|
5
|
+
import { test } from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import vm from 'node:vm'
|
|
11
|
+
|
|
12
|
+
const CLIENT_SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'client.js'), 'utf8').trimEnd()
|
|
13
|
+
const DECLARATION_NAMES = [
|
|
14
|
+
...new Set(
|
|
15
|
+
[...CLIENT_SOURCE.matchAll(/^(?:const|let|var|class|(?:async )?function\*?) ([A-Za-z_$][\w$]*)/gm)].map((match) => match[1])
|
|
16
|
+
),
|
|
17
|
+
]
|
|
18
|
+
const BUNDLE_ID = '@mzzsfy/dsh-context-manager'
|
|
19
|
+
|
|
20
|
+
test('Given 同 context 已有外部 CSS 声明(宿主经典 script 全局词法环境), When 整源求值 client.js, Then 双源共存不拒载且外部声明原值不变', () => {
|
|
21
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
22
|
+
vm.runInContext('const CSS = 1', ctx)
|
|
23
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
24
|
+
assert.equal(vm.runInContext('CSS', ctx), 1)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('Given client.js 已整源求值, When 同 context 再声明外部 CSS, Then 双源共存不拒载', () => {
|
|
28
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
29
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
30
|
+
vm.runInContext('const CSS = 1', ctx)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('Given client.js 整源求值完成, When 逐名以 const 重声明探针行首声明名, Then 全局词法环境零泄漏', () => {
|
|
34
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: () => {} } } })
|
|
35
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
36
|
+
const leaked = DECLARATION_NAMES.filter((name) => {
|
|
37
|
+
try {
|
|
38
|
+
vm.runInContext(`const ${name} = null`, ctx)
|
|
39
|
+
return false
|
|
40
|
+
} catch {
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
assert.deepEqual(leaked, [])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('Given 宿主 loader 就绪, When 整源求值 client.js, Then 自注册照常发生且 id 不变', () => {
|
|
48
|
+
const registrations = []
|
|
49
|
+
const ctx = vm.createContext({ window: { __ModuleLoader__: { load: (registration) => registrations.push(registration) } } })
|
|
50
|
+
vm.runInContext(CLIENT_SOURCE, ctx)
|
|
51
|
+
assert.equal(registrations.length, 1)
|
|
52
|
+
assert.equal(registrations[0].id, BUNDLE_ID)
|
|
53
|
+
assert.equal(typeof registrations[0].factory, 'function')
|
|
54
|
+
})
|