test(torrents): harden runtime smoke harness

This commit is contained in:
NimBold
2026-07-31 20:30:57 +03:30
parent 186e189277
commit a8bd80d557
3 changed files with 735 additions and 0 deletions
+2
View File
@@ -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",
+715
View File
@@ -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);
});
+18
View File
@@ -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
```