diff --git a/package.json b/package.json index 0ecb646..94b50f0 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "build": "tsc && vite build", "check:i18n": "vitest run src/i18n/resources.test.ts", "check:updates": "node scripts/check-updates.js", + "smoke:torrent": "node scripts/smoke-torrent.js", + "smoke:torrent:failure-paths": "node scripts/smoke-torrent.js --failure-paths", "verify:macos-signing": "node scripts/verify-macos-signing.js", "preview": "vite preview", "tauri": "tauri", diff --git a/scripts/smoke-torrent.js b/scripts/smoke-torrent.js new file mode 100644 index 0000000..72ed83e --- /dev/null +++ b/scripts/smoke-torrent.js @@ -0,0 +1,715 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import http from 'node:http'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); + +function argumentValue(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +if (process.argv.includes('--help')) { + console.log('Usage: node scripts/smoke-torrent.js [--binary PATH] [--failure-paths] [--keep-temp]'); + process.exit(0); +} + +const keepTemp = process.argv.includes('--keep-temp'); +const runFailurePaths = process.argv.includes('--failure-paths'); +const arch = { x64: 'x86_64', arm64: 'aarch64' }[os.arch()]; +const platform = { + darwin: 'apple-darwin', + linux: 'unknown-linux-gnu', + win32: 'pc-windows-msvc', +}[os.platform()]; +if (!arch || !platform) { + throw new Error(`Unsupported host: ${os.arch()} / ${os.platform()}`); +} +const targetTriple = `${arch}-${platform}`; +const executableName = `aria2c-${targetTriple}${os.platform() === 'win32' ? '.exe' : ''}`; +const binaryPath = path.resolve( + argumentValue('--binary') || path.join(repoRoot, 'src-tauri', 'binaries', executableName), +); + +const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); +const activeDaemons = new Set(); +let runtimeTempRoot; +let runtimeTracker; +let cleanupPromise; +let signalTerminationRequested = false; + +function childExited(child) { + return child.exitCode !== null || child.signalCode !== null; +} + +async function findAvailablePort() { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port: 0 }, resolve); + }); + const address = server.address(); + const port = address && typeof address !== 'string' ? address.port : null; + await new Promise(resolve => server.close(resolve)); + if (!port) throw new Error('Could not reserve a local port'); + return port; +} + +function bencode(value) { + if (Buffer.isBuffer(value)) { + return Buffer.concat([Buffer.from(`${value.length}:`), value]); + } + if (typeof value === 'string') return bencode(Buffer.from(value)); + if (typeof value === 'number' && Number.isInteger(value)) { + return Buffer.from(`i${value}e`); + } + if (Array.isArray(value)) { + return Buffer.concat([Buffer.from('l'), ...value.map(bencode), Buffer.from('e')]); + } + if (value instanceof Map || (value && typeof value === 'object')) { + const entries = value instanceof Map ? [...value.entries()] : Object.entries(value); + entries.sort(([left], [right]) => Buffer.compare(Buffer.from(left), Buffer.from(right))); + return Buffer.concat([ + Buffer.from('d'), + ...entries.flatMap(([key, child]) => [bencode(String(key)), bencode(child)]), + Buffer.from('e'), + ]); + } + throw new Error(`Unsupported bencode value: ${typeof value}`); +} + +function decodeBencode(bytes) { + let position = 0; + const parse = () => { + const marker = bytes[position]; + if (marker === 0x69) { + position += 1; + const end = bytes.indexOf(0x65, position); + if (end < 0) throw new Error('Malformed integer in saved torrent'); + const value = Number(bytes.subarray(position, end).toString('ascii')); + position = end + 1; + return value; + } + if (marker === 0x6c) { + position += 1; + const values = []; + while (bytes[position] !== 0x65) values.push(parse()); + position += 1; + return values; + } + if (marker === 0x64) { + position += 1; + const values = new Map(); + while (bytes[position] !== 0x65) { + const key = parse(); + if (!Buffer.isBuffer(key)) throw new Error('Malformed dictionary key in saved torrent'); + values.set(key.toString('utf8'), parse()); + } + position += 1; + return values; + } + if (marker >= 0x30 && marker <= 0x39) { + const separator = bytes.indexOf(0x3a, position); + if (separator < 0) throw new Error('Malformed byte string in saved torrent'); + const length = Number(bytes.subarray(position, separator).toString('ascii')); + position = separator + 1; + const value = bytes.subarray(position, position + length); + if (value.length !== length) throw new Error('Truncated byte string in saved torrent'); + position += length; + return value; + } + throw new Error(`Unexpected bencode marker ${String.fromCharCode(marker || 0)}`); + }; + const value = parse(); + if (position !== bytes.length) throw new Error('Saved torrent contains trailing data'); + return value; +} + +function sha1(value) { + return crypto.createHash('sha1').update(value).digest(); +} + +function createRuntimeTorrent(trackerUrl) { + const name = 'firelink-torrent-runtime'; + const pieceLength = 16 * 1024; + const files = [ + { + path: 'selected.bin', + data: Buffer.alloc(256 * 1024, 0x53), + }, + { + path: 'skipped.bin', + data: Buffer.from('this file must not be selected\n'), + }, + ]; + const piecesSource = Buffer.concat(files.map(file => file.data)); + const pieces = []; + for (let offset = 0; offset < piecesSource.length; offset += pieceLength) { + pieces.push(sha1(piecesSource.subarray(offset, offset + pieceLength))); + } + const info = new Map([ + ['files', files.map(file => new Map([ + ['length', file.data.length], + ['path', [file.path]], + ]))], + ['name', name], + ['piece length', pieceLength], + ['pieces', Buffer.concat(pieces)], + ]); + const torrent = new Map([ + ['announce', trackerUrl], + ['info', info], + ]); + const bytes = bencode(torrent); + const infoHash = sha1(bencode(info)).toString('hex'); + return { bytes, infoHash, name, files }; +} + +function startTracker(seederPort) { + let activeSeederPort = seederPort; + const server = http.createServer((request, response) => { + if (!request.url || new URL(request.url, 'http://127.0.0.1').pathname !== '/announce') { + response.writeHead(404).end(); + return; + } + const peer = Buffer.alloc(6); + peer[0] = 127; + peer[1] = 0; + peer[2] = 0; + peer[3] = 1; + peer.writeUInt16BE(activeSeederPort, 4); + const body = bencode(new Map([ + ['complete', 1], + ['incomplete', 0], + ['interval', 2], + ['min interval', 1], + ['peers', peer], + ])); + response.writeHead(200, { + 'Content-Length': body.length, + 'Content-Type': 'text/plain', + }); + response.end(body); + }); + return { + server, + setSeederPort(port) { + activeSeederPort = port; + }, + }; +} + +async function listen(server) { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen({ host: '127.0.0.1', port: 0 }, resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Could not determine local server port'); + return address.port; +} + +function daemonEnvironment() { + const libraries = path.join(path.dirname(binaryPath), 'aria2-libs'); + return fs.existsSync(libraries) + ? { ...process.env, OPENSSL_MODULES: libraries } + : process.env; +} + +async function rpc(port, secret, method, params = []) { + const response = await fetch(`http://127.0.0.1:${port}/jsonrpc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: `firelink-torrent-${Date.now()}`, + method, + params: [`token:${secret}`, ...params], + }), + signal: AbortSignal.timeout(3000), + }); + const body = await response.json(); + if (body.error) throw new Error(`${method}: ${JSON.stringify(body.error)}`); + if (!Object.hasOwn(body, 'result')) throw new Error(`${method}: response has no result`); + return body.result; +} + +async function waitFor(description, check, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + const result = await check(); + if (result) return result; + } catch (error) { + lastError = error; + } + await sleep(100); + } + throw new Error(`Timed out waiting for ${description}${lastError ? `: ${lastError.message}` : ''}`); +} + +async function startDaemon({ name, rpcPort, listenPort, directory, extraArgs = [] }) { + const maxAttempts = 3; + let lastError; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const selectedRpcPort = attempt === 0 ? rpcPort : await findAvailablePort(); + const selectedListenPort = listenPort === undefined + ? undefined + : attempt === 0 ? listenPort : await findAvailablePort(); + const secret = `firelink-${name}-${crypto.randomUUID()}`; + const stderr = []; + const child = spawn(binaryPath, [ + '--enable-rpc=true', + `--rpc-listen-port=${selectedRpcPort}`, + '--rpc-listen-all=false', + `--rpc-secret=${secret}`, + `--dir=${directory}`, + '--file-allocation=none', + '--enable-dht=false', + '--enable-peer-exchange=false', + '--bt-enable-lpd=false', + '--console-log-level=error', + '--quiet=true', + ...(selectedListenPort ? [`--listen-port=${selectedListenPort}`] : []), + ...extraArgs, + ], { + env: daemonEnvironment(), + stdio: ['ignore', 'ignore', 'pipe'], + }); + child.stderr.on('data', chunk => stderr.push(chunk.toString())); + let exit; + child.once('exit', (code, signal) => { exit = { code, signal }; }); + child.once('error', error => { exit = { error }; }); + const daemon = { + child, + secret, + stderr, + rpcPort: selectedRpcPort, + listenPort: selectedListenPort, + }; + activeDaemons.add(daemon); + try { + await waitFor(`${name} Aria2 RPC`, async () => { + if (exit) throw new Error(`${name} exited: ${exit.error?.message || `${exit.code}/${exit.signal}`}`); + try { + await rpc(selectedRpcPort, secret, 'aria2.getVersion'); + return true; + } catch { + return false; + } + }, 10000); + return daemon; + } catch (error) { + lastError = error; + const detail = `${error.message}\n${stderr.join('')}`; + await stopDaemon(daemon); + if (!/address already in use|failed to bind|could not bind|listen failed/i.test(detail)) { + throw error; + } + } + } + throw lastError || new Error(`Could not start ${name} Aria2 daemon`); +} + +async function stopDaemon(daemon) { + const child = daemon?.child; + if (!child) return; + try { + if (!childExited(child)) { + try { + await rpc(daemon.rpcPort, daemon.secret, 'aria2.shutdown'); + } catch { + // The process may already have exited during cleanup. + } + } + if (!await waitForChildExit(child, 3000)) { + child.kill('SIGTERM'); + if (!await waitForChildExit(child, 3000) && process.platform !== 'win32') { + child.kill('SIGKILL'); + await waitForChildExit(child, 1000); + } + } + } finally { + activeDaemons.delete(daemon); + } +} + +async function closeServer(server) { + if (!server || !server.listening) return; + await new Promise(resolve => server.close(() => resolve())); +} + +async function cleanupRuntime() { + if (cleanupPromise) return cleanupPromise; + cleanupPromise = (async () => { + for (const daemon of [...activeDaemons].reverse()) { + try { + await stopDaemon(daemon); + } catch (error) { + console.error(`[WARN] failed to stop Aria2 daemon: ${error.message}`); + } + } + if (runtimeTracker) { + const tracker = runtimeTracker; + runtimeTracker = undefined; + await closeServer(tracker.server); + } + if (runtimeTempRoot) { + const tempRoot = runtimeTempRoot; + runtimeTempRoot = undefined; + if (keepTemp) console.log(`[INFO] retained smoke-test directory: ${tempRoot}`); + else fs.rmSync(tempRoot, { recursive: true, force: true }); + } + })(); + return cleanupPromise; +} + +async function tellStatus(daemon, gid) { + return rpc(daemon.rpcPort, daemon.secret, 'aria2.tellStatus', [gid, [ + 'status', + 'errorCode', + 'errorMessage', + 'completedLength', + 'totalLength', + 'files', + ]]); +} + +async function waitForTerminal(daemon, gid, timeoutMs = 30000) { + let lastStatus; + try { + return await waitFor(`Aria2 transfer ${gid} to complete`, async () => { + lastStatus = await tellStatus(daemon, gid); + if (lastStatus.status === 'complete') return lastStatus; + if (lastStatus.status === 'error' || lastStatus.status === 'removed') { + throw new Error(`transfer ended ${lastStatus.status}: ${lastStatus.errorCode || ''} ${lastStatus.errorMessage || ''}`); + } + return false; + }, timeoutMs); + } catch (error) { + const stderr = daemon.stderr?.join('').trim(); + throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}${stderr ? `; stderr ${stderr}` : ''}`); + } +} + +async function waitForStatus(daemon, gid, expected, timeoutMs = 10000) { + return waitFor(`Aria2 transfer ${gid} to become ${expected}`, async () => { + const status = await tellStatus(daemon, gid); + if (status.status === expected) return status; + if (status.status === 'error' || status.status === 'removed') { + throw new Error(`transfer ended ${status.status}: ${status.errorCode || ''} ${status.errorMessage || ''}`); + } + return false; + }, timeoutMs); +} + +async function waitForDataComplete(daemon, gid, timeoutMs = 30000) { + let lastStatus; + try { + return await waitFor(`Aria2 transfer ${gid} data to complete`, async () => { + lastStatus = await tellStatus(daemon, gid); + if (lastStatus.status === 'error' || lastStatus.status === 'removed') { + throw new Error(`transfer ended ${lastStatus.status}: ${lastStatus.errorCode || ''} ${lastStatus.errorMessage || ''}`); + } + return Number(lastStatus.completedLength) === Number(lastStatus.totalLength) ? lastStatus : false; + }, timeoutMs); + } catch (error) { + const stderr = daemon.stderr?.join('').trim(); + throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}${stderr ? `; stderr ${stderr}` : ''}`); + } +} + +async function waitForRemoved(daemon, gid) { + let lastStatus; + try { + return await waitFor(`Aria2 transfer ${gid} to be removed`, async () => { + try { + lastStatus = await tellStatus(daemon, gid); + return lastStatus.status === 'removed' ? lastStatus : false; + } catch (error) { + return /not found|no such download/i.test(error.message) ? true : false; + } + }, 10000); + } catch (error) { + throw new Error(`${error.message}; last status ${JSON.stringify(lastStatus)}`); + } +} + +async function forceRemoveIfPresent(daemon, gid) { + try { + await rpc(daemon.rpcPort, daemon.secret, 'aria2.forceRemove', [gid]); + return true; + } catch (error) { + if (/not found|no such download|active download not found/i.test(error.message)) return false; + throw error; + } +} + +async function waitForChildExit(child, timeoutMs = 5000) { + if (childExited(child)) return true; + return new Promise(resolve => { + let settled = false; + let timer; + let onExit; + const finish = result => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.off('exit', onExit); + resolve(result); + }; + timer = setTimeout(() => { + finish(false); + }, timeoutMs); + onExit = () => finish(true); + child.once('exit', onExit); + if (childExited(child)) finish(true); + }); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +async function runFailurePathChecks({ client, tempRoot }) { + const failureDir = path.join(tempRoot, 'failure'); + fs.mkdirSync(failureDir, { recursive: true }); + const unavailablePort = await findAvailablePort(); + const unavailableInfoHash = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + const unavailableMagnet = `magnet:?xt=urn:btih:${unavailableInfoHash}&dn=unavailable-firelink-fixture&tr=${encodeURIComponent(`http://127.0.0.1:${unavailablePort}/announce`)}`; + const failureGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[unavailableMagnet], { + dir: failureDir, + 'bt-metadata-only': 'true', + 'bt-save-metadata': 'true', + 'max-tries': '1', + 'retry-wait': '1', + 'connect-timeout': '1', + 'bt-tracker-connect-timeout': '1', + timeout: '2', + 'auto-file-renaming': 'false', + }]); + let lastStatus; + const deadline = Date.now() + 8000; + while (Date.now() < deadline) { + try { + lastStatus = await tellStatus(client, failureGid); + if (lastStatus.status === 'error' || lastStatus.status === 'complete') break; + } catch { + break; + } + await sleep(100); + } + const failureRemoved = await forceRemoveIfPresent(client, failureGid); + if (failureRemoved) await waitForRemoved(client, failureGid); + const savedFailureMetadata = fs.readdirSync(failureDir) + .filter(file => file.toLowerCase().endsWith('.torrent')); + assert(savedFailureMetadata.length === 0, 'unavailable tracker produced metadata unexpectedly'); + assert(lastStatus?.status !== 'complete', 'unavailable tracker completed a metadata probe unexpectedly'); + console.log(`[OK] unavailable tracker was cleaned up without metadata (${lastStatus?.status || 'gone'})`); + + const exitDir = path.join(tempRoot, 'daemon-exit'); + fs.mkdirSync(exitDir, { recursive: true }); + const exitRpcPort = await findAvailablePort(); + const exitDaemon = await startDaemon({ + name: 'exit', + rpcPort: exitRpcPort, + listenPort: await findAvailablePort(), + directory: exitDir, + extraArgs: ['--seed-time=0'], + }); + const exitGid = await rpc(exitDaemon.rpcPort, exitDaemon.secret, 'aria2.addUri', [[unavailableMagnet], { + dir: exitDir, + 'bt-metadata-only': 'true', + 'bt-save-metadata': 'true', + 'max-tries': '1', + 'connect-timeout': '1', + 'bt-tracker-connect-timeout': '1', + timeout: '15', + }]); + assert(exitGid, 'daemon-exit probe did not return a GID'); + exitDaemon.child.kill('SIGTERM'); + assert(await waitForChildExit(exitDaemon.child), 'Aria2 daemon did not exit after termination'); + assert( + !fs.readdirSync(exitDir).some(file => file.toLowerCase().endsWith('.torrent')), + 'daemon exit left saved metadata in the probe directory', + ); + console.log('[OK] daemon termination left no saved probe metadata'); +} + +async function main() { + if (!fs.existsSync(binaryPath)) { + throw new Error(`aria2c binary not found: ${binaryPath}`); + } + + runtimeTempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'firelink-torrent-smoke-')); + try { + const tempRoot = runtimeTempRoot; + const seedParent = path.join(tempRoot, 'seed'); + const seedRoot = path.join(seedParent, 'firelink-torrent-runtime'); + const probeDir = path.join(tempRoot, 'probe'); + const finalDir = path.join(tempRoot, 'final'); + const cancelDir = path.join(tempRoot, 'cancel'); + for (const directory of [seedRoot, probeDir, finalDir, cancelDir]) fs.mkdirSync(directory, { recursive: true }); + + const seederListenPort = await findAvailablePort(); + const clientListenPort = await findAvailablePort(); + runtimeTracker = startTracker(seederListenPort); + const trackerPort = await listen(runtimeTracker.server); + const torrent = createRuntimeTorrent(`http://127.0.0.1:${trackerPort}/announce`); + for (const file of torrent.files) fs.writeFileSync(path.join(seedRoot, file.path), file.data); + const magnet = `magnet:?xt=urn:btih:${torrent.infoHash}&dn=${encodeURIComponent(torrent.name)}&tr=${encodeURIComponent(`http://127.0.0.1:${trackerPort}/announce`)}`; + const indexOut = [ + `1=${torrent.name}/selected.bin`, + `2=${torrent.name}/skipped.bin`, + ]; + const seederRpcPort = await findAvailablePort(); + const seeder = await startDaemon({ + name: 'seeder', + rpcPort: seederRpcPort, + listenPort: seederListenPort, + directory: seedParent, + extraArgs: ['--seed-time=60', '--seed-ratio=0'], + }); + runtimeTracker.setSeederPort(seeder.listenPort); + const seedGid = await rpc(seeder.rpcPort, seeder.secret, 'aria2.addTorrent', [ + torrent.bytes.toString('base64'), + [], + { + dir: seedParent, + 'seed-time': '60', + 'seed-ratio': '0', + 'allow-overwrite': 'true', + 'check-integrity': 'true', + }, + ]); + await waitForDataComplete(seeder, seedGid); + console.log(`[OK] local seeder completed ${seedGid}`); + + const clientRpcPort = await findAvailablePort(); + const client = await startDaemon({ + name: 'client', + rpcPort: clientRpcPort, + listenPort: clientListenPort, + directory: probeDir, + extraArgs: ['--seed-time=0'], + }); + const probeGid = await rpc(client.rpcPort, client.secret, 'aria2.addUri', [[magnet], { + dir: probeDir, + 'bt-metadata-only': 'true', + 'bt-save-metadata': 'true', + 'max-tries': '3', + 'retry-wait': '1', + 'connect-timeout': '5', + timeout: '15', + 'auto-file-renaming': 'false', + }]); + const probeStatus = await waitForTerminal(client, probeGid, 30000); + assert(probeStatus.status === 'complete', 'magnet metadata probe did not complete'); + const savedTorrentPaths = fs.readdirSync(probeDir) + .filter(file => file.toLowerCase().endsWith('.torrent')) + .map(file => path.join(probeDir, file)); + assert(savedTorrentPaths.length === 1, `expected one saved metadata file, found ${savedTorrentPaths.length}`); + const expectedMetadataPath = path.join(probeDir, `${torrent.infoHash}.torrent`); + assert(savedTorrentPaths[0] === expectedMetadataPath, `Aria2 saved metadata at an unexpected path: ${savedTorrentPaths[0]}`); + const savedTorrentBytes = fs.readFileSync(savedTorrentPaths[0]); + const savedTorrent = decodeBencode(savedTorrentBytes); + const savedInfo = savedTorrent instanceof Map ? savedTorrent.get('info') : undefined; + assert(savedInfo instanceof Map, 'saved metadata has no info dictionary'); + assert(sha1(bencode(savedInfo)).toString('hex') === torrent.infoHash, 'saved metadata hash differs from magnet'); + console.log(`[OK] magnet metadata resolved and hash matched ${torrent.infoHash}`); + const probeRemoved = await forceRemoveIfPresent(client, probeGid); + if (probeRemoved) await waitForRemoved(client, probeGid); + fs.rmSync(probeDir, { recursive: true, force: true }); + fs.mkdirSync(probeDir, { recursive: true }); + console.log('[OK] metadata probe was removed after resolution'); + + const finalGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ + savedTorrentBytes.toString('base64'), + [], + { + dir: finalDir, + 'select-file': '1', + 'index-out': indexOut, + 'max-download-limit': '32K', + 'seed-time': '0', + 'auto-file-renaming': 'false', + }, + ]); + await waitForStatus(client, finalGid, 'active', 10000); + await rpc(client.rpcPort, client.secret, 'aria2.forcePause', [finalGid]); + await waitForStatus(client, finalGid, 'paused', 10000); + await rpc(client.rpcPort, client.secret, 'aria2.unpause', [finalGid]); + const finalStatus = await waitForTerminal(client, finalGid, 30000); + const selectedPath = path.join(finalDir, torrent.name, 'selected.bin'); + const skippedPath = path.join(finalDir, torrent.name, 'skipped.bin'); + assert(fs.existsSync(selectedPath), `selected torrent output is missing: ${selectedPath}`); + assert(fs.readFileSync(selectedPath).equals(torrent.files[0].data), 'selected torrent output content differs'); + assert(!fs.existsSync(skippedPath), 'unselected torrent output was written'); + const reportedFiles = await rpc(client.rpcPort, client.secret, 'aria2.getFiles', [finalGid]); + const reportedSelected = reportedFiles.find(file => file.path === selectedPath || file.path.endsWith('/selected.bin')); + assert(reportedSelected, `Aria2 ownership list did not report ${selectedPath}`); + assert(finalStatus.files?.some(file => file.path === selectedPath || file.path.endsWith('/selected.bin')), 'terminal status omitted selected output'); + console.log('[OK] selected addTorrent output, pause/resume, and Aria2 file ownership passed'); + + const cancelGid = await rpc(client.rpcPort, client.secret, 'aria2.addTorrent', [ + savedTorrentBytes.toString('base64'), + [], + { + dir: cancelDir, + 'select-file': '1', + 'index-out': indexOut, + 'max-download-limit': '8K', + 'seed-time': '0', + 'auto-file-renaming': 'false', + }, + ]); + await waitForStatus(client, cancelGid, 'active', 10000); + assert(await forceRemoveIfPresent(client, cancelGid), 'cancel/remove raced to a terminal state before forceRemove'); + await waitForRemoved(client, cancelGid); + const canceledPath = path.join(cancelDir, torrent.name, 'selected.bin'); + assert( + !fs.existsSync(canceledPath) || fs.statSync(canceledPath).size < torrent.files[0].data.length, + 'canceled torrent produced a complete output', + ); + console.log('[OK] cancel/remove stopped the second torrent before completion'); + + if (runFailurePaths) { + await runFailurePathChecks({ client, tempRoot }); + } + } finally { + await cleanupRuntime(); + } +} + +function handleTerminationSignal(signal) { + if (signalTerminationRequested) return; + signalTerminationRequested = true; + void cleanupRuntime().then(() => { + process.exit(signal === 'SIGINT' ? 130 : 143); + }, error => { + console.error(`[FAIL] Torrent runtime smoke cleanup: ${error.message}`); + process.exit(signal === 'SIGINT' ? 130 : 143); + }); +} + +const onSigint = () => handleTerminationSignal('SIGINT'); +const onSigterm = () => handleTerminationSignal('SIGTERM'); +process.once('SIGINT', onSigint); +process.once('SIGTERM', onSigterm); + +main().catch(error => { + console.error(`[FAIL] Torrent runtime smoke test: ${error.message}`); + process.exitCode = 1; +}).finally(() => { + process.off('SIGINT', onSigint); + process.off('SIGTERM', onSigterm); +}); diff --git a/src-tauri/tests/README.md b/src-tauri/tests/README.md index dde7111..0d1ca99 100644 --- a/src-tauri/tests/README.md +++ b/src-tauri/tests/README.md @@ -23,3 +23,21 @@ cd src-tauri FIRELINK_LIVE_YOUTUBE_URL='https://www.youtube.com/watch?v=dQw4w9WgXcQ' \ cargo test filters_live_youtube_metadata_from_env --lib -- --ignored --nocapture ``` + +Run the real local Torrent runtime smoke test against the host bundled Aria2 +binary. It starts a local tracker and two Aria2 daemons, then covers magnet +metadata resolution, saved-metadata hash validation, selected-file output, +pause/resume, ownership reporting, and cancel/remove: + +```sh +npm run smoke:torrent +``` + +Use `node scripts/smoke-torrent.js --binary /path/to/aria2c` when validating a +packaged or target-specific Aria2 binary. + +Run the deterministic unavailable-tracker and Aria2-daemon-exit checks with: + +```sh +npm run smoke:torrent:failure-paths +```