dsh-my-computer 0.0.0-stage → 0.2.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.
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env node
2
+ // dsh-my-computer 冒烟测试
3
+ //
4
+ // 不需要 dsh 宿主:伪造一个最小的 ctx(effect + connection.fetch.register),
5
+ // import lib/index.js 把它注册出来的 handler 一个个当成真 HTTP 请求打一遍。
6
+ // 跑法:node tools/smoke.mjs
7
+ //
8
+ // 覆盖:/drives /list /read /write /download /upload /mkdir /rename /delete /move
9
+ // 以及各类错误路径(非法路径、不存在、目录当文件读、二进制、409 冲突、
10
+ // 缺少 confirm、目录下载 422)。所有临时文件都在 TMP_DIR 里,跑完删干净。
11
+
12
+ import fs from 'node:fs'
13
+ import fsp from 'node:fs/promises'
14
+ import path from 'node:path'
15
+ import zlib from 'node:zlib'
16
+ import { fileURLToPath } from 'node:url'
17
+
18
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
19
+ const PKG_ROOT = path.resolve(HERE, '..')
20
+ const SMOKE_SELF = fileURLToPath(import.meta.url)
21
+
22
+ const HAS_D = fs.existsSync('D:\\')
23
+ const TMP_DIR = HAS_D
24
+ ? 'D:\\dsh-workspace\\tmp\\mycomp-smoke'
25
+ : path.join(PKG_ROOT, '.tmp-smoke')
26
+ const WORKSPACE = HAS_D ? 'D:\\dsh-workspace' : PKG_ROOT
27
+
28
+ /* ---------------- 断言框架 ---------------- */
29
+
30
+ const checks = []
31
+ function record(pass, name, detail) {
32
+ checks.push({ pass, name, detail })
33
+ const mark = pass ? ' ✓' : ' ✗'
34
+ const tail = detail === undefined || detail === '' ? '' : ' ' + detail
35
+ console.log(mark + ' ' + name + tail)
36
+ }
37
+ function check(name, condition, detail) {
38
+ record(Boolean(condition), name, detail)
39
+ return Boolean(condition)
40
+ }
41
+ function checkEqual(name, actual, expected) {
42
+ const pass = actual === expected
43
+ return check(name, pass, pass ? '' : `期望 ${JSON.stringify(expected)},实际 ${JSON.stringify(actual)}`)
44
+ }
45
+ function section(title) {
46
+ console.log('\n── ' + title + ' ' + '─'.repeat(Math.max(0, 58 - title.length)))
47
+ }
48
+
49
+ /* ---------------- 伪造宿主 ---------------- */
50
+
51
+ const registered = []
52
+ // 只实现 lib/index.js 真正用到的那几样:effect / connection.fetch.register / logger
53
+ const fakeCtx = {
54
+ effect(fn) {
55
+ const dispose = fn()
56
+ return typeof dispose === 'function' ? dispose : () => {}
57
+ },
58
+ connection: {
59
+ fetch: {
60
+ register(route) {
61
+ registered.push(route)
62
+ return () => {
63
+ const at = registered.indexOf(route)
64
+ if (at >= 0) registered.splice(at, 1)
65
+ }
66
+ },
67
+ },
68
+ },
69
+ logger: { info() {}, warn() {}, error() {} },
70
+ }
71
+
72
+ const mod = await import(new URL('../lib/index.js', import.meta.url).href)
73
+ await mod.apply(fakeCtx, {})
74
+
75
+ const byId = new Map()
76
+ for (const route of registered) {
77
+ byId.set(route.path.slice(route.path.lastIndexOf('/') + 1), route)
78
+ }
79
+
80
+ /**
81
+ * 打一个注册出来的 handler。
82
+ * 走的是真实 Request:URL 与 DSH 桥接层一致(http://dsh.internal + 注册路径 + query)。
83
+ */
84
+ async function call(id, options = {}) {
85
+ const route = byId.get(id)
86
+ if (!route) throw new Error('路由没注册:' + id)
87
+ const method = options.method || route.methods[0]
88
+ const url =
89
+ 'http://dsh.internal' + route.path + (options.query ? '?' + options.query : '')
90
+ const headers = Object.assign({ host: 'dsh.internal' }, options.headers || {})
91
+ const init = { method, headers }
92
+ if (options.body !== undefined) {
93
+ init.body = options.body
94
+ if (options.contentType) headers['content-type'] = options.contentType
95
+ }
96
+ const request = new Request(url, init)
97
+ const response = await route.fetch(request)
98
+ if (options.raw) return response
99
+ const text = await response.text()
100
+ let data = null
101
+ try {
102
+ data = text ? JSON.parse(text) : null
103
+ } catch {
104
+ data = { __raw: text }
105
+ }
106
+ return { status: response.status, ok: response.ok, headers: response.headers, data, text }
107
+ }
108
+
109
+ function q(params) {
110
+ return Object.keys(params)
111
+ .map((key) => encodeURIComponent(key) + '=' + encodeURIComponent(String(params[key])))
112
+ .join('&')
113
+ }
114
+
115
+ /* ---------------- 开始 ---------------- */
116
+
117
+ console.log('dsh-my-computer 冒烟测试')
118
+ console.log(' 插件目录:' + PKG_ROOT)
119
+ console.log(' 临时目录:' + TMP_DIR)
120
+
121
+ section('路由注册')
122
+
123
+ checkEqual('注册了 12 条路由', registered.length, 12)
124
+ for (const route of registered) {
125
+ const name = route.path.slice(route.path.lastIndexOf('/') + 1)
126
+ const bodyModeOk = route.requestBody === 'buffered' || route.requestBody === 'streaming'
127
+ if (!route.path.startsWith('/api/my-computer/')) {
128
+ check('前缀必须是 /api/my-computer/(' + route.path + ')', false)
129
+ }
130
+ if (!Array.isArray(route.methods) || route.methods.length === 0) {
131
+ check('methods 不能为空(' + name + ')', false)
132
+ }
133
+ if (!bodyModeOk) check('requestBody 必须是 buffered/streaming(' + name + ')', false)
134
+ if (typeof route.fetch !== 'function') check('fetch 必须是函数(' + name + ')', false)
135
+ }
136
+ check('每条路由都有 path/methods/requestBody/fetch', registered.every((r) => r.path && r.methods && r.requestBody && typeof r.fetch === 'function'))
137
+ checkEqual('上传路由声明 streaming(否则大文件会被整体缓冲)', byId.get('upload').requestBody, 'streaming')
138
+
139
+ section('GET /drives')
140
+
141
+ const drivesRes = await call('drives')
142
+ checkEqual('/drives 状态码 200', drivesRes.status, 200)
143
+ const driveLetters = (drivesRes.data.drives || []).map((d) => d.letter)
144
+ check('至少列出 C: 和 D:', driveLetters.includes('C:') && driveLetters.includes('D:'), '实际:' + driveLetters.join(' '))
145
+ const dDrive = (drivesRes.data.drives || []).find((d) => d.letter === 'D:')
146
+ check('D: 带容量信息(statfs)', dDrive && typeof dDrive.total === 'number' && dDrive.total > 0, dDrive ? `total=${dDrive.total} free=${dDrive.free}` : '没有 D:')
147
+ check('D: 标了可写', dDrive && dDrive.writable === true)
148
+ check('/drives 不包含不存在的盘符(E: 之类)', !driveLetters.includes('E:') || fs.existsSync('E:\\'))
149
+
150
+ section('GET /list')
151
+
152
+ const listWs = await call('list', { query: q({ path: WORKSPACE }) })
153
+ checkEqual('/list 状态码 200', listWs.status, 200)
154
+ check('/list 返回目录项', Array.isArray(listWs.data.entries) && listWs.data.entries.length > 0, '共 ' + (listWs.data.entries || []).length + ' 项')
155
+ check('parent 是盘符根', listWs.data.parent === (HAS_D ? 'D:\\' : path.dirname(WORKSPACE)), 'parent=' + listWs.data.parent)
156
+ check('返回 total/free', typeof listWs.data.total === 'number' && typeof listWs.data.free === 'number')
157
+
158
+ const dirs = listWs.data.entries.filter((e) => e.kind === 'dir')
159
+ const files = listWs.data.entries.filter((e) => e.kind === 'file')
160
+ check('目录排在文件前面', dirs.length === 0 || files.length === 0 || listWs.data.entries.indexOf(dirs[0]) < listWs.data.entries.indexOf(files[0]))
161
+
162
+ const firstEntry = listWs.data.entries[0]
163
+ check('条目字段齐全(name/path/kind/size/mtime/ext)',
164
+ firstEntry && typeof firstEntry.name === 'string' && typeof firstEntry.path === 'string' &&
165
+ ['dir', 'file', 'link', 'other'].includes(firstEntry.kind) && 'size' in firstEntry && 'mtime' in firstEntry && 'ext' in firstEntry,
166
+ firstEntry ? firstEntry.name + ' → ' + firstEntry.kind : '')
167
+
168
+ const rootList = await call('list', { query: q({ path: HAS_D ? 'D:\\' : path.parse(PKG_ROOT).root }) })
169
+ checkEqual('盘符根的 parent 是 null', rootList.data.parent, null)
170
+
171
+ section('错误路径(必须返回 ok:false,绝不抛异常)')
172
+
173
+ const badPath = await call('list', { query: q({ path: 'foo/bar' }) })
174
+ checkEqual('相对路径 → 400', badPath.status, 400)
175
+ checkEqual('相对路径 → INVALID_PATH', badPath.data.error && badPath.data.error.code, 'INVALID_PATH')
176
+
177
+ const emptyPath = await call('list', {})
178
+ checkEqual('缺少 path → 400', emptyPath.status, 400)
179
+
180
+ const missing = await call('list', { query: q({ path: HAS_D ? 'D:\\dsh-workspace\\__不存在的目录__' : path.join(PKG_ROOT, '__nope__') }) })
181
+ checkEqual('不存在的路径 → 404', missing.status, 404)
182
+ checkEqual('不存在 → NOT_FOUND', missing.data.error && missing.data.error.code, 'NOT_FOUND')
183
+
184
+ const asDir = await call('read', { query: q({ path: HAS_D ? 'D:\\' : PKG_ROOT }) })
185
+ checkEqual('把目录当文件读 → 400', asDir.status, 400)
186
+ checkEqual('目录当文件读 → IS_DIRECTORY', asDir.data.error && asDir.data.error.code, 'IS_DIRECTORY')
187
+
188
+ const badJson = await call('mkdir', { body: '{不是 json', contentType: 'application/json' })
189
+ checkEqual('非法 JSON 体 → 400', badJson.status, 400)
190
+
191
+ const wrongType = await call('mkdir', { body: 'x=1', contentType: 'text/plain' })
192
+ checkEqual('非 application/json → 415', wrongType.status, 415)
193
+
194
+ const crossSite = await call('list', { query: q({ path: WORKSPACE }), headers: { origin: 'https://evil.example' } })
195
+ checkEqual('跨站 Origin → 403', crossSite.status, 403)
196
+
197
+ const sameSite = await call('list', { query: q({ path: WORKSPACE }), headers: { origin: 'http://dsh.internal' } })
198
+ checkEqual('同源 Origin 放行 → 200', sameSite.status, 200)
199
+
200
+ section('GET /read')
201
+
202
+ const readSelf = await call('read', { query: q({ path: SMOKE_SELF }) })
203
+ checkEqual('/read 状态码 200', readSelf.status, 200)
204
+ check('读到本文件开头', typeof readSelf.data.text === 'string' && readSelf.data.text.startsWith('#!/usr/bin/env node'))
205
+ checkEqual('编码识别为 utf-8', readSelf.data.encoding, 'utf-8')
206
+ checkEqual('没被截断', readSelf.data.truncated, false)
207
+
208
+ const truncated = await call('read', { query: q({ path: SMOKE_SELF, maxBytes: 12 }) })
209
+ checkEqual('maxBytes 生效 → truncated', truncated.data.truncated, true)
210
+ checkEqual('maxBytes 生效 → 只回 12 字节', await readByteLength(truncated.data.text), 12)
211
+
212
+ const skipped = await call('read', { query: q({ path: 'D:\\node24\\node.exe' }) })
213
+ if (fs.existsSync('D:\\node24\\node.exe')) {
214
+ checkEqual('二进制文件 → 415', skipped.status, 415)
215
+ checkEqual('二进制文件 → BINARY_FILE', skipped.data.error && skipped.data.error.code, 'BINARY_FILE')
216
+ }
217
+
218
+ section('文件操作闭环(mkdir → list → rename → delete)')
219
+
220
+ await fsp.rm(TMP_DIR, { recursive: true, force: true })
221
+ await fsp.mkdir(TMP_DIR, { recursive: true })
222
+
223
+ const mk = await call('mkdir', {
224
+ body: JSON.stringify({ path: TMP_DIR, name: '测试 目录' }),
225
+ contentType: 'application/json',
226
+ })
227
+ checkEqual('/mkdir 状态码 200', mk.status, 200)
228
+ const madeDir = path.join(TMP_DIR, '测试 目录')
229
+ check('磁盘上真的建出来了', fs.existsSync(madeDir))
230
+
231
+ const listTmp = await call('list', { query: q({ path: TMP_DIR }) })
232
+ check('list 能看到新建目录', (listTmp.data.entries || []).some((e) => e.name === '测试 目录' && e.kind === 'dir'))
233
+
234
+ const mkAgain = await call('mkdir', {
235
+ body: JSON.stringify({ path: TMP_DIR, name: '测试 目录' }),
236
+ contentType: 'application/json',
237
+ })
238
+ checkEqual('重名 mkdir → 409', mkAgain.status, 409)
239
+ checkEqual('重名 mkdir → ALREADY_EXISTS', mkAgain.data.error && mkAgain.data.error.code, 'ALREADY_EXISTS')
240
+
241
+ const renamed = await call('rename', {
242
+ body: JSON.stringify({ path: madeDir, name: '改名后' }),
243
+ contentType: 'application/json',
244
+ })
245
+ checkEqual('/rename 状态码 200', renamed.status, 200)
246
+ check('原路径已不存在', !fs.existsSync(madeDir))
247
+ check('新路径存在', fs.existsSync(path.join(TMP_DIR, '改名后')))
248
+
249
+ const listAfterRename = await call('list', { query: q({ path: TMP_DIR }) })
250
+ check('list 能看到改名结果', (listAfterRename.data.entries || []).some((e) => e.name === '改名后'))
251
+
252
+ const delNoConfirm = await call('delete', {
253
+ body: JSON.stringify({ paths: [path.join(TMP_DIR, '改名后')] }),
254
+ contentType: 'application/json',
255
+ })
256
+ checkEqual('删除不带 confirm → 400', delNoConfirm.status, 400)
257
+ checkEqual('删除不带 confirm → CONFIRMATION_REQUIRED', delNoConfirm.data.error && delNoConfirm.data.error.code, 'CONFIRMATION_REQUIRED')
258
+ check('没有 confirm 时文件还在', fs.existsSync(path.join(TMP_DIR, '改名后')))
259
+
260
+ const delOk = await call('delete', {
261
+ body: JSON.stringify({ paths: [path.join(TMP_DIR, '改名后')], confirm: true }),
262
+ contentType: 'application/json',
263
+ })
264
+ checkEqual('/delete 状态码 200', delOk.status, 200)
265
+ checkEqual('deleted 数组长度 1', (delOk.data.deleted || []).length, 1)
266
+ check('磁盘上真的删掉了', !fs.existsSync(path.join(TMP_DIR, '改名后')))
267
+
268
+ const delRootGuard = await call('delete', {
269
+ body: JSON.stringify({ paths: [HAS_D ? 'D:\\' : 'C:\\'], confirm: true }),
270
+ contentType: 'application/json',
271
+ })
272
+ check('拒绝删除磁盘根目录', delRootGuard.status === 207 && (delRootGuard.data.failed || []).length === 1, 'failed=' + JSON.stringify((delRootGuard.data.failed || []).map((f) => f.code)))
273
+
274
+ section('POST /write + GET /read 回读')
275
+
276
+ const textPath = path.join(TMP_DIR, '笔记.txt')
277
+ const written = await call('write', {
278
+ body: JSON.stringify({ path: textPath, text: '你好,DSH\n第二行\n第三行' }),
279
+ contentType: 'application/json',
280
+ })
281
+ checkEqual('/write 状态码 200', written.status, 200)
282
+ check('返回 mtime', typeof written.data.mtime === 'number' && written.data.mtime > 0)
283
+ checkEqual('返回字节数(UTF-8 中文 3 字节)', written.data.size, Buffer.byteLength('你好,DSH\n第二行\n第三行'))
284
+
285
+ const readBack = await call('read', { query: q({ path: textPath }) })
286
+ checkEqual('/read 回读内容一致', readBack.data.text, '你好,DSH\n第二行\n第三行')
287
+
288
+ // GB18030 兜底:老的中文 Windows 文本文件
289
+ const gbkPath = path.join(TMP_DIR, 'gbk.txt')
290
+ await fsp.writeFile(gbkPath, Buffer.from([0xd6, 0xd0, 0xce, 0xc4, 0xb2, 0xe2, 0xca, 0xd4]))
291
+ const gbkRead = await call('read', { query: q({ path: gbkPath }) })
292
+ checkEqual('GBK 文件识别为 gb18030', gbkRead.data.encoding, 'gb18030')
293
+ checkEqual('GBK 文件解码正确', gbkRead.data.text, '中文测试')
294
+
295
+ section('PUT /upload + GET /download')
296
+
297
+ const payload = Buffer.alloc(256)
298
+ for (let i = 0; i < 256; i += 1) payload[i] = i
299
+
300
+ const up1 = await call('upload', {
301
+ method: 'PUT',
302
+ query: q({ path: TMP_DIR, name: 'up.bin' }),
303
+ body: payload,
304
+ contentType: 'application/octet-stream',
305
+ })
306
+ checkEqual('/upload 状态码 200', up1.status, 200)
307
+ checkEqual('落盘大小正确', up1.data.size, 256)
308
+ checkEqual('overwritten=false', up1.data.overwritten, false)
309
+
310
+ const up2 = await call('upload', {
311
+ method: 'PUT',
312
+ query: q({ path: TMP_DIR, name: 'up.bin' }),
313
+ body: payload,
314
+ contentType: 'application/octet-stream',
315
+ })
316
+ checkEqual('同名再传一次 → 409', up2.status, 409)
317
+ checkEqual('同名再传一次 → ALREADY_EXISTS', up2.data.error && up2.data.error.code, 'ALREADY_EXISTS')
318
+
319
+ const up3 = await call('upload', {
320
+ method: 'PUT',
321
+ query: q({ path: TMP_DIR, name: 'up.bin', overwrite: '1' }),
322
+ body: Buffer.from('覆盖后的内容'),
323
+ contentType: 'application/octet-stream',
324
+ })
325
+ checkEqual('overwrite=1 覆盖成功', up3.status, 200)
326
+ checkEqual('overwritten=true', up3.data.overwritten, true)
327
+ checkEqual('覆盖后大小变化', up3.data.size, Buffer.byteLength('覆盖后的内容'))
328
+
329
+ const badName = await call('upload', {
330
+ method: 'PUT',
331
+ query: q({ path: TMP_DIR, name: '..\\逃逸.txt' }),
332
+ body: payload,
333
+ })
334
+ checkEqual('文件名带 ..\\ → 400', badName.status, 400)
335
+
336
+ const dl = await call('download', { query: q({ path: SMOKE_SELF }), raw: true })
337
+ checkEqual('/download 状态码 200', dl.status, 200)
338
+ const dlBuffer = Buffer.from(await dl.arrayBuffer())
339
+ check('下载内容与磁盘一致', dlBuffer.equals(await fsp.readFile(SMOKE_SELF)), dlBuffer.length + ' 字节')
340
+ check('Content-Disposition 是 attachment',
341
+ String(dl.headers.get('content-disposition') || '').startsWith('attachment;'),
342
+ String(dl.headers.get('content-disposition') || ''))
343
+ check('Content-Type 有值', Boolean(dl.headers.get('content-type')), String(dl.headers.get('content-type')))
344
+
345
+ const dlDir = await call('download', { query: q({ path: TMP_DIR }) })
346
+ checkEqual('用 /download 下目录 → 400(应改用 download-dir)', dlDir.status, 400)
347
+ checkEqual('错误码提示 IS_DIRECTORY', dlDir.data.error && dlDir.data.error.code, 'IS_DIRECTORY')
348
+
349
+ /* ---------------- GET /download-dir(整目录 zip) ---------------- */
350
+
351
+ section('GET /download-dir(zip 打包)')
352
+
353
+ const zipSrc = path.join(TMP_DIR, '打包测试')
354
+ await fsp.mkdir(path.join(zipSrc, '空目录'), { recursive: true })
355
+ await fsp.mkdir(path.join(zipSrc, '子目录'), { recursive: true })
356
+ const zipFileA = path.join(zipSrc, 'a.txt')
357
+ const zipFileB = path.join(zipSrc, '子目录', '中文名.md')
358
+ await fsp.writeFile(zipFileA, 'hello zip\n'.repeat(50), 'utf8')
359
+ await fsp.writeFile(zipFileB, '# 中文内容\n'.repeat(30), 'utf8')
360
+
361
+ const zipRes = await call('download-dir', { query: q({ path: zipSrc }), raw: true })
362
+ checkEqual('/download-dir 状态码 200', zipRes.status, 200)
363
+ checkEqual('Content-Type 是 application/zip', String(zipRes.headers.get('content-type')), 'application/zip')
364
+ check('Content-Disposition 是 <目录名>.zip',
365
+ String(zipRes.headers.get('content-disposition') || '').includes(encodeURIComponent('打包测试') + '.zip'),
366
+ String(zipRes.headers.get('content-disposition') || ''))
367
+ const zipBuf = Buffer.from(await zipRes.arrayBuffer())
368
+ check('ZIP 魔数正确', zipBuf.readUInt32LE(0) === 0x04034b50, zipBuf.length + ' 字节')
369
+
370
+ let zipEntries = []
371
+ let zipError = ''
372
+ try {
373
+ zipEntries = parseZip(zipBuf)
374
+ } catch (error) {
375
+ zipError = String(error && error.message)
376
+ }
377
+ const zipNames = zipEntries.map((entry) => entry.name).sort()
378
+ check('zip 能解析出条目', zipError === '', zipError || zipNames.join(' | '))
379
+ check('zip 含两个文件', zipNames.filter((name) => !name.endsWith('/')).length === 2, zipNames.join(' | '))
380
+ check('zip 保留中文文件名', zipNames.some((name) => name.includes('中文名.md')))
381
+ check('zip 保留空目录', zipNames.some((name) => name.endsWith('空目录/')), zipNames.join(' | '))
382
+
383
+ const entryA = zipEntries.find((entry) => entry.name.endsWith('a.txt'))
384
+ const entryB = zipEntries.find((entry) => entry.name.endsWith('中文名.md'))
385
+ check('a.txt 内容与磁盘一致', Boolean(entryA) && entryA.data.equals(await fsp.readFile(zipFileA)))
386
+ check('中文名.md 内容与磁盘一致', Boolean(entryB) && entryB.data.equals(await fsp.readFile(zipFileB)))
387
+ check('每个条目 CRC 都对',
388
+ zipEntries.every((entry) => crc32Of(entry.data) === entry.crc),
389
+ zipEntries.map((entry) => entry.name + ':' + (crc32Of(entry.data) === entry.crc ? 'ok' : 'BAD')).join(' '))
390
+
391
+ const zipEmpty = await call('download-dir', { query: q({ path: path.join(zipSrc, '空目录') }) })
392
+ checkEqual('打包空目录 → 400', zipEmpty.status, 400)
393
+ checkEqual('错误码 ZIP_EMPTY', zipEmpty.data.error && zipEmpty.data.error.code, 'ZIP_EMPTY')
394
+
395
+ const zipOnFile = await call('download-dir', { query: q({ path: zipFileA }) })
396
+ checkEqual('对文件用 download-dir → 400', zipOnFile.status, 400)
397
+
398
+ const zipMissing = await call('download-dir', { query: q({ path: path.join(TMP_DIR, '没有这个目录') }) })
399
+ checkEqual('打包不存在的目录 → 404', zipMissing.status, 404)
400
+
401
+ section('POST /move')
402
+
403
+ const destDir = path.join(TMP_DIR, '目标目录')
404
+ await fsp.mkdir(destDir, { recursive: true })
405
+ const moved = await call('move', {
406
+ body: JSON.stringify({ sources: [textPath], targetDir: destDir }),
407
+ contentType: 'application/json',
408
+ })
409
+ checkEqual('/move 状态码 200', moved.status, 200)
410
+ check('源文件已消失', !fs.existsSync(textPath))
411
+ check('目标文件存在', fs.existsSync(path.join(destDir, '笔记.txt')))
412
+
413
+ const movedMissing = await call('move', {
414
+ body: JSON.stringify({ sources: [path.join(TMP_DIR, '没有这个.txt')], targetDir: destDir }),
415
+ contentType: 'application/json',
416
+ })
417
+ checkEqual('移动不存在的源 → 207(部分失败)', movedMissing.status, 207)
418
+ check('失败项带 code', (movedMissing.data.failed || []).length === 1)
419
+
420
+ section('GET /health')
421
+
422
+ const health = await call('health')
423
+ checkEqual('/health 状态码 200', health.status, 200)
424
+ checkEqual('health 报插件名', health.data.plugin, 'dsh-my-computer')
425
+
426
+ /* ---------------- 收尾 ---------------- */
427
+
428
+ await fsp.rm(TMP_DIR, { recursive: true, force: true })
429
+ check('临时目录已清理', !fs.existsSync(TMP_DIR))
430
+
431
+ const passed = checks.filter((c) => c.pass).length
432
+ const total = checks.length
433
+ console.log('\n' + '='.repeat(64))
434
+ console.log(`结果:${passed}/${total} 通过,${total - passed} 失败`)
435
+ console.log('='.repeat(64))
436
+ if (passed !== total) {
437
+ console.log('\n失败项:')
438
+ for (const c of checks) {
439
+ if (!c.pass) console.log(' ✗ ' + c.name + (c.detail ? ' ' + c.detail : ''))
440
+ }
441
+ process.exitCode = 1
442
+ } else {
443
+ console.log('全部通过。')
444
+ }
445
+
446
+ /* ---------------- 辅助 ---------------- */
447
+
448
+ async function readByteLength(text) {
449
+ return Buffer.byteLength(text, 'utf8')
450
+ }
451
+
452
+ /** 只够自检用的 zip 读取:走中央目录,支持 STORE(0) 与 DEFLATE(8)。 */
453
+ function parseZip(buf) {
454
+ const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
455
+ if (eocd < 0) throw new Error('找不到中央目录结尾(EOCD)')
456
+ const count = buf.readUInt16LE(eocd + 10)
457
+ let offset = buf.readUInt32LE(eocd + 16)
458
+ const out = []
459
+ for (let i = 0; i < count; i += 1) {
460
+ if (buf.readUInt32LE(offset) !== 0x02014b50) throw new Error('中央目录签名不对 @' + offset)
461
+ const flags = buf.readUInt16LE(offset + 8)
462
+ const method = buf.readUInt16LE(offset + 10)
463
+ const crc = buf.readUInt32LE(offset + 16)
464
+ const compSize = buf.readUInt32LE(offset + 20)
465
+ const size = buf.readUInt32LE(offset + 24)
466
+ const nameLen = buf.readUInt16LE(offset + 28)
467
+ const extraLen = buf.readUInt16LE(offset + 30)
468
+ const commentLen = buf.readUInt16LE(offset + 32)
469
+ const localOffset = buf.readUInt32LE(offset + 42)
470
+ const name = buf.slice(offset + 46, offset + 46 + nameLen).toString('utf8')
471
+ offset += 46 + nameLen + extraLen + commentLen
472
+ if (buf.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('本地头签名不对:' + name)
473
+ const localNameLen = buf.readUInt16LE(localOffset + 26)
474
+ const localExtraLen = buf.readUInt16LE(localOffset + 28)
475
+ const dataStart = localOffset + 30 + localNameLen + localExtraLen
476
+ const raw = buf.slice(dataStart, dataStart + compSize)
477
+ let data
478
+ if (method === 0) data = raw
479
+ else if (method === 8) data = zlib.inflateRawSync(raw)
480
+ else throw new Error('不支持的压缩方法 ' + method + ':' + name)
481
+ out.push({ name, flags, method, crc, compSize, size, data })
482
+ }
483
+ return out
484
+ }
485
+
486
+ function crc32Of(buffer) {
487
+ // Node 24 自带 zlib.crc32,省得在自检里再维护一张表
488
+ return zlib.crc32(buffer) >>> 0
489
+ }