feat: 增加下载任务中心与断点续传
This commit is contained in:
@@ -20,10 +20,15 @@ const knownChunks = Array.from({ length: 6 }, (_unused, index) => Buffer.from(
|
||||
const unknownChunks = Array.from({ length: 5 }, (_unused, index) => Buffer.from(
|
||||
`unknown-chunk-${index}-` + String.fromCharCode(97 + index).repeat(12 * 1024)
|
||||
));
|
||||
const rangedChunks = Array.from({ length: 12 }, (_unused, index) => Buffer.from(
|
||||
`ranged-chunk-${index}-` + String.fromCharCode(75 + (index % 10)).repeat(16 * 1024)
|
||||
));
|
||||
const knownPayload = Buffer.concat(knownChunks);
|
||||
const unknownPayload = Buffer.concat(unknownChunks);
|
||||
const rangedPayload = Buffer.concat(rangedChunks);
|
||||
|
||||
const results = [];
|
||||
const rangedRequests = [];
|
||||
let server;
|
||||
let testWindow;
|
||||
|
||||
@@ -35,6 +40,15 @@ function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForRenderer(expression, timeout = 8000) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < timeout) {
|
||||
if (await testWindow.webContents.executeJavaScript(`Boolean(${expression})`)) return true;
|
||||
await wait(50);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function serveChunks(response, chunks, contentLength) {
|
||||
const headers = {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
@@ -58,6 +72,46 @@ function serveChunks(response, chunks, contentLength) {
|
||||
sendNext();
|
||||
}
|
||||
|
||||
function serveRange(request, response) {
|
||||
const match = String(request.headers.range || '').match(/^bytes=(\d+)-$/);
|
||||
const start = match ? Number(match[1]) : 0;
|
||||
rangedRequests.push({ url: request.url, start });
|
||||
if (!Number.isSafeInteger(start) || start < 0 || start >= rangedPayload.length) {
|
||||
response.writeHead(416, {
|
||||
'Content-Range': `bytes */${rangedPayload.length}`,
|
||||
Connection: 'close'
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
for (let offset = start; offset < rangedPayload.length; offset += 16 * 1024) {
|
||||
chunks.push(rangedPayload.subarray(offset, Math.min(rangedPayload.length, offset + 16 * 1024)));
|
||||
}
|
||||
const headers = {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="ranged-fixture.txt"',
|
||||
'Content-Length': String(rangedPayload.length - start),
|
||||
'Accept-Ranges': 'bytes',
|
||||
ETag: '"ranged-fixture-v1"',
|
||||
Connection: 'close'
|
||||
};
|
||||
if (start) headers['Content-Range'] = `bytes ${start}-${rangedPayload.length - 1}/${rangedPayload.length}`;
|
||||
response.writeHead(start ? 206 : 200, headers);
|
||||
if (response.socket) response.socket.setNoDelay(true);
|
||||
let index = 0;
|
||||
const sendNext = () => {
|
||||
if (response.destroyed || response.writableEnded) return;
|
||||
if (index >= chunks.length) {
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.write(chunks[index++]);
|
||||
setTimeout(sendNext, 130);
|
||||
};
|
||||
sendNext();
|
||||
}
|
||||
|
||||
function monotonic(events) {
|
||||
return events.every((event, index) => {
|
||||
const current = Number(event.receivedBytes);
|
||||
@@ -139,6 +193,8 @@ async function run() {
|
||||
serveChunks(response, knownChunks, knownPayload.length);
|
||||
} else if (request.url === '/unknown.txt') {
|
||||
serveChunks(response, unknownChunks, null);
|
||||
} else if (request.url.startsWith('/range.txt')) {
|
||||
serveRange(request, response);
|
||||
} else {
|
||||
response.writeHead(404, { Connection: 'close' });
|
||||
response.end('not found');
|
||||
@@ -210,7 +266,12 @@ async function run() {
|
||||
})()`);
|
||||
|
||||
check('preload 暴露下载 API',
|
||||
await testWindow.webContents.executeJavaScript('typeof window.api.downloadFile === "function"'));
|
||||
await testWindow.webContents.executeJavaScript(
|
||||
'typeof window.api.downloadFile === "function"'
|
||||
+ ' && typeof window.api.downloads.run === "function"'
|
||||
+ ' && typeof window.api.downloads.pause === "function"'
|
||||
+ ' && typeof window.api.downloads.delete === "function"'
|
||||
));
|
||||
|
||||
const port = server.address().port;
|
||||
const known = await downloadInRenderer(
|
||||
@@ -294,6 +355,188 @@ async function run() {
|
||||
&& unknownEntry.files.some((file) => file.path === unknownPath && file.exists),
|
||||
unknownEntry && unknownEntry.id);
|
||||
|
||||
const directPageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
|
||||
await testWindow.loadFile(path.join(ROOT, 'src', 'ui', 'index.html'));
|
||||
await testWindow.webContents.executeJavaScript(`(() => {
|
||||
window.__pageErrors = [];
|
||||
addEventListener('error', (event) => window.__pageErrors.push(String(event.message || event.error)));
|
||||
addEventListener('unhandledrejection', (event) => window.__pageErrors.push(String(event.reason)));
|
||||
})()`);
|
||||
const centerReady = await waitForRenderer(
|
||||
'window.DownloadCenter && document.getElementById("taskCenterBtn").onclick'
|
||||
);
|
||||
check('主界面加载任务中心', centerReady);
|
||||
|
||||
const centerStarted = await testWindow.webContents.executeJavaScript(`(() => {
|
||||
window.__centerResult = null;
|
||||
window.DownloadCenter.start({
|
||||
key: 'integration-center-download',
|
||||
url: ${JSON.stringify(`http://127.0.0.1:${port}/known.txt`)},
|
||||
suggestName: 'center-fixture.txt',
|
||||
meta: {
|
||||
title: 'Task Center Download',
|
||||
authors: ['Integration Fixture'],
|
||||
sourceId: 'download-test',
|
||||
sourcePostId: 'center'
|
||||
}
|
||||
}).then((result) => { window.__centerResult = result; });
|
||||
document.getElementById('taskCenterBtn').click();
|
||||
return true;
|
||||
})()`);
|
||||
check('任务中心可发起下载', centerStarted);
|
||||
const centerRunning = await waitForRenderer(
|
||||
'document.querySelector(".task-center-item.running")'
|
||||
);
|
||||
check('任务中心显示进行中任务', centerRunning);
|
||||
check('进行中任务显示实时字节进度',
|
||||
await waitForRenderer(
|
||||
'document.querySelector(".task-center-item.running .task-center-status")'
|
||||
+ ' && /已下载|%/.test(document.querySelector(".task-center-item.running .task-center-status").textContent)'
|
||||
));
|
||||
|
||||
await testWindow.webContents.executeJavaScript(
|
||||
'document.querySelector(".tab[data-tab=\\"settings\\"]").click()'
|
||||
);
|
||||
check('切换页面后任务中心仍保留下载',
|
||||
await testWindow.webContents.executeJavaScript(
|
||||
'!document.getElementById("settingsTab").classList.contains("hidden")'
|
||||
+ ' && !!document.querySelector(".task-center-item.running")'
|
||||
));
|
||||
|
||||
const centerComplete = await waitForRenderer(
|
||||
'window.__centerResult && document.querySelector(".task-center-item.complete")',
|
||||
10000
|
||||
);
|
||||
check('切换页面后下载继续并完成', centerComplete);
|
||||
const centerState = await testWindow.webContents.executeJavaScript(`(() => {
|
||||
const item = document.querySelector('.task-center-item.complete');
|
||||
return {
|
||||
result: window.__centerResult,
|
||||
state: item && item.querySelector('.task-center-state').textContent,
|
||||
hasOpen: !!(item && item.querySelector('[data-task-action="open"]')),
|
||||
hasReveal: !!(item && item.querySelector('[data-task-action="reveal"]'))
|
||||
};
|
||||
})()`);
|
||||
check('已完成任务提供打开与定位入口',
|
||||
centerState.state === '已完成' && centerState.hasOpen && centerState.hasReveal,
|
||||
JSON.stringify(centerState));
|
||||
const centerPath = centerState.result && centerState.result.ok && centerState.result.data.path;
|
||||
check('任务中心下载字节完全一致',
|
||||
!!centerPath && fs.existsSync(centerPath) && fs.readFileSync(centerPath).equals(knownPayload),
|
||||
centerPath || '');
|
||||
const centerEntry = centerState.result && centerState.result.ok
|
||||
? library.get(centerState.result.data.entryId) : null;
|
||||
check('任务中心下载自动挂载到书库',
|
||||
!!centerEntry && centerEntry.title === 'Task Center Download'
|
||||
&& centerEntry.files.some((file) => file.path === centerPath && file.exists),
|
||||
centerEntry && centerEntry.id);
|
||||
|
||||
await testWindow.webContents.executeJavaScript(`(() => {
|
||||
window.__resumeFirst = null;
|
||||
window.DownloadCenter.start({
|
||||
key: 'integration-resume-download',
|
||||
url: ${JSON.stringify(`http://127.0.0.1:${port}/range.txt?resume=1`)},
|
||||
suggestName: 'resume-fixture.txt',
|
||||
meta: {
|
||||
title: 'Resume Download',
|
||||
authors: [],
|
||||
sourceId: 'download-test',
|
||||
sourcePostId: 'resume'
|
||||
}
|
||||
}).then((result) => { window.__resumeFirst = result; });
|
||||
})()`);
|
||||
const resumeProgress = await waitForRenderer(`(() => {
|
||||
const item = [...document.querySelectorAll('.task-center-item')]
|
||||
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||
return item && item.classList.contains('running')
|
||||
&& parseFloat(item.querySelector('.task-center-progress-fill').style.width) >= 8;
|
||||
})()`);
|
||||
check('可续传任务开始下载并产生进度', resumeProgress);
|
||||
await testWindow.webContents.executeJavaScript(`(() => {
|
||||
const item = [...document.querySelectorAll('.task-center-item')]
|
||||
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||
item.querySelector('[data-task-action="pause"]').click();
|
||||
})()`);
|
||||
const paused = await waitForRenderer(`(() => {
|
||||
const item = [...document.querySelectorAll('.task-center-item')]
|
||||
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||
return item && item.classList.contains('paused')
|
||||
&& item.querySelector('.task-center-state').textContent === '已暂停'
|
||||
&& item.querySelector('[data-task-action="resume"]');
|
||||
})()`);
|
||||
check('任务中心可暂停未完成下载', paused);
|
||||
|
||||
const filesDir = path.join(LIBRARY_DIR, 'files');
|
||||
const pausedParts = fs.readdirSync(filesDir).filter((name) => name.endsWith('.part'));
|
||||
const pausedPart = pausedParts.length === 1 ? path.join(filesDir, pausedParts[0]) : '';
|
||||
const pausedSize = pausedPart && fs.existsSync(pausedPart) ? fs.statSync(pausedPart).size : 0;
|
||||
check('暂停保留未完成文件作为续传断点',
|
||||
pausedParts.length === 1 && pausedSize > 0 && pausedSize < rangedPayload.length,
|
||||
`文件=${pausedParts.join(',')} 大小=${pausedSize}`);
|
||||
|
||||
await testWindow.webContents.executeJavaScript(`(() => {
|
||||
const item = [...document.querySelectorAll('.task-center-item')]
|
||||
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||
item.querySelector('[data-task-action="resume"]').click();
|
||||
})()`);
|
||||
const resumed = await waitForRenderer(`(() => {
|
||||
const item = [...document.querySelectorAll('.task-center-item')]
|
||||
.find((el) => el.querySelector('.task-center-name').textContent === 'resume-fixture.txt');
|
||||
return item && item.classList.contains('complete');
|
||||
})()`, 10000);
|
||||
check('任务中心可继续已暂停下载并完成', resumed);
|
||||
const resumeRequests = rangedRequests.filter((request) => request.url.includes('resume=1'));
|
||||
check('继续下载从临时文件末尾发送 Range',
|
||||
resumeRequests.length >= 2 && resumeRequests[1].start === pausedSize && pausedSize > 0,
|
||||
JSON.stringify(resumeRequests));
|
||||
const resumeEntry = library.findBySource('download-test', 'resume');
|
||||
const resumePath = resumeEntry && resumeEntry.files[0] && resumeEntry.files[0].path;
|
||||
check('断点续传后的文件字节完全一致',
|
||||
!!resumePath && fs.existsSync(resumePath)
|
||||
&& fs.readFileSync(resumePath).equals(rangedPayload),
|
||||
resumePath || '');
|
||||
check('断点续传完成后清理临时文件',
|
||||
fs.readdirSync(filesDir).every((name) => !name.endsWith('.part')));
|
||||
|
||||
await testWindow.webContents.executeJavaScript(`(() => {
|
||||
window.__deleteResult = null;
|
||||
window.DownloadCenter.start({
|
||||
key: 'integration-delete-download',
|
||||
url: ${JSON.stringify(`http://127.0.0.1:${port}/range.txt?delete=1`)},
|
||||
suggestName: 'delete-fixture.txt',
|
||||
meta: {
|
||||
title: 'Delete Download',
|
||||
authors: [],
|
||||
sourceId: 'download-test',
|
||||
sourcePostId: 'delete'
|
||||
}
|
||||
}).then((result) => { window.__deleteResult = result; });
|
||||
})()`);
|
||||
check('待删除任务先产生部分内容',
|
||||
await waitForRenderer(`(() => {
|
||||
const item = [...document.querySelectorAll('.task-center-item')]
|
||||
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
|
||||
return item && item.classList.contains('running')
|
||||
&& parseFloat(item.querySelector('.task-center-progress-fill').style.width) >= 8;
|
||||
})()`));
|
||||
await testWindow.webContents.executeJavaScript(`(() => {
|
||||
const item = [...document.querySelectorAll('.task-center-item')]
|
||||
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
|
||||
item.querySelector('[data-task-action="delete"]').click();
|
||||
})()`);
|
||||
const deleted = await waitForRenderer(`(() => {
|
||||
const item = [...document.querySelectorAll('.task-center-item')]
|
||||
.find((el) => el.querySelector('.task-center-name').textContent === 'delete-fixture.txt');
|
||||
return !item && window.__deleteResult && window.__deleteResult.ok
|
||||
&& window.__deleteResult.data.deleted === true;
|
||||
})()`);
|
||||
check('任务中心可删除进行中的下载', deleted);
|
||||
await wait(150);
|
||||
check('删除未完成任务会清理临时文件',
|
||||
fs.readdirSync(filesDir).every((name) => !name.endsWith('.part')));
|
||||
check('删除未完成任务不会创建书库条目',
|
||||
!library.findBySource('download-test', 'delete'));
|
||||
|
||||
const css = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'style.css'), 'utf8');
|
||||
const downloadedRule = cssRule(css, '.dl-btn.downloaded');
|
||||
const backgroundValue = declaration(downloadedRule, 'background');
|
||||
@@ -313,8 +556,8 @@ async function run() {
|
||||
await wait(100);
|
||||
const pageErrors = await testWindow.webContents.executeJavaScript('window.__pageErrors.slice()');
|
||||
check('下载流程没有渲染器错误',
|
||||
rendererErrors.length === 0 && pageErrors.length === 0,
|
||||
rendererErrors.concat(pageErrors).join(' | '));
|
||||
rendererErrors.length === 0 && directPageErrors.length === 0 && pageErrors.length === 0,
|
||||
rendererErrors.concat(directPageErrors, pageErrors).join(' | '));
|
||||
} catch (error) {
|
||||
check('下载集成流程无异常', false, error && (error.stack || error.message || String(error)));
|
||||
} finally {
|
||||
|
||||
@@ -285,6 +285,12 @@ test('下载处理发送隔离请求 ID 的字节进度和完成事件', () => {
|
||||
assert.match(mainSrc, /receivedBytes\s*\+=\s*chunk\.length/);
|
||||
assert.match(mainSrc, /percent:\s*totalBytes\s*\?\s*Math\.min\(1,\s*receivedBytes\s*\/\s*totalBytes\)\s*:\s*null/);
|
||||
assert.match(mainSrc, /percent:\s*1,\s*complete:\s*true/);
|
||||
assert.match(mainSrc, /headers\['Range'\]\s*=\s*`bytes=\$\{resumeBytes\}-`/);
|
||||
assert.match(mainSrc, /res\.status\s*===\s*206/);
|
||||
assert.match(mainSrc, /ipcMain\.handle\('download:pause'/);
|
||||
assert.match(mainSrc, /ipcMain\.handle\('download:delete'/);
|
||||
assert.match(mainSrc, /downloadSessionKey\(event\.sender\.id,\s*id\)/);
|
||||
assert.match(mainSrc, /removeDownloadPartial\(download\)/);
|
||||
});
|
||||
|
||||
test('窗口使用 icons/dist 主题图标并同步界面主题', () => {
|
||||
|
||||
+24
-1
@@ -165,9 +165,32 @@ test('书库页提供可管理标签目录和整理多选下拉', () => {
|
||||
assert.doesNotMatch(library, /id="libraryBookTags" type="text"/);
|
||||
});
|
||||
|
||||
test('下载区展示进度且完成按钮使用高对比绿色底色', () => {
|
||||
test('下载区接入全局任务中心且完成按钮使用高对比绿色底色', () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8');
|
||||
const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8');
|
||||
const center = fs.readFileSync(path.join(__dirname, '..', 'ui', 'download-center.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8');
|
||||
const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8');
|
||||
assert.match(html, /id="taskCenterBtn"/);
|
||||
assert.match(html, /id="taskCenterPanel"/);
|
||||
assert.ok(html.indexOf('download-center.js') < html.indexOf('views/browse.js'));
|
||||
assert.match(app, /DownloadCenter\.init\(\)/);
|
||||
assert.match(browse, /window\.DownloadCenter\.start\(\{/);
|
||||
assert.doesNotMatch(browse, /await window\.api\.downloadFile/);
|
||||
assert.match(center, /window\.api\.downloads\.run\(/);
|
||||
assert.match(center, /window\.api\.downloads\.pause\(/);
|
||||
assert.match(center, /window\.api\.downloads\.delete\(/);
|
||||
assert.match(center, /data-task-action="pause"/);
|
||||
assert.match(center, /data-task-action="resume"/);
|
||||
assert.match(center, /data-task-action="delete"/);
|
||||
assert.match(preload, /pause:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:pause'/);
|
||||
assert.match(preload, /delete:\s*\(requestId\)\s*=>\s*ipcRenderer\.invoke\('download:delete'/);
|
||||
assert.match(center, /task\.status = 'complete'/);
|
||||
assert.match(center, /task\.status = 'failed'/);
|
||||
assert.match(center, /data-task-action="open"/);
|
||||
assert.match(css, /\.task-center-panel\s*\{/);
|
||||
assert.match(css, /\.task-center-badge\s*\{/);
|
||||
assert.match(browse, /createDownloadProgress/);
|
||||
assert.match(browse, /updateDownloadProgress/);
|
||||
assert.match(browse, /classList\.add\('downloaded'\)/);
|
||||
|
||||
Reference in New Issue
Block a user