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