@mzzsfy/dsh-session-manager 0.3.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 +82 -0
- package/cordis.patch.yml +12 -0
- package/package.json +51 -0
- package/src/client.js +835 -0
- package/src/core.mjs +190 -0
- package/src/history-cache.mjs +94 -0
- package/src/index.js +1069 -0
- package/src/trash.mjs +49 -0
- package/test/client-id.test.mjs +16 -0
- package/test/client-toast.test.mjs +89 -0
- package/test/core.test.mjs +307 -0
- package/test/index.test.mjs +1851 -0
- package/test/parity.test.mjs +160 -0
- package/test/trash.test.mjs +73 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// 镜像逻辑 parity:client.js 与 core.mjs 双实现同源(仓库规约"双实现同源必须 parity 测试")。
|
|
2
|
+
// client.js 为单文件自包含格式无法 import,此处按标记切片提取镜像函数段,
|
|
3
|
+
// 经 new Function 实例化后与 core.mjs 同输入断言同输出。
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { dirname, join } from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
archiveToastStep as coreArchiveToastStep,
|
|
13
|
+
projectArchiveRows,
|
|
14
|
+
projectDeletedRows,
|
|
15
|
+
} from '../src/core.mjs'
|
|
16
|
+
|
|
17
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
const CLIENT_SRC = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
19
|
+
const INDEX_SRC = readFileSync(join(PKG_ROOT, 'src', 'index.js'), 'utf8')
|
|
20
|
+
|
|
21
|
+
// 切片:从 projectRows 定义起到最后一个镜像纯函数止,均为无外部依赖纯函数
|
|
22
|
+
const MIRROR_START = CLIENT_SRC.indexOf('function projectRows(')
|
|
23
|
+
const MIRROR_END_MARKER = 'function ArchiveRow('
|
|
24
|
+
const MIRROR_END = CLIENT_SRC.indexOf(MIRROR_END_MARKER)
|
|
25
|
+
assert.ok(MIRROR_START >= 0 && MIRROR_END > MIRROR_START, 'client.js 镜像函数切片定位失败')
|
|
26
|
+
|
|
27
|
+
const mirror = new Function(
|
|
28
|
+
CLIENT_SRC.slice(MIRROR_START, MIRROR_END)
|
|
29
|
+
+ '; return { projectRows: projectRows, projectDeletedRows: projectDeletedRows, archiveToastStep: archiveToastStep }',
|
|
30
|
+
)()
|
|
31
|
+
|
|
32
|
+
// client 侧输入形态 byId 字典,core 侧 rows 数组:按 title 约定构造等价输入
|
|
33
|
+
function byIdOf(rows) {
|
|
34
|
+
const byId = {}
|
|
35
|
+
for (const row of rows) byId[row.id] = { displayTitle: row.title, updatedAt: row.updatedAt }
|
|
36
|
+
return byId
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertArchiveProjectionParity(rows, archivedIds) {
|
|
40
|
+
const clientRows = mirror.projectRows({ byId: byIdOf(rows) }, archivedIds)
|
|
41
|
+
const coreRows = projectArchiveRows({ rows, archivedIds })
|
|
42
|
+
assert.deepEqual(clientRows, coreRows)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test('parity 归档投影:常规交集与倒序', () => {
|
|
46
|
+
assertArchiveProjectionParity([
|
|
47
|
+
{ id: 'b', title: 'B', updatedAt: 200 },
|
|
48
|
+
{ id: 'a', title: 'A', updatedAt: 300 },
|
|
49
|
+
{ id: 'c', title: 'C', updatedAt: 100 },
|
|
50
|
+
{ id: 'd', title: 'D', updatedAt: 400 },
|
|
51
|
+
], ['b', 'c', 'gone'])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('parity 归档投影:空输入与空集合', () => {
|
|
55
|
+
assertArchiveProjectionParity([], ['a'])
|
|
56
|
+
assertArchiveProjectionParity([{ id: 'a', title: 'A', updatedAt: 1 }], [])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('parity 归档投影:标题缺失回退会话 id', () => {
|
|
60
|
+
assertArchiveProjectionParity([{ id: 'a', title: '', updatedAt: 5 }], ['a'])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('parity 归档投影:updatedAt 并列时排序不漂移(同数组序)', () => {
|
|
64
|
+
assertArchiveProjectionParity([
|
|
65
|
+
{ id: 'x', title: 'X', updatedAt: 100 },
|
|
66
|
+
{ id: 'y', title: 'Y', updatedAt: 100 },
|
|
67
|
+
{ id: 'z', title: 'Z', updatedAt: 100 },
|
|
68
|
+
], ['x', 'y', 'z'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
function assertDeletedProjectionParity(deleted, sessionsById) {
|
|
72
|
+
const clientRows = mirror.projectDeletedRows(deleted, { byId: sessionsById })
|
|
73
|
+
const coreRows = projectDeletedRows(deleted, sessionsById)
|
|
74
|
+
assert.deepEqual(clientRows, coreRows)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('parity 已删除投影:倒序与标题回退', () => {
|
|
78
|
+
assertDeletedProjectionParity([
|
|
79
|
+
{ sessionId: 'a', path: 'C:\\w\\a', deletedAt: 100 },
|
|
80
|
+
{ sessionId: 'b', path: 'C:\\w\\b', deletedAt: 300 },
|
|
81
|
+
{ sessionId: 'c', path: 'C:\\w\\c', deletedAt: 200 },
|
|
82
|
+
], { a: { displayTitle: '会话 A' } })
|
|
83
|
+
assertDeletedProjectionParity([], {})
|
|
84
|
+
assertDeletedProjectionParity([{ sessionId: 's', path: 'p', deletedAt: 1 }], undefined)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
function assertToastStepParity(frames) {
|
|
88
|
+
let clientPrevious
|
|
89
|
+
let corePrevious
|
|
90
|
+
for (const frame of frames) {
|
|
91
|
+
const clientStep = mirror.archiveToastStep(clientPrevious, frame)
|
|
92
|
+
const coreStep = coreArchiveToastStep(corePrevious, frame)
|
|
93
|
+
assert.deepEqual(clientStep, coreStep, '帧 ' + JSON.stringify(frame) + ' 差分不一致')
|
|
94
|
+
clientPrevious = clientStep.state
|
|
95
|
+
corePrevious = coreStep.state
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
test('parity Toast 差分:pending 首装 / ready 增量 / 重连 / 缩减全序列', () => {
|
|
100
|
+
assertToastStepParity([
|
|
101
|
+
{ phase: 'pending', archivedSessionIds: [] },
|
|
102
|
+
{ phase: 'pending', archivedSessionIds: ['a', 'b'] },
|
|
103
|
+
{ phase: 'ready', archivedSessionIds: ['a', 'b'] },
|
|
104
|
+
{ phase: 'ready', archivedSessionIds: ['a', 'b', 'c'] },
|
|
105
|
+
{ phase: 'ready', archivedSessionIds: ['a', 'c'] },
|
|
106
|
+
{ phase: 'pending', archivedSessionIds: ['a', 'c'] },
|
|
107
|
+
{ phase: 'ready', archivedSessionIds: ['a', 'c', 'd'] },
|
|
108
|
+
])
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('parity Toast 差分:订阅即 ready(无 pending 帧)首帧守卫', () => {
|
|
112
|
+
assertToastStepParity([
|
|
113
|
+
{ phase: 'ready', archivedSessionIds: ['a'] },
|
|
114
|
+
{ phase: 'ready', archivedSessionIds: ['a', 'b'] },
|
|
115
|
+
])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test('parity Toast 差分:大集合增量性能形态一致性(n=5000)', () => {
|
|
119
|
+
const big = Array.from({ length: 5000 }, (_, i) => 'id-' + i)
|
|
120
|
+
assertToastStepParity([
|
|
121
|
+
{ phase: 'ready', archivedSessionIds: [] },
|
|
122
|
+
{ phase: 'ready', archivedSessionIds: big },
|
|
123
|
+
])
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('源码契约:client fetchInputs 必须解包 host 响应信封 { inputs }', () => {
|
|
127
|
+
// host 与 client 对信封各自测试自洽时,信封形状漂移只会以运行时 TypeError 暴露;
|
|
128
|
+
// 此守卫锁定解包点存在,防消费侧把信封对象当数组使用
|
|
129
|
+
const guard = /Array\.isArray\((\w+)\.inputs\)/
|
|
130
|
+
assert.ok(guard.test('Array.isArray(payload.inputs)'), '守卫正则必须命中合规样本')
|
|
131
|
+
const fetchStart = CLIENT_SRC.indexOf('function fetchInputs(')
|
|
132
|
+
const fetchBody = fetchStart >= 0 ? CLIENT_SRC.slice(fetchStart, CLIENT_SRC.indexOf('}', fetchStart)) : ''
|
|
133
|
+
assert.ok(fetchBody.includes('api(INPUTS_URL'), '未找到 fetchInputs 的 host 请求')
|
|
134
|
+
assert.ok(guard.test(fetchBody), 'fetchInputs 缺少 payload.inputs 数组解包')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('parity 文案:client 确认态文案与 host MESSAGES.unsupportedBackend 同值', () => {
|
|
138
|
+
const match = INDEX_SRC.match(/unsupportedBackend: '([^']+)'/)
|
|
139
|
+
assert.ok(match, 'index.js 缺少 unsupportedBackend 文案')
|
|
140
|
+
assert.ok(CLIENT_SRC.includes(match[1]), 'client.js 确认态文案与 host 漂移')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('parity API 路径:client 字面量与 host 路由注册互为镜像', () => {
|
|
144
|
+
// 跨半区契约拼错只会在运行时 404,双向集合相等断言防漂移
|
|
145
|
+
const clientUrls = new Set([...CLIENT_SRC.matchAll("'(/api/session-manager/[^']*)'")].map((m) => m[1])
|
|
146
|
+
.filter((url) => !url.endsWith('/*')))
|
|
147
|
+
assert.ok(clientUrls.size >= 7, 'client.js API 路径字面量异常减少')
|
|
148
|
+
const hostPaths = new Set([...INDEX_SRC.matchAll(/path: '(\/api\/session-manager\/[^']*)'/g)].map((m) => m[1]))
|
|
149
|
+
assert.deepEqual([...hostPaths].sort(), [...clientUrls].sort(), 'client 与 host 的 API 路径集合不一致')
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('源码契约:Toast 差分 Set 每帧构建一次(禁止 filter 谓词内 new Set)', () => {
|
|
153
|
+
// 违规必为 new Set(...).has(...) 紧邻链式;合规形态是 new Set 赋值与 baseline.has 分离。
|
|
154
|
+
// 元断言先自证正则能命中回归样本,防正则本身失效时守卫恒绿
|
|
155
|
+
const regression = "ids.filter((id) => !new Set(previous.ids).has(id))"
|
|
156
|
+
const guard = /new Set\([^)]*\)\.has\(/
|
|
157
|
+
assert.ok(guard.test(regression), '守卫正则必须命中回归样本')
|
|
158
|
+
const slice = CLIENT_SRC.slice(MIRROR_START, MIRROR_END)
|
|
159
|
+
assert.ok(!guard.test(slice), '差分 new Set 必须逐帧构建一次,禁止谓词内逐元素重建')
|
|
160
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// 回收站命令构造测试:平台分支与参数、执行失败传播;Windows 上补一条真实执行。
|
|
2
|
+
|
|
3
|
+
import test from 'node:test'
|
|
4
|
+
import assert from 'node:assert/strict'
|
|
5
|
+
import { mkdtemp, writeFile, rm, access } from 'node:fs/promises'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { TRASH_ENV_NAME, trashCommandFor, trashPath } from '../src/trash.mjs'
|
|
10
|
+
|
|
11
|
+
test('Windows 走 PowerShell VisualBasic 回收站,路径经环境变量传递', () => {
|
|
12
|
+
const cmd = trashCommandFor('win32', 'C:\\logs\\a.jsonl')
|
|
13
|
+
assert.equal(cmd.file, 'powershell.exe')
|
|
14
|
+
assert.equal(cmd.env[TRASH_ENV_NAME], 'C:\\logs\\a.jsonl')
|
|
15
|
+
assert.ok(cmd.args.some((arg) => arg.includes('SendToRecycleBin')))
|
|
16
|
+
// 路径不得进入 argv:-Command 会把 argv 空格重拼接进命令文本,存在断裂与注入面
|
|
17
|
+
assert.ok(cmd.args.every((arg) => arg !== 'C:\\logs\\a.jsonl'))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('macOS 走 Finder 回收站', () => {
|
|
21
|
+
const cmd = trashCommandFor('darwin', '/tmp/a.jsonl')
|
|
22
|
+
assert.equal(cmd.file, 'osascript')
|
|
23
|
+
assert.ok(cmd.args.includes('/tmp/a.jsonl'))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('Linux 走 gio trash', () => {
|
|
27
|
+
const cmd = trashCommandFor('linux', '/tmp/a.jsonl')
|
|
28
|
+
assert.equal(cmd.file, 'gio')
|
|
29
|
+
assert.deepEqual(cmd.args, ['trash', '/tmp/a.jsonl'])
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('Windows 脚本按 PSIsContainer 分派 DeleteDirectory / DeleteFile', () => {
|
|
33
|
+
const cmd = trashCommandFor('win32', 'C:\\logs\\a.jsonl')
|
|
34
|
+
const script = cmd.args.find((arg) => arg.includes('DeleteDirectory'))
|
|
35
|
+
assert.ok(script.includes('PSIsContainer'))
|
|
36
|
+
assert.ok(script.includes('DeleteFile'))
|
|
37
|
+
assert.ok(script.includes('exit 1'))
|
|
38
|
+
// 脚本从环境变量取路径:变量名与宿主半区约定一致
|
|
39
|
+
assert.ok(script.includes('$env:' + TRASH_ENV_NAME))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('trashPath 真实执行:文件进入回收站且原路径消失(仅 Windows)', { skip: process.platform !== 'win32' }, async () => {
|
|
43
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'dsh-trash-test-'))
|
|
44
|
+
const file = path.join(dir, 'a.jsonl')
|
|
45
|
+
try {
|
|
46
|
+
await writeFile(file, '{"header":1}\n')
|
|
47
|
+
await trashPath(file)
|
|
48
|
+
await assert.rejects(access(file))
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(dir, { recursive: true, force: true })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('trashPath 真实执行:含空格路径完整回收(-Command 空格重拼接不再破坏路径)', { skip: process.platform !== 'win32' }, async () => {
|
|
55
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'dsh trash test '))
|
|
56
|
+
const file = path.join(dir, 'a b.jsonl')
|
|
57
|
+
try {
|
|
58
|
+
await writeFile(file, '{"header":1}\n')
|
|
59
|
+
await trashPath(file)
|
|
60
|
+
await assert.rejects(access(file))
|
|
61
|
+
} finally {
|
|
62
|
+
await rm(dir, { recursive: true, force: true })
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('trashPath 向执行器传递超时配置', async () => {
|
|
67
|
+
const captured = []
|
|
68
|
+
const run = async (file, args, options) => { captured.push(options) }
|
|
69
|
+
await trashPath('C:\\x\\a.jsonl', { platform: 'linux', run })
|
|
70
|
+
assert.equal(captured[0].timeout, 60 * 1000)
|
|
71
|
+
await trashPath('C:\\x\\a.jsonl', { platform: 'linux', run, timeoutMs: 5 * 1000 })
|
|
72
|
+
assert.equal(captured[1].timeout, 5 * 1000)
|
|
73
|
+
})
|