|
|
@@ -32,6 +32,8 @@
|
|
|
const call = (method, params) => ipcRenderer.invoke(`controller/codexCtl/${method}`, params);
|
|
|
/** 既有的 osCtl controller(窗口控制 / 打开本机文件等) */
|
|
|
const osCall = (method, params) => ipcRenderer.invoke(`controller/osCtl/${method}`, params);
|
|
|
+ /** 下载管理器 controller(controller/downloadCtl/*) */
|
|
|
+ const dlCall = (method, params) => ipcRenderer.invoke(`controller/downloadCtl/${method}`, params);
|
|
|
|
|
|
function log(name, ok, detail) {
|
|
|
results.push({ name, ok, detail });
|
|
|
@@ -162,14 +164,6 @@
|
|
|
await check('approvalResolve 非法 decision', () => call('approvalResolve', { token: 'x', decision: 'whatever' }), expectFailure(/decision/));
|
|
|
await check('eventsRead 非法 threadId', () => call('eventsRead', { threadId: '../etc' }), expectFailure(/不能为空|threadId|非法/));
|
|
|
await check('logsTail', () => call('logsTail', { limit: 5 }), expectData((d) => Array.isArray(d)));
|
|
|
- // 「打开记录关联文件」用的 IPC:只验参数校验分支(成功分支会真的弹起本机应用)
|
|
|
- await check('osCtl.openPath 空路径被拒', () => osCall('openPath', {}), expectFailure(/不能为空/));
|
|
|
- await check('osCtl.openPath 相对路径被拒', () => osCall('openPath', { path: 'relative/a.xlsx' }), expectFailure(/绝对路径/));
|
|
|
- await check(
|
|
|
- 'osCtl.openPath 不存在的文件被拒',
|
|
|
- () => osCall('openPath', { path: 'E:/no-such-dir/no-such-file.xlsx' }),
|
|
|
- expectFailure(/文件不存在/),
|
|
|
- );
|
|
|
await check('stop', () => call('stop'), expectData((d) => d.state === 'stopped'));
|
|
|
|
|
|
// 本机地图目录:存在客户端 local-settings.json,不经服务端(设置抽屉的「地图」用它)
|
|
|
@@ -270,6 +264,214 @@
|
|
|
expectFailure(/不是文件/),
|
|
|
);
|
|
|
|
|
|
+ // 下载管理器:落盘到可配置目录 + 清单 + 打开/移除(都在临时目录里做,不动使用者的文件)
|
|
|
+ const dlTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'zsjz-dl-'));
|
|
|
+ const dirBefore = ((await dlCall('getDownloadDir')) || {}).data || {};
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.getDownloadDir',
|
|
|
+ () => dlCall('getDownloadDir'),
|
|
|
+ expectData((d) => typeof d.dir === 'string' && d.dir.length > 0),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.setDownloadDir 文件被拒',
|
|
|
+ () => dlCall('setDownloadDir', { dir: MAP_FILE_SAMPLE }),
|
|
|
+ expectFailure(/请选择目录/),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.setDownloadDir 不存在被拒',
|
|
|
+ () => dlCall('setDownloadDir', { dir: 'E:/no-such-dir/downloads' }),
|
|
|
+ expectFailure(/目录不存在/),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.setDownloadDir 切到临时目录',
|
|
|
+ () => dlCall('setDownloadDir', { dir: dlTempDir }),
|
|
|
+ expectData((d) => d.dir === dlTempDir),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.saveDownload 落盘',
|
|
|
+ async () => {
|
|
|
+ const saved = await dlCall('saveDownload', {
|
|
|
+ fileName: 'probe-download.txt',
|
|
|
+ mime: 'text/plain',
|
|
|
+ data: Buffer.from('hello-download'),
|
|
|
+ source: 'probe',
|
|
|
+ });
|
|
|
+ const path = saved?.data?.savedPath || '';
|
|
|
+ const content = path && fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '';
|
|
|
+ return { success: true, data: { fileName: saved?.data?.fileName, bytes: saved?.data?.bytes, content } };
|
|
|
+ },
|
|
|
+ expectData((d) => d.fileName === 'probe-download.txt' && d.bytes === 14 && d.content === 'hello-download'),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.listDownloads 含新记录且目录正确',
|
|
|
+ () => dlCall('listDownloads'),
|
|
|
+ expectData((d) => d.dir === dlTempDir && Array.isArray(d.items) && d.items[0]?.fileName === 'probe-download.txt'),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.saveDownload 重名自动加序号',
|
|
|
+ () =>
|
|
|
+ dlCall('saveDownload', {
|
|
|
+ fileName: 'probe-download.txt',
|
|
|
+ mime: 'text/plain',
|
|
|
+ data: Buffer.from('second'),
|
|
|
+ source: 'probe',
|
|
|
+ }),
|
|
|
+ expectData((d) => d.fileName === 'probe-download (1).txt'),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.saveDownload 支持直接传 ArrayBuffer',
|
|
|
+ async () => {
|
|
|
+ const bytes = new TextEncoder().encode('array-buffer-payload');
|
|
|
+ const saved = await dlCall('saveDownload', {
|
|
|
+ fileName: 'probe-arraybuffer.bin',
|
|
|
+ mime: 'application/octet-stream',
|
|
|
+ data: bytes.buffer,
|
|
|
+ source: 'probe',
|
|
|
+ });
|
|
|
+ const path = saved?.data?.savedPath || '';
|
|
|
+ const content = path && fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : '';
|
|
|
+ return { success: true, data: { fileName: saved?.data?.fileName, bytes: saved?.data?.bytes, content } };
|
|
|
+ },
|
|
|
+ expectData((d) => d.bytes === 20 && d.content === 'array-buffer-payload'),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.openDownloadFile 记录不存在被拒',
|
|
|
+ () => dlCall('openDownloadFile', { id: 'no-such-id' }),
|
|
|
+ expectFailure(/记录不存在/),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.removeDownload 移除一条',
|
|
|
+ async () => {
|
|
|
+ const list = ((await dlCall('listDownloads')) || {}).data || {};
|
|
|
+ const first = (list.items || [])[0];
|
|
|
+ await dlCall('removeDownload', { id: first?.id });
|
|
|
+ const after = ((await dlCall('listDownloads')) || {}).data || {};
|
|
|
+ return { success: true, data: { before: (list.items || []).length, after: (after.items || []).length } };
|
|
|
+ },
|
|
|
+ expectData((d) => d.before > 0 && d.after === d.before - 1),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.clearDownloads 清空清单',
|
|
|
+ async () => {
|
|
|
+ await dlCall('clearDownloads');
|
|
|
+ const after = ((await dlCall('listDownloads')) || {}).data || {};
|
|
|
+ return { success: true, data: { count: (after.items || []).length } };
|
|
|
+ },
|
|
|
+ expectData((d) => d.count === 0),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.setDownloadDir 恢复运行前配置',
|
|
|
+ async () => {
|
|
|
+ const expected = dirBefore.dir || '';
|
|
|
+ await dlCall('setDownloadDir', { dir: expected });
|
|
|
+ const after = ((await dlCall('getDownloadDir')) || {}).data || {};
|
|
|
+ return { success: true, data: { dir: after.dir, expected } };
|
|
|
+ },
|
|
|
+ expectData((d) => d.dir === d.expected),
|
|
|
+ );
|
|
|
+
|
|
|
+ // 后台下载 + 进度推送:探针页自己起一个"细水长流"的本地服务,验证进度事件与落盘
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.startDownload 后台下载并推送进度',
|
|
|
+ async () => {
|
|
|
+ const http = require('node:http');
|
|
|
+ const payload = Buffer.alloc(256 * 1024, 0x41);
|
|
|
+ const server = http.createServer((req, res) => {
|
|
|
+ res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': String(payload.length) });
|
|
|
+ // 分 16 片下发,每片 30ms:足够观察到进度
|
|
|
+ let offset = 0;
|
|
|
+ const timer = setInterval(() => {
|
|
|
+ if (offset >= payload.length) {
|
|
|
+ clearInterval(timer);
|
|
|
+ res.end();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ res.write(payload.subarray(offset, offset + 16 * 1024));
|
|
|
+ offset += 16 * 1024;
|
|
|
+ }, 30);
|
|
|
+ req.on('close', () => clearInterval(timer));
|
|
|
+ });
|
|
|
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
|
+ const port = server.address().port;
|
|
|
+
|
|
|
+ const progresses = [];
|
|
|
+ const onProgress = (_event, item) => progresses.push(item);
|
|
|
+ ipcRenderer.on('downloadCtl:progress', onProgress);
|
|
|
+ const started = await dlCall('startDownload', {
|
|
|
+ url: `http://127.0.0.1:${port}/probe-background.bin`,
|
|
|
+ fileName: 'probe-background.bin',
|
|
|
+ source: 'probe',
|
|
|
+ });
|
|
|
+ const id = started?.data?.id;
|
|
|
+
|
|
|
+ // 等它下完(最多 6 秒)
|
|
|
+ let finalItem = null;
|
|
|
+ for (let i = 0; i < 60; i += 1) {
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
+ const list = ((await dlCall('listDownloads')) || {}).data || {};
|
|
|
+ const found = (list.items || []).find((item) => item.id === id);
|
|
|
+ if (found && found.status !== 'downloading') {
|
|
|
+ finalItem = found;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ipcRenderer.removeListener('downloadCtl:progress', onProgress);
|
|
|
+ server.close();
|
|
|
+
|
|
|
+ const sameId = progresses.filter((item) => item.id === id);
|
|
|
+ const increased = sameId.some((item, index) => index > 0 && item.received > (sameId[index - 1].received || 0));
|
|
|
+ const size = finalItem?.savedPath && fs.existsSync(finalItem.savedPath) ? fs.statSync(finalItem.savedPath).size : 0;
|
|
|
+ return {
|
|
|
+ status: 'ok',
|
|
|
+ success: true,
|
|
|
+ data: {
|
|
|
+ eventCount: sameId.length,
|
|
|
+ receivedIncreased: increased,
|
|
|
+ finalStatus: finalItem?.status || null,
|
|
|
+ size,
|
|
|
+ total: finalItem?.total || 0,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ },
|
|
|
+ expectData((d) => d.eventCount >= 2 && d.receivedIncreased === true && d.finalStatus === 'done' && d.size === 256 * 1024),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.startDownload 后端报错记为 failed',
|
|
|
+ async () => {
|
|
|
+ const http = require('node:http');
|
|
|
+ const server = http.createServer((req, res) => {
|
|
|
+ res.writeHead(404, { 'content-type': 'application/json' });
|
|
|
+ res.end(JSON.stringify({ code: 404, message: '文件不存在(探针)' }));
|
|
|
+ });
|
|
|
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
|
+ const port = server.address().port;
|
|
|
+ const started = await dlCall('startDownload', {
|
|
|
+ url: `http://127.0.0.1:${port}/missing.bin`,
|
|
|
+ fileName: 'probe-missing.bin',
|
|
|
+ source: 'probe',
|
|
|
+ });
|
|
|
+ const id = started?.data?.id;
|
|
|
+ let finalItem = null;
|
|
|
+ for (let i = 0; i < 30; i += 1) {
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
+ const list = ((await dlCall('listDownloads')) || {}).data || {};
|
|
|
+ const found = (list.items || []).find((item) => item.id === id);
|
|
|
+ if (found && found.status !== 'downloading') {
|
|
|
+ finalItem = found;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ server.close();
|
|
|
+ return { success: true, data: { status: finalItem?.status || null, message: finalItem?.message || '' } };
|
|
|
+ },
|
|
|
+ expectData((d) => d.status === 'failed' && /文件不存在/.test(d.message)),
|
|
|
+ );
|
|
|
+ await check(
|
|
|
+ 'downloadCtl.startDownload 空地址被拒',
|
|
|
+ () => dlCall('startDownload', { fileName: 'x.bin' }),
|
|
|
+ expectFailure(/地址为空/),
|
|
|
+ );
|
|
|
+
|
|
|
fs.writeFileSync(outFile, JSON.stringify({ at: new Date().toISOString(), results }, null, 2), 'utf8');
|
|
|
const failed = results.filter((item) => !item.ok);
|
|
|
pre.innerHTML += `\n\n${failed.length === 0 ? 'ALL PASS' : 'FAILED ' + failed.length} → ${outFile}`;
|