@mzzsfy/dsh-session-manager 0.8.1 → 0.9.1
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 +21 -29
- package/package.json +1 -1
- package/src/client.js +805 -1368
- package/src/core.mjs +262 -344
- package/src/index.js +851 -1166
- package/src/trash.mjs +40 -2
- package/test/client-delete.test.mjs +46 -0
- package/test/client-help.test.mjs +36 -0
- package/test/client-id.test.mjs +8 -0
- package/test/core.test.mjs +485 -595
- package/test/index.test.mjs +1730 -1968
- package/test/parity.test.mjs +0 -11
- package/test/trash.test.mjs +73 -2
- package/src/history-cache.mjs +0 -127
- package/test/client-steer.test.mjs +0 -266
- package/test/history-cache.test.mjs +0 -84
package/test/parity.test.mjs
CHANGED
|
@@ -211,17 +211,6 @@ test('源码契约:client 分页粒度常量与 core ARCHIVE_PAGE_SIZE 同值',
|
|
|
211
211
|
assert.ok(guard.test(CLIENT_SRC), 'client 分页粒度常量与 core 漂移')
|
|
212
212
|
})
|
|
213
213
|
|
|
214
|
-
test('源码契约:client fetchInputs 必须解包 host 响应信封 { inputs }', () => {
|
|
215
|
-
// host 与 client 对信封各自测试自洽时,信封形状漂移只会以运行时 TypeError 暴露;
|
|
216
|
-
// 此守卫锁定解包点存在,防消费侧把信封对象当数组使用
|
|
217
|
-
const guard = /Array\.isArray\((\w+)\.inputs\)/
|
|
218
|
-
assert.ok(guard.test('Array.isArray(payload.inputs)'), '守卫正则必须命中合规样本')
|
|
219
|
-
const fetchStart = CLIENT_SRC.indexOf('function fetchInputs(')
|
|
220
|
-
const fetchBody = fetchStart >= 0 ? CLIENT_SRC.slice(fetchStart, CLIENT_SRC.indexOf('}', fetchStart)) : ''
|
|
221
|
-
assert.ok(fetchBody.includes('api(INPUTS_URL'), '未找到 fetchInputs 的 host 请求')
|
|
222
|
-
assert.ok(guard.test(fetchBody), 'fetchInputs 缺少 payload.inputs 数组解包')
|
|
223
|
-
})
|
|
224
|
-
|
|
225
214
|
test('parity 文案:client 确认态文案与 host MESSAGES.unsupportedBackend 同值', () => {
|
|
226
215
|
const match = INDEX_SRC.match(/unsupportedBackend: '([^']+)'/)
|
|
227
216
|
assert.ok(match, 'index.js 缺少 unsupportedBackend 文案')
|
package/test/trash.test.mjs
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import test from 'node:test'
|
|
4
4
|
import assert from 'node:assert/strict'
|
|
5
|
-
import { mkdtemp, writeFile, rm, access } from 'node:fs/promises'
|
|
5
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm, access } from 'node:fs/promises'
|
|
6
6
|
import { tmpdir } from 'node:os'
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
|
|
9
|
-
import { TRASH_ENV_NAME, trashCommandFor, trashPath } from '../src/trash.mjs'
|
|
9
|
+
import { TRASH_ENV_NAME, moveToQuarantine, restoreFromQuarantine, trashCommandFor, trashPath } from '../src/trash.mjs'
|
|
10
10
|
|
|
11
11
|
test('Windows 走 PowerShell VisualBasic 回收站,路径经环境变量传递', () => {
|
|
12
12
|
const cmd = trashCommandFor('win32', 'C:\\logs\\a.jsonl')
|
|
@@ -71,3 +71,74 @@ test('trashPath 向执行器传递超时配置', async () => {
|
|
|
71
71
|
await trashPath('C:\\x\\a.jsonl', { platform: 'linux', run, timeoutMs: 5 * 1000 })
|
|
72
72
|
assert.equal(captured[1].timeout, 5 * 1000)
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
// Given 回收区目录不存在与同名基名, When moveToQuarantine, Then 目录整体搬入回收区
|
|
76
|
+
// 且返回唯一暂存路径(原路径消失,暂存路径存在)
|
|
77
|
+
test('moveToQuarantine:目录整体搬入回收区并返回暂存路径(真实 rename)', { skip: process.platform !== 'win32' && process.platform !== 'linux' && process.platform !== 'darwin' }, async () => {
|
|
78
|
+
const base = await mkdtemp(path.join(tmpdir(), 'dsh-quarantine-'))
|
|
79
|
+
const quarantineDir = path.join(base, 'held')
|
|
80
|
+
const artifactDir = path.join(base, 'sess-1')
|
|
81
|
+
await mkdir(path.join(artifactDir, 'inner'), { recursive: true })
|
|
82
|
+
await writeFile(path.join(artifactDir, 'inner', 'a.jsonl'), '{"header":1}\n')
|
|
83
|
+
try {
|
|
84
|
+
const heldPath = await moveToQuarantine(artifactDir, quarantineDir)
|
|
85
|
+
assert.ok(heldPath.startsWith(quarantineDir), '暂存路径应在回收区目录内')
|
|
86
|
+
await assert.rejects(access(artifactDir), '原路径应消失')
|
|
87
|
+
const moved = await readFile(path.join(heldPath, 'inner', 'a.jsonl'), 'utf8')
|
|
88
|
+
assert.equal(moved, '{"header":1}\n')
|
|
89
|
+
} finally {
|
|
90
|
+
await rm(base, { recursive: true, force: true })
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
// Given rename 因跨设备失败(EXDEV), When moveToQuarantine, Then 回退为复制后删除原路径
|
|
95
|
+
test('moveToQuarantine:EXDEV 回退复制+删除(注入 fs)', async () => {
|
|
96
|
+
const calls = []
|
|
97
|
+
const fakeFs = {
|
|
98
|
+
rename: async (from, to) => {
|
|
99
|
+
calls.push(['rename', from, to])
|
|
100
|
+
throw Object.assign(new Error('cross-device'), { code: 'EXDEV' })
|
|
101
|
+
},
|
|
102
|
+
cp: async (from, to) => { calls.push(['cp', from, to]) },
|
|
103
|
+
rm: async (target) => { calls.push(['rm', target]) },
|
|
104
|
+
mkdir: async () => { calls.push(['mkdir']) },
|
|
105
|
+
}
|
|
106
|
+
const heldPath = await moveToQuarantine('/data/sess-1', '/home/.dsh/held', { fs: fakeFs, now: () => 1234 })
|
|
107
|
+
assert.equal(heldPath, path.join('/home/.dsh/held', 'sess-1-1234'))
|
|
108
|
+
assert.deepEqual(calls, [
|
|
109
|
+
['mkdir'],
|
|
110
|
+
['rename', '/data/sess-1', heldPath],
|
|
111
|
+
['cp', '/data/sess-1', heldPath],
|
|
112
|
+
['rm', '/data/sess-1'],
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Given 回收区暂存的目录与空缺的原位置, When restoreFromQuarantine, Then 目录回到原位置且暂存消失
|
|
117
|
+
test('restoreFromQuarantine:移回原位置且暂存消失(真实 rename)', { skip: process.platform !== 'win32' && process.platform !== 'linux' && process.platform !== 'darwin' }, async () => {
|
|
118
|
+
const base = await mkdtemp(path.join(tmpdir(), 'dsh-quarantine-'))
|
|
119
|
+
const quarantineDir = path.join(base, 'held')
|
|
120
|
+
const originalDir = path.join(base, 'sess-2')
|
|
121
|
+
await mkdir(path.join(originalDir, 'inner'), { recursive: true })
|
|
122
|
+
await writeFile(path.join(originalDir, 'inner', 'a.jsonl'), '{"header":1}\n')
|
|
123
|
+
try {
|
|
124
|
+
const heldPath = await moveToQuarantine(originalDir, quarantineDir)
|
|
125
|
+
await restoreFromQuarantine(heldPath, originalDir)
|
|
126
|
+
await assert.rejects(access(heldPath), '暂存路径应消失')
|
|
127
|
+
const restored = await readFile(path.join(originalDir, 'inner', 'a.jsonl'), 'utf8')
|
|
128
|
+
assert.equal(restored, '{"header":1}\n')
|
|
129
|
+
} finally {
|
|
130
|
+
await rm(base, { recursive: true, force: true })
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Given rename 因原位置已被占用而失败, When restoreFromQuarantine, Then 错误原样抛出由调用方响应
|
|
135
|
+
test('restoreFromQuarantine:原位置已存在时失败上抛(注入 fs)', async () => {
|
|
136
|
+
const occupied = Object.assign(new Error('not empty'), { code: 'ENOTEMPTY' })
|
|
137
|
+
const fakeFs = {
|
|
138
|
+
rename: async () => { throw occupied },
|
|
139
|
+
cp: async () => { throw new Error('不应回退复制') },
|
|
140
|
+
rm: async () => { throw new Error('不应删除') },
|
|
141
|
+
mkdir: async () => {},
|
|
142
|
+
}
|
|
143
|
+
await assert.rejects(restoreFromQuarantine('/held/s1', '/data/s1', { fs: fakeFs }), (error) => error.code === 'ENOTEMPTY')
|
|
144
|
+
})
|
package/src/history-cache.mjs
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
// 历史输入工作区缓存:每工作区一个 JSON 文件(~/.dsh/historyPrompt/<工作区>-<hash>.json),
|
|
2
|
-
// 记录该工作区聚合后的人类输入(带 sid 溯源)。浮层请求直接读文件(毫秒级)立即返回,
|
|
3
|
-
// 对齐在后台解压最新产物与缓存合并写回——解压成本不落在用户等待路径上。
|
|
4
|
-
// extracts 段持久化每会话的提取结果与其产物 stat 指纹(mtimeMs+size):
|
|
5
|
-
// 对齐时先 stat 比对指纹,产物没变的会话不再解压——判断完全基于磁盘,跨重启生效。
|
|
6
|
-
// 文件名 = 工作区路径末段(可辨认)+ 路径哈希前缀(防不同目录同名冲突)
|
|
7
|
-
|
|
8
|
-
import { createHash } from 'node:crypto'
|
|
9
|
-
import { mkdir, readFile, readdir, stat, writeFile, rename } from 'node:fs/promises'
|
|
10
|
-
import { dirname, join, sep } from 'node:path'
|
|
11
|
-
|
|
12
|
-
function workspaceFile(dir, cwd) {
|
|
13
|
-
const leaf = cwd.split(/[\\/]/).filter(Boolean).pop() || 'root'
|
|
14
|
-
const hash = createHash('sha1').update(cwd).digest('hex').slice(0, 8)
|
|
15
|
-
return join(dir, `${leaf}-${hash}.json`)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function ensureCacheDir(dir) {
|
|
19
|
-
return mkdir(dir, { recursive: true })
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function normalize(parsed, cwd) {
|
|
23
|
-
if (!parsed || parsed.cwd !== cwd || !Array.isArray(parsed.entries)) return null
|
|
24
|
-
return {
|
|
25
|
-
cwd,
|
|
26
|
-
entries: parsed.entries,
|
|
27
|
-
extracts: parsed.extracts && typeof parsed.extracts === 'object' ? parsed.extracts : {},
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export async function readWorkspaceCache(dir, cwd) {
|
|
32
|
-
try {
|
|
33
|
-
return normalize(JSON.parse(await readFile(workspaceFile(dir, cwd), 'utf8')), cwd)
|
|
34
|
-
} catch {
|
|
35
|
-
return null
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// 原子写:先写临时文件再改名,进程中断不会留下半截 JSON
|
|
40
|
-
export async function writeWorkspaceCache(dir, cwd, cache) {
|
|
41
|
-
await ensureCacheDir(dir)
|
|
42
|
-
const target = workspaceFile(dir, cwd)
|
|
43
|
-
const tmp = target + '.tmp'
|
|
44
|
-
await writeFile(tmp, JSON.stringify({ cwd, entries: cache.entries, extracts: cache.extracts }, null, 0), 'utf8')
|
|
45
|
-
await rename(tmp, target)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// global 浮层轮询用指纹缓存:每 3s 轮询都全量读取并 JSON.parse 所有工作区缓存文件,
|
|
49
|
-
// 单文件 entries 可达数百条,持续打开浮层时是秒级重复大 IO+CPU。以 (mtimeMs,size)
|
|
50
|
-
// 指纹复用上次解析结果,产物未变的文件不再读盘;写入侧经临时文件改名,mtime 必变,
|
|
51
|
-
// 指纹天然失效。模块级 Map 以全路径为键(防多目录同名文件串扰),条目数有界。
|
|
52
|
-
const globalCachePollState = new Map()
|
|
53
|
-
|
|
54
|
-
export async function listWorkspaceCachesCached(dir) {
|
|
55
|
-
let names
|
|
56
|
-
try {
|
|
57
|
-
names = await readdir(dir)
|
|
58
|
-
} catch {
|
|
59
|
-
// 目录缺失/不可读:仅清本目录条目(分隔符锚定,防误清兄弟目录),条目自愈
|
|
60
|
-
for (const key of globalCachePollState.keys()) {
|
|
61
|
-
if (key.startsWith(dir + sep)) globalCachePollState.delete(key)
|
|
62
|
-
}
|
|
63
|
-
return []
|
|
64
|
-
}
|
|
65
|
-
const live = new Set()
|
|
66
|
-
const caches = await Promise.all(names
|
|
67
|
-
.filter((name) => name.endsWith('.json') && name !== PROMPTS_FILE)
|
|
68
|
-
.map(async (name) => {
|
|
69
|
-
const file = join(dir, name)
|
|
70
|
-
live.add(file)
|
|
71
|
-
let fingerprint
|
|
72
|
-
try {
|
|
73
|
-
const info = await stat(file)
|
|
74
|
-
fingerprint = { mtimeMs: info.mtimeMs, size: info.size }
|
|
75
|
-
} catch {
|
|
76
|
-
globalCachePollState.delete(file)
|
|
77
|
-
return null
|
|
78
|
-
}
|
|
79
|
-
const hit = globalCachePollState.get(file)
|
|
80
|
-
if (hit !== undefined && hit.fp.mtimeMs === fingerprint.mtimeMs && hit.fp.size === fingerprint.size) {
|
|
81
|
-
return hit.parsed
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
const parsed = JSON.parse(await readFile(file, 'utf8'))
|
|
85
|
-
if (!parsed || typeof parsed.cwd !== 'string' || !Array.isArray(parsed.entries)) {
|
|
86
|
-
globalCachePollState.delete(file)
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
globalCachePollState.set(file, { fp: fingerprint, parsed })
|
|
90
|
-
return parsed
|
|
91
|
-
} catch {
|
|
92
|
-
globalCachePollState.delete(file)
|
|
93
|
-
return null
|
|
94
|
-
}
|
|
95
|
-
}))
|
|
96
|
-
for (const key of globalCachePollState.keys()) {
|
|
97
|
-
if (!live.has(key)) globalCachePollState.delete(key)
|
|
98
|
-
}
|
|
99
|
-
return caches.filter((cache) => cache && typeof cache.cwd === 'string' && Array.isArray(cache.entries))
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// 常用提示词:全局单文件(不按工作区),记录用户显式收藏的输入文本。
|
|
103
|
-
// 收藏是用户意志,与自动对齐的历史缓存互不覆写
|
|
104
|
-
const PROMPTS_FILE = 'prompts.json'
|
|
105
|
-
|
|
106
|
-
function normalizePrompts(parsed) {
|
|
107
|
-
if (!parsed || !Array.isArray(parsed.items)) return []
|
|
108
|
-
return parsed.items
|
|
109
|
-
.filter((item) => item && typeof item.text === 'string' && item.text.length > 0)
|
|
110
|
-
.map((item) => ({ text: item.text, at: typeof item.at === 'number' ? item.at : 0 }))
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export async function readPrompts(dir) {
|
|
114
|
-
try {
|
|
115
|
-
return normalizePrompts(JSON.parse(await readFile(join(dir, PROMPTS_FILE), 'utf8')))
|
|
116
|
-
} catch {
|
|
117
|
-
return []
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export async function writePrompts(dir, items) {
|
|
122
|
-
await ensureCacheDir(dir)
|
|
123
|
-
const target = join(dir, PROMPTS_FILE)
|
|
124
|
-
const tmp = target + '.tmp'
|
|
125
|
-
await writeFile(tmp, JSON.stringify({ items }, null, 0), 'utf8')
|
|
126
|
-
await rename(tmp, target)
|
|
127
|
-
}
|
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
// 插话撤回(steer recall)接线与纯函数测试:加载真实 src/client.js,
|
|
2
|
-
// 捕获 slots.register 断言 dock 条目结构,切片实例化纯函数锁定撤回动作与失败文案。
|
|
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 PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
10
|
-
const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
11
|
-
|
|
12
|
-
// 加载 client.js 并执行 apply(mock 最小服务面),返回插槽注册捕获。
|
|
13
|
-
// slots.inject 收到注册回调即同步执行;binding 桩仅对会话 s1 生效
|
|
14
|
-
function loadClient({ binding } = {}) {
|
|
15
|
-
const modules = []
|
|
16
|
-
const registered = []
|
|
17
|
-
const injectedNames = []
|
|
18
|
-
const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
|
|
19
|
-
const reactStub = {
|
|
20
|
-
useState: (value) => [value, () => {}],
|
|
21
|
-
useEffect: () => {},
|
|
22
|
-
useSyncExternalStore: () => [],
|
|
23
|
-
useRef: (value) => ({ current: value }),
|
|
24
|
-
createElement: () => null,
|
|
25
|
-
}
|
|
26
|
-
const requireStub = (name) => {
|
|
27
|
-
if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
|
|
28
|
-
return reactStub
|
|
29
|
-
}
|
|
30
|
-
const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
|
|
31
|
-
factory(
|
|
32
|
-
windowStub,
|
|
33
|
-
requireStub,
|
|
34
|
-
{ createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
|
|
35
|
-
)
|
|
36
|
-
assert.equal(modules.length, 1, 'client.js 模块未被捕获')
|
|
37
|
-
const mod = modules[0].factory(requireStub)
|
|
38
|
-
|
|
39
|
-
const sessions = {
|
|
40
|
-
binding: (sessionId) => (sessionId === 's1' ? binding : undefined),
|
|
41
|
-
list: { subscribe: () => () => {}, getSnapshot: () => ({ ids: [], byId: {} }) },
|
|
42
|
-
}
|
|
43
|
-
const workspaces = {
|
|
44
|
-
list: { subscribe: () => () => {}, getSnapshot: () => undefined },
|
|
45
|
-
}
|
|
46
|
-
const ctx = {
|
|
47
|
-
get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
|
|
48
|
-
effect: () => {},
|
|
49
|
-
slots: {
|
|
50
|
-
inject: (name, register) => { injectedNames.push(name); register() },
|
|
51
|
-
register: (options, component) => registered.push({ options, component }),
|
|
52
|
-
},
|
|
53
|
-
}
|
|
54
|
-
mod.apply(ctx)
|
|
55
|
-
return { registered, injectedNames }
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// 插话撤回 dock 条目捕获:与官方 queue dock(order 20)、goal dock(order 10)同槽位
|
|
59
|
-
function findSteerEntry(options) {
|
|
60
|
-
const { registered, injectedNames } = loadClient(options)
|
|
61
|
-
assert.ok(injectedNames.includes('conversation.input.dock'), 'apply 未注入 conversation.input.dock 插槽')
|
|
62
|
-
const entries = registered.filter((item) => item.options.name === 'conversation.input.dock')
|
|
63
|
-
const entry = entries.find((item) => item.options.id === 'session-manager-steer')
|
|
64
|
-
assert.ok(entry, '未注册 id=session-manager-steer 的插话撤回 dock 条目')
|
|
65
|
-
return { entry, entries }
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
test('Given client.js 装载, When apply, Then 注册插话撤回 dock 条目且历史浮层条目保留', () => {
|
|
69
|
-
const { entry, entries } = findSteerEntry()
|
|
70
|
-
assert.equal(typeof entry.component, 'function', 'dock 条目缺组件')
|
|
71
|
-
assert.equal(entry.options.order, 25, '插话撤回条目 order 应紧邻官方队列面板(20)之后')
|
|
72
|
-
assert.ok(entries.some((item) => item.options.id === 'session-manager-history'), '历史浮层条目被挤掉')
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
test('Given 会话绑定存在, When dock inject, Then updateQueue 路由到 binding.session.updateQueue', async () => {
|
|
76
|
-
const calls = []
|
|
77
|
-
const binding = {
|
|
78
|
-
session: {
|
|
79
|
-
updateQueue: async (itemId, action) => {
|
|
80
|
-
calls.push({ itemId, action })
|
|
81
|
-
return { ok: true }
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
}
|
|
85
|
-
const { entry } = findSteerEntry({ binding })
|
|
86
|
-
const props = entry.options.inject('s1')
|
|
87
|
-
assert.equal(typeof props.updateQueue, 'function', 'inject 未返回 updateQueue')
|
|
88
|
-
const result = await props.updateQueue('m1', { kind: 'remove' })
|
|
89
|
-
assert.deepEqual(calls, [{ itemId: 'm1', action: { kind: 'remove' } }], 'updateQueue 未按 remove 动作透传')
|
|
90
|
-
assert.deepEqual(result, { ok: true })
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
test('Given 会话绑定缺失, When dock inject, Then 返回空 props(干净禁用)', () => {
|
|
94
|
-
const { entry } = findSteerEntry()
|
|
95
|
-
assert.deepEqual(entry.options.inject('s1'), {}, '绑定缺失应返回空 props')
|
|
96
|
-
assert.deepEqual(entry.options.inject('missing'), {}, '未知会话应返回空 props')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('Given 会话面无 updateQueue(旧宿主), When dock inject, Then 返回空 props(干净禁用)', () => {
|
|
100
|
-
const { entry } = findSteerEntry({ binding: { session: {} } })
|
|
101
|
-
assert.deepEqual(entry.options.inject('s1'), {}, '会话面无 updateQueue 应返回空 props')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
// 纯函数切片:steerRowsOf / withdrawFailureText / withdrawSteer(无外部依赖,动作经注入)
|
|
105
|
-
const PURE_START = CLIENT_SRC.indexOf('function steerRowsOf(')
|
|
106
|
-
const PURE_END = CLIENT_SRC.indexOf('function SteerRecallDock(')
|
|
107
|
-
assert.ok(PURE_START >= 0 && PURE_END > PURE_START, 'client.js 插话撤回纯函数切片定位失败')
|
|
108
|
-
const pure = new Function(
|
|
109
|
-
CLIENT_SRC.slice(PURE_START, PURE_END)
|
|
110
|
-
+ '; return { steerRowsOf: steerRowsOf, withdrawFailureText: withdrawFailureText, withdrawSteer: withdrawSteer }',
|
|
111
|
-
)()
|
|
112
|
-
|
|
113
|
-
const STEERING_ROW = { id: 'm1', placement: 'steering', preview: '帮我看下', text: '帮我看下' }
|
|
114
|
-
|
|
115
|
-
test('steerRowsOf:仅保留 placement=steering,防非数组与非行形态', () => {
|
|
116
|
-
const queue = [
|
|
117
|
-
{ id: 'q1', placement: 'queued' },
|
|
118
|
-
STEERING_ROW,
|
|
119
|
-
{ id: 'c1', placement: 'context' },
|
|
120
|
-
STEERING_ROW,
|
|
121
|
-
null,
|
|
122
|
-
]
|
|
123
|
-
assert.deepEqual(pure.steerRowsOf(queue), [STEERING_ROW, STEERING_ROW])
|
|
124
|
-
assert.deepEqual(pure.steerRowsOf([]), [])
|
|
125
|
-
assert.deepEqual(pure.steerRowsOf(undefined), [])
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
test('withdrawSteer 主路径:remove 成功后回填草稿并提示,返回 true', async () => {
|
|
129
|
-
const calls = []
|
|
130
|
-
const actions = {
|
|
131
|
-
updateQueue: async (itemId, action) => { calls.push({ itemId, action }); return { ok: true } },
|
|
132
|
-
setDraft: (text) => calls.push({ setDraft: text }),
|
|
133
|
-
notify: (text, opts) => calls.push({ notify: text, opts }),
|
|
134
|
-
}
|
|
135
|
-
const done = await pure.withdrawSteer(STEERING_ROW, actions)
|
|
136
|
-
assert.equal(done, true)
|
|
137
|
-
assert.deepEqual(calls, [
|
|
138
|
-
{ itemId: 'm1', action: { kind: 'remove' } },
|
|
139
|
-
{ setDraft: '帮我看下' },
|
|
140
|
-
{ notify: '已撤回到输入框', opts: undefined },
|
|
141
|
-
])
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
test('withdrawSteer 已被应用:queue-item-not-found 不回填草稿,专用文案报错', async () => {
|
|
145
|
-
const calls = []
|
|
146
|
-
const actions = {
|
|
147
|
-
updateQueue: async () => ({ ok: false, error: { code: 'session/queue-item-not-found', message: 'queued item is no longer pending' } }),
|
|
148
|
-
setDraft: (text) => calls.push({ setDraft: text }),
|
|
149
|
-
notify: (text, opts) => calls.push({ notify: text, opts }),
|
|
150
|
-
}
|
|
151
|
-
const done = await pure.withdrawSteer(STEERING_ROW, actions)
|
|
152
|
-
assert.equal(done, false)
|
|
153
|
-
assert.deepEqual(calls, [{ notify: '插话已被应用,无法撤回', opts: { kind: 'error' } }])
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
test('withdrawSteer 其他失败:透传错误消息且不回填草稿', async () => {
|
|
157
|
-
const calls = []
|
|
158
|
-
const actions = {
|
|
159
|
-
updateQueue: async () => ({ ok: false, error: { code: 'session/steer-unavailable', message: 'busy' } }),
|
|
160
|
-
setDraft: (text) => calls.push({ setDraft: text }),
|
|
161
|
-
notify: (text, opts) => calls.push({ notify: text, opts }),
|
|
162
|
-
}
|
|
163
|
-
const done = await pure.withdrawSteer(STEERING_ROW, actions)
|
|
164
|
-
assert.equal(done, false)
|
|
165
|
-
assert.deepEqual(calls, [{ notify: '撤回失败: busy', opts: { kind: 'error' } }])
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
test('withdrawSteer 传输层拒绝:按错误处理且不回填草稿', async () => {
|
|
169
|
-
const calls = []
|
|
170
|
-
const actions = {
|
|
171
|
-
updateQueue: async () => { throw new Error('network down') },
|
|
172
|
-
setDraft: (text) => calls.push({ setDraft: text }),
|
|
173
|
-
notify: (text, opts) => calls.push({ notify: text, opts }),
|
|
174
|
-
}
|
|
175
|
-
const done = await pure.withdrawSteer(STEERING_ROW, actions)
|
|
176
|
-
assert.equal(done, false)
|
|
177
|
-
assert.deepEqual(calls, [{ notify: '撤回失败: network down', opts: { kind: 'error' } }])
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
test('withdrawSteer 摘除成功但回填抛错:兜底展示原文,不报成功', async () => {
|
|
181
|
-
const calls = []
|
|
182
|
-
const actions = {
|
|
183
|
-
updateQueue: async () => ({ ok: true }),
|
|
184
|
-
setDraft: () => { throw new Error('shell released') },
|
|
185
|
-
notify: (text, opts) => calls.push({ notify: text, opts }),
|
|
186
|
-
}
|
|
187
|
-
const done = await pure.withdrawSteer(STEERING_ROW, actions)
|
|
188
|
-
assert.equal(done, false)
|
|
189
|
-
assert.deepEqual(calls, [{ notify: '草稿回填失败,原文: 帮我看下', opts: { kind: 'error' } }])
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
// ── 组件级门控:干净禁用规约的落地断言(整源加载 + 记录型 createElement 直接调组件) ──
|
|
193
|
-
|
|
194
|
-
// createElement 桩记录调用树:h(type, props, ...children) -> {type, props, children}
|
|
195
|
-
function loadComponent() {
|
|
196
|
-
const modules = []
|
|
197
|
-
const registered = []
|
|
198
|
-
const windowStub = { __ModuleLoader__: { load: (module) => modules.push(module) }, addEventListener: () => {} }
|
|
199
|
-
const element = (type, props, ...children) => ({ type, props, children })
|
|
200
|
-
const reactStub = {
|
|
201
|
-
useState: (value) => [value, () => {}],
|
|
202
|
-
useEffect: () => {},
|
|
203
|
-
useRef: (value) => ({ current: value }),
|
|
204
|
-
createElement: element,
|
|
205
|
-
}
|
|
206
|
-
const requireStub = (name) => {
|
|
207
|
-
if (name === '@mzzsfy/dsh-toast/client') return { show: () => 0 }
|
|
208
|
-
return reactStub
|
|
209
|
-
}
|
|
210
|
-
const factory = new Function('window', 'require', 'document', CLIENT_SRC + '\n;return null')
|
|
211
|
-
factory(
|
|
212
|
-
windowStub,
|
|
213
|
-
requireStub,
|
|
214
|
-
{ createElement: () => ({ style: {}, remove() {} }), head: { appendChild: () => {} }, body: { appendChild: () => {} } },
|
|
215
|
-
)
|
|
216
|
-
const mod = modules[0].factory(requireStub)
|
|
217
|
-
const sessions = { binding: () => undefined, list: { subscribe: () => () => {}, getSnapshot: () => ({ ids: [], byId: {} }) } }
|
|
218
|
-
const workspaces = { list: { subscribe: () => () => {}, getSnapshot: () => undefined } }
|
|
219
|
-
const ctx = {
|
|
220
|
-
get: (name) => (name === 'sessions' ? sessions : name === 'workspaces' ? workspaces : {}),
|
|
221
|
-
effect: () => {},
|
|
222
|
-
slots: {
|
|
223
|
-
inject: (name, register) => register(),
|
|
224
|
-
register: (options, component) => registered.push({ options, component }),
|
|
225
|
-
},
|
|
226
|
-
}
|
|
227
|
-
mod.apply(ctx)
|
|
228
|
-
const entry = registered.find((item) => item.options.id === 'session-manager-steer')
|
|
229
|
-
assert.ok(entry, '组件级测试未捕获插话撤回条目')
|
|
230
|
-
return entry.component
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const DOCK_COMPONENT = loadComponent()
|
|
234
|
-
const NO_ACTIONS = { setDraft: () => {} }
|
|
235
|
-
const OK_UPDATE = async () => ({ ok: true })
|
|
236
|
-
|
|
237
|
-
// useSession 桩:两次 selector 读取共用同一快照(与官方 SessionSnapshotSelector 单快照同构)
|
|
238
|
-
function dockWith(snapshot) {
|
|
239
|
-
const useSession = (selector) => selector(snapshot)
|
|
240
|
-
return (props) => DOCK_COMPONENT({ session: {}, useSession, inputActions: NO_ACTIONS, updateQueue: OK_UPDATE, ...props })
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
test('SteerRecallDock 干净禁用:依赖任一缺失即返回 null', () => {
|
|
244
|
-
const dock = dockWith({ queue: [STEERING_ROW], subagent: null })
|
|
245
|
-
assert.equal(dock({ session: undefined }), null, 'session 缺失应不渲染')
|
|
246
|
-
assert.equal(dock({ inputActions: undefined }), null, 'inputActions 缺失应不渲染')
|
|
247
|
-
assert.equal(dock({ updateQueue: undefined }), null, 'updateQueue 缺失应不渲染')
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
test('SteerRecallDock 干净禁用:subagent 非 continuable 即返回 null,启用时零占位宿主', () => {
|
|
251
|
-
const locked = dockWith({ queue: [STEERING_ROW], subagent: { address: { mode: 'prompt' } } })
|
|
252
|
-
assert.equal(locked({}), null, 'queueMutable 为假应不渲染')
|
|
253
|
-
const host = dockWith({ queue: [], subagent: null })({})
|
|
254
|
-
assert.equal(host.type, 'div')
|
|
255
|
-
assert.equal(host.props.className, 'sm-steer-host', '启用时应渲染 display:none 宿主承载注入逻辑')
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
// 注入契约(源码级守卫):选择器/标记/清理必须存在,官方 DOM 结构漂移时静默降级
|
|
259
|
-
test('SteerRecallDock 注入契约:语义标记选择器、按钮标记与卸载清理齐备', () => {
|
|
260
|
-
assert.ok(CLIENT_SRC.includes('[data-pending-steering]'), '应定位官方气泡语义标记')
|
|
261
|
-
assert.ok(CLIENT_SRC.includes('[class$="_actions"]'), '应按 CSS module 类名后缀定位官方图标排')
|
|
262
|
-
assert.ok(CLIENT_SRC.includes("data-sm-steer-recall"), '应有自家按钮标记防重复注入')
|
|
263
|
-
assert.ok(CLIENT_SRC.includes('new MutationObserver'), '应经 MutationObserver 跟随气泡出现')
|
|
264
|
-
assert.ok(CLIENT_SRC.includes('official.className'), '应克隆官方按钮类名保证样式原生')
|
|
265
|
-
assert.ok(CLIENT_SRC.includes('observer.disconnect()'), '卸载应断开观察')
|
|
266
|
-
})
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// global 浮层轮询指纹缓存 BDD:stat 指纹命中不重复读盘,产物变更即失效,
|
|
2
|
-
// 删除/损坏文件与目录缺失不残留缓存条目。
|
|
3
|
-
|
|
4
|
-
import { test } from 'node:test'
|
|
5
|
-
import assert from 'node:assert/strict'
|
|
6
|
-
import { mkdtemp, writeFile, utimes, rm, readdir, mkdir } from 'node:fs/promises'
|
|
7
|
-
import { tmpdir } from 'node:os'
|
|
8
|
-
import { join } from 'node:path'
|
|
9
|
-
|
|
10
|
-
const { listWorkspaceCachesCached } = await import('../src/history-cache.mjs')
|
|
11
|
-
|
|
12
|
-
async function makeDir(t) {
|
|
13
|
-
const dir = await mkdtemp(join(tmpdir(), 'sm-fp-cache-'))
|
|
14
|
-
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
15
|
-
return dir
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
async function writeCache(dir, name, cwd, entries, mtime) {
|
|
19
|
-
const file = join(dir, name)
|
|
20
|
-
await writeFile(file, JSON.stringify({ cwd, entries }), 'utf8')
|
|
21
|
-
if (mtime !== undefined) await utimes(file, mtime, mtime)
|
|
22
|
-
return file
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
test('指纹命中:产物未变时复用解析结果,内容对象同引用', async (t) => {
|
|
26
|
-
const dir = await makeDir(t)
|
|
27
|
-
await writeCache(dir, 'a-1.json', 'C:\\a', [{ sid: 's1', text: 'x' }])
|
|
28
|
-
const first = await listWorkspaceCachesCached(dir)
|
|
29
|
-
assert.equal(first.length, 1)
|
|
30
|
-
assert.equal(first[0].cwd, 'C:\\a')
|
|
31
|
-
// When 再次读取(文件未动)
|
|
32
|
-
const second = await listWorkspaceCachesCached(dir)
|
|
33
|
-
// Then 指纹命中,返回缓存中的同一解析对象
|
|
34
|
-
assert.equal(second[0], first[0])
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
test('指纹失效:文件内容变更后重读,新内容可见', async (t) => {
|
|
38
|
-
const dir = await makeDir(t)
|
|
39
|
-
const stamp = new Date()
|
|
40
|
-
const file = await writeCache(dir, 'b-1.json', 'C:\\b', [{ sid: 's1', text: 'old' }], stamp)
|
|
41
|
-
const first = await listWorkspaceCachesCached(dir)
|
|
42
|
-
assert.equal(first[0].entries[0].text, 'old')
|
|
43
|
-
// When 追加条目并推进 mtime(写入侧经临时文件改名,mtime 必变)
|
|
44
|
-
await writeFile(file, JSON.stringify({ cwd: 'C:\\b', entries: [{ sid: 's1', text: 'old' }, { sid: 's2', text: 'new' }] }), 'utf8')
|
|
45
|
-
const later = new Date(stamp.getTime() + 2000)
|
|
46
|
-
await utimes(file, later, later)
|
|
47
|
-
const second = await listWorkspaceCachesCached(dir)
|
|
48
|
-
// Then 新内容可见
|
|
49
|
-
assert.equal(second[0].entries.length, 2)
|
|
50
|
-
assert.equal(second[0].entries[1].text, 'new')
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
test('卫生:文件删除与目录缺失清空对应缓存', async (t) => {
|
|
54
|
-
const dir = await makeDir(t)
|
|
55
|
-
const file = await writeCache(dir, 'c-1.json', 'C:\\c', [])
|
|
56
|
-
await listWorkspaceCachesCached(dir)
|
|
57
|
-
// When 删除文件后再读
|
|
58
|
-
await rm(file)
|
|
59
|
-
const afterRemove = await listWorkspaceCachesCached(dir)
|
|
60
|
-
// Then 结果为空(目录仍在,缓存条目清除)
|
|
61
|
-
assert.deepEqual(afterRemove, [])
|
|
62
|
-
// When 目录整个消失(缓存目录被手工清理)
|
|
63
|
-
await rm(dir, { recursive: true, force: true })
|
|
64
|
-
const afterDirGone = await listWorkspaceCachesCached(dir)
|
|
65
|
-
// Then 返回空数组不抛
|
|
66
|
-
assert.deepEqual(afterDirGone, [])
|
|
67
|
-
// 目录恢复后缓存可重建
|
|
68
|
-
await mkdir(dir, { recursive: true })
|
|
69
|
-
assert.deepEqual(await readdir(dir), [])
|
|
70
|
-
await writeCache(dir, 'd-1.json', 'C:\\d', [{ sid: 's9', text: 'fresh' }])
|
|
71
|
-
const rebuilt = await listWorkspaceCachesCached(dir)
|
|
72
|
-
assert.equal(rebuilt.length, 1)
|
|
73
|
-
assert.equal(rebuilt[0].entries[0].text, 'fresh')
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
test('过滤:损坏文件与 prompts.json 不进结果', async (t) => {
|
|
77
|
-
const dir = await makeDir(t)
|
|
78
|
-
await writeFile(join(dir, 'broken.json'), '{not json', 'utf8')
|
|
79
|
-
await writeFile(join(dir, 'prompts.json'), JSON.stringify({ items: [] }), 'utf8')
|
|
80
|
-
await writeCache(dir, 'ok-1.json', 'C:\\ok', [])
|
|
81
|
-
const caches = await listWorkspaceCachesCached(dir)
|
|
82
|
-
assert.equal(caches.length, 1)
|
|
83
|
-
assert.equal(caches[0].cwd, 'C:\\ok')
|
|
84
|
-
})
|