Restore the v2.1.19 application baseline and retain only the focused import preflight summary with duplicate, unavailable, destination, job, and size-limit visibility.
This commit is contained in:
@@ -1,58 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const preloadSource = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8');
|
||||
|
||||
test('publishes the authoritative batch report only after finalization and source cleanup', () => {
|
||||
const handler = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf('// Shutdown after finish'));
|
||||
const finalization = handler.indexOf('uploadFinalizationBarrier.finalize');
|
||||
const cleanup = handler.indexOf('sourceCleanup.finishBatch');
|
||||
const report = handler.indexOf('publishBatchCompletionReport');
|
||||
|
||||
assert.ok(finalization >= 0);
|
||||
assert.ok(cleanup > finalization);
|
||||
assert.ok(report > cleanup);
|
||||
});
|
||||
|
||||
test('keeps initial and live admission skips in the final batch summary', () => {
|
||||
assert.match(mainSource, /const uploadBatchAdmissionSkips = new WeakMap\(\)/);
|
||||
assert.match(mainSource, /uploadBatchAdmissionSkips\.set\(_thisManager, batchAdmissionSkippedJobs\)/);
|
||||
assert.match(mainSource, /uploadBatchAdmissionSkips\.get\(batchManager\).*push\(\.\.\.skippedJobs\)/s);
|
||||
assert.match(mainSource, /stats\.mergeSkippedIntoSummary\(summary, batchAdmissionSkippedJobs\)/);
|
||||
assert.match(mainSource, /fileName: j\.fileName \|\| path\.basename\(j\.file \|\| ''\)/);
|
||||
assert.match(mainSource, /fileKey: buildBatchFileKey\(j\.file\)/);
|
||||
assert.match(mainSource, /size: Number\(j\.bytesTotal\) \|\| 0/);
|
||||
});
|
||||
|
||||
test('finalizes cleanup and reports skipped-only and rejected-start batches', () => {
|
||||
const skippedOnly = mainSource.slice(mainSource.indexOf('if (tasks.length === 0)'), mainSource.indexOf('uploadManager = new UploadManager'));
|
||||
const rejectedStart = mainSource.slice(mainSource.indexOf('}).catch(async (err) =>'), mainSource.indexOf('logMemorySnapshot(\'batch-start\')'));
|
||||
|
||||
assert.match(skippedOnly, /sourceCleanup\.finishBatch/);
|
||||
assert.match(skippedOnly, /publishBatchCompletionReport/);
|
||||
assert.match(rejectedStart, /sourceCleanup\.finishBatch/);
|
||||
assert.match(rejectedStart, /publishBatchCompletionReport/);
|
||||
});
|
||||
|
||||
test('preload exposes report recovery and report-bound exports', () => {
|
||||
assert.match(preloadSource, /onUploadBatchReport/);
|
||||
assert.match(preloadSource, /getLastBatchCompletionReport/);
|
||||
assert.match(preloadSource, /exportBatchCompletionReport/);
|
||||
assert.match(preloadSource, /removeAllListeners\('upload-batch-report'\)/);
|
||||
assert.match(mainSource, /shellText\('Der Batch-Bericht ist nicht mehr verfügbar', 'The batch report is no longer available'\)/);
|
||||
assert.match(mainSource, /shellText\('Ungültiges Exportformat', 'Invalid export format'\)/);
|
||||
});
|
||||
|
||||
test('does not cache or publish a fully aborted batch report', () => {
|
||||
const publisher = mainSource.slice(
|
||||
mainSource.indexOf('function publishBatchCompletionReport'),
|
||||
mainSource.indexOf('function shouldLogHosterToFile')
|
||||
);
|
||||
|
||||
assert.match(publisher, /if \(isAllAborted\(summary\)\) return null/);
|
||||
assert.ok(publisher.indexOf('isAllAborted(summary)') < publisher.indexOf('batchCompletionReports.set'));
|
||||
assert.ok(publisher.indexOf('isAllAborted(summary)') < publisher.indexOf("safeSend('upload-batch-report'"));
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
test('builds immutable file, job, transfer, cleanup, host and error totals', () => {
|
||||
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
|
||||
const report = buildBatchCompletionReport({
|
||||
reportId: 'report-1',
|
||||
startedAt: '2026-08-16T10:00:00.000Z',
|
||||
completedAt: '2026-08-16T10:00:10.000Z',
|
||||
cleanupOutcomes: ['deleted', 'blocked', 'source-changed', 'source-missing', 'unsafe-source-type', 'failed', 'setting-disabled'],
|
||||
summary: {
|
||||
id: 'batch-1',
|
||||
files: [
|
||||
{
|
||||
name: 'complete.mkv',
|
||||
size: 100,
|
||||
results: [
|
||||
{ jobId: 'done-a', hoster: 'doodstream.com', status: 'done', attempt: 1, maxAttempts: 3 },
|
||||
{ jobId: 'done-b', hoster: 'voe.sx', status: 'done', attempt: 1, maxAttempts: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'partial.mkv',
|
||||
size: 200,
|
||||
results: [
|
||||
{ jobId: 'done-c', hoster: 'doodstream.com', status: 'done', attempt: 2, maxAttempts: 3 },
|
||||
{ jobId: 'error-a', hoster: 'voe.sx', status: 'error', error: 'network timeout', attempt: 2, maxAttempts: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'failed.mkv',
|
||||
size: 300,
|
||||
results: [
|
||||
{ jobId: 'skip-a', hoster: 'doodstream.com', status: 'skipped', error: 'No account', attempt: 0, maxAttempts: 0 },
|
||||
{ jobId: 'abort-a', hoster: 'voe.sx', status: 'aborted', error: 'Aborted', attempt: 0, maxAttempts: 2 },
|
||||
{ jobId: 'error-b', hoster: 'byse.sx', status: 'error', error: 'account full', attempt: 1, maxAttempts: 1, remoteCommitUncertain: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(report.files, { total: 3, fullySucceeded: 1, partiallySucceeded: 1, failed: 1 });
|
||||
assert.deepEqual(report.jobs, { total: 7, succeeded: 3, failed: 2, skipped: 1, aborted: 1 });
|
||||
assert.deepEqual(report.cleanup, { requested: 6, deleted: 1, blocked: 4, failed: 1 });
|
||||
assert.deepEqual(report.transfer, { successfulBytes: 400, averageBytesPerSecond: 40 });
|
||||
assert.deepEqual(report.hosters['doodstream.com'], { total: 3, succeeded: 2, failed: 0, skipped: 1, aborted: 0, successfulBytes: 300 });
|
||||
assert.equal(report.errors.length, 2);
|
||||
assert.deepEqual(report.errors.map(error => error.category), ['network', 'account-error']);
|
||||
assert.equal(report.errors[1].remoteCommitUncertain, true);
|
||||
assert.equal(report.batchId, 'batch-1');
|
||||
assert.equal(report.durationSec, 10);
|
||||
assert.equal(Object.isFrozen(report), true);
|
||||
assert.equal(Object.isFrozen(report.errors), true);
|
||||
assert.equal(Object.isFrozen(report.errors[0]), true);
|
||||
});
|
||||
|
||||
test('counts duplicate basenames as separate summary files without exposing local paths', () => {
|
||||
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
|
||||
const report = buildBatchCompletionReport({
|
||||
summary: {
|
||||
files: [
|
||||
{ name: 'C:\\private\\one\\same.mkv', size: 10, results: [{ jobId: 'one', hoster: 'voe.sx', status: 'done' }] },
|
||||
{ name: '/private/two/same.mkv', size: 10, results: [{ jobId: 'two', hoster: 'voe.sx', status: 'error', error: 'failed at C:\\private\\two\\same.mkv' }] }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(report.files.total, 2);
|
||||
assert.equal(report.files.fullySucceeded, 1);
|
||||
assert.equal(report.files.failed, 1);
|
||||
assert.equal(report.errors[0].fileName, 'same.mkv');
|
||||
assert.doesNotMatch(JSON.stringify(report), /private[\\/](?:one|two)/i);
|
||||
});
|
||||
|
||||
test('redacts configured secrets, opaque tokens, URLs and local paths from errors', () => {
|
||||
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
|
||||
const secret = 'private-api-value';
|
||||
const opaqueToken = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4';
|
||||
const report = buildBatchCompletionReport({
|
||||
secrets: [secret],
|
||||
summary: {
|
||||
files: [{
|
||||
name: 'secret.mkv',
|
||||
size: 1,
|
||||
results: [{
|
||||
jobId: 'error-secret',
|
||||
hoster: 'doodstream.com',
|
||||
status: 'error',
|
||||
error: `token=${secret} path=D:\\private\\secret.mkv /home/private/customer/file.mkv https://private.example.test/upload/${opaqueToken}`
|
||||
}]
|
||||
}]
|
||||
}
|
||||
});
|
||||
const serialized = JSON.stringify(report);
|
||||
|
||||
assert.doesNotMatch(serialized, new RegExp(secret));
|
||||
assert.doesNotMatch(serialized, /D:\\\\private/);
|
||||
assert.doesNotMatch(serialized, /\/home\/private/);
|
||||
assert.doesNotMatch(serialized, /private\.example\.test/);
|
||||
assert.doesNotMatch(serialized, new RegExp(opaqueToken));
|
||||
assert.match(report.errors[0].message, /<redacted>/);
|
||||
assert.match(report.errors[0].message, /<redacted-path>/);
|
||||
});
|
||||
|
||||
test('builds a formula-safe English error CSV and handles zero duration', () => {
|
||||
const { buildBatchCompletionReport, buildBatchErrorCsv } = require('../lib/batch-completion-report');
|
||||
const report = buildBatchCompletionReport({
|
||||
startedAt: '2026-08-16T10:00:00.000Z',
|
||||
completedAt: '2026-08-16T10:00:00.000Z',
|
||||
summary: {
|
||||
files: [{
|
||||
name: '=danger.csv',
|
||||
size: 8,
|
||||
results: [{ jobId: '+job', hoster: '@host', status: 'error', error: '-CMD()', attempt: 1, maxAttempts: 1 }]
|
||||
}]
|
||||
}
|
||||
});
|
||||
const csv = buildBatchErrorCsv(report);
|
||||
|
||||
assert.equal(report.transfer.averageBytesPerSecond, 0);
|
||||
assert.match(csv, /^Job ID,File name,Host,Status,Category,Attempt,Max attempts,Remote commit uncertain,Message\n/);
|
||||
assert.match(csv, /'\+job/);
|
||||
assert.match(csv, /'=danger\.csv/);
|
||||
assert.match(csv, /'@host/);
|
||||
assert.match(csv, /'-CMD\(\)/);
|
||||
const prefixed = buildBatchErrorCsv({ errors: [{ message: '\t=HYPERLINK("https://example.test")' }] });
|
||||
assert.match(prefixed, /'\t=HYPERLINK/);
|
||||
assert.equal(csv.endsWith('\n'), true);
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createBatchMutationGate } = require('../lib/batch-mutation-gate');
|
||||
|
||||
it('drains main-process batch mutations before source cleanup finalization', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const batchDoneStart = source.indexOf("uploadManager.on('batch-done'");
|
||||
const sealAndDrain = source.indexOf('batchMutationGate.sealAndDrain()', batchDoneStart);
|
||||
const cleanupFinish = source.indexOf('sourceCleanup.finishBatch', batchDoneStart);
|
||||
assert.ok(batchDoneStart >= 0);
|
||||
assert.ok(sealAndDrain > batchDoneStart);
|
||||
assert.ok(cleanupFinish > sealAndDrain);
|
||||
assert.match(source.slice(sealAndDrain, cleanupFinish + 300), /!hadActiveBatchMutation/);
|
||||
assert.match(source, /batchMutationGate\.acquire\(\)/);
|
||||
assert.match(source, /finally\s*{\s*batchMutationLease\.finish\(\)/);
|
||||
});
|
||||
|
||||
describe('batch mutation gate', () => {
|
||||
it('keeps a seal pending until every lease active at seal has finished', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const first = gate.acquire();
|
||||
const second = gate.acquire();
|
||||
|
||||
const drain = gate.sealAndDrain();
|
||||
let drained = false;
|
||||
drain.then(() => {
|
||||
drained = true;
|
||||
});
|
||||
|
||||
assert.equal(gate.acquire(), null);
|
||||
assert.equal(first.isOpen(), true);
|
||||
assert.equal(second.isOpen(), true);
|
||||
|
||||
first.finish();
|
||||
await Promise.resolve();
|
||||
assert.equal(drained, false);
|
||||
|
||||
second.finish();
|
||||
assert.equal(await drain, true);
|
||||
assert.equal(drained, true);
|
||||
});
|
||||
|
||||
it('reports no active mutation when sealing an idle gate', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
|
||||
assert.equal(await gate.sealAndDrain(), false);
|
||||
assert.equal(gate.acquire(), null);
|
||||
});
|
||||
|
||||
it('returns the same drain promise and preserves the first seal snapshot', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const lease = gate.acquire();
|
||||
|
||||
const firstDrain = gate.sealAndDrain();
|
||||
lease.finish();
|
||||
const secondDrain = gate.sealAndDrain();
|
||||
|
||||
assert.equal(secondDrain, firstDrain);
|
||||
assert.equal(await firstDrain, true);
|
||||
assert.equal(await gate.sealAndDrain(), true);
|
||||
});
|
||||
|
||||
it('makes lease completion idempotent without affecting other leases', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const first = gate.acquire();
|
||||
const second = gate.acquire();
|
||||
const drain = gate.sealAndDrain();
|
||||
|
||||
assert.equal(first.finish(), true);
|
||||
assert.equal(first.finish(), false);
|
||||
assert.equal(first.isOpen(), false);
|
||||
assert.equal(second.isOpen(), true);
|
||||
|
||||
let drained = false;
|
||||
drain.then(() => {
|
||||
drained = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
assert.equal(drained, false);
|
||||
|
||||
assert.equal(second.finish(), true);
|
||||
assert.equal(await drain, true);
|
||||
});
|
||||
|
||||
it('does not let a rejected caller block drain when the lease finishes in finally', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const lease = gate.acquire();
|
||||
const caller = (async () => {
|
||||
try {
|
||||
await Promise.reject(new Error('audit failed'));
|
||||
} finally {
|
||||
lease.finish();
|
||||
}
|
||||
})();
|
||||
const drain = gate.sealAndDrain();
|
||||
|
||||
await assert.rejects(caller, /audit failed/);
|
||||
assert.equal(await drain, true);
|
||||
assert.equal(lease.isOpen(), false);
|
||||
});
|
||||
|
||||
it('does not count a lease finished before sealing as active at seal', async () => {
|
||||
const gate = createBatchMutationGate();
|
||||
const lease = gate.acquire();
|
||||
|
||||
lease.finish();
|
||||
|
||||
assert.equal(await gate.sealAndDrain(), false);
|
||||
});
|
||||
});
|
||||
@@ -171,94 +171,6 @@ test('byse empty filecode WITHOUT explicit rejection still polls recovery', asyn
|
||||
assert.ok(listCalls >= 2, 'recovery polling must run when there is no explicit rejection');
|
||||
});
|
||||
|
||||
test('byse never recovers an old file after a failed baseline', async () => {
|
||||
stubByseUploadServer();
|
||||
const abort = new AbortController();
|
||||
const fileName = path.basename(tmpFile);
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, opts) => {
|
||||
if (/\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
if (listCalls === 1) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
body: { text: async () => '<html>baseline-token=SYNTHETIC_BYSE_BASELINE</html>' }
|
||||
};
|
||||
}
|
||||
abort.abort();
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, result: { files: [{ file_code: 'OLD_BYSE_123', title: fileName }] } }) }
|
||||
};
|
||||
}
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
|
||||
};
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_BYSE_BASELINE/);
|
||||
assert.equal(err.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(listCalls, 1);
|
||||
});
|
||||
|
||||
test('byse recovery rejects ambiguous same-title candidates', async () => {
|
||||
stubByseUploadServer();
|
||||
const abort = new AbortController();
|
||||
const fileName = path.basename(tmpFile);
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, opts) => {
|
||||
if (/\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
if (listCalls === 1) {
|
||||
return { statusCode: 200, headers: {}, body: { text: async () => '{"status":200,"result":{"files":[]}}' } };
|
||||
}
|
||||
abort.abort();
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: {
|
||||
text: async () => JSON.stringify({
|
||||
status: 200,
|
||||
result: {
|
||||
files: [
|
||||
{ file_code: 'PARALLEL_A', title: fileName },
|
||||
{ file_code: 'PARALLEL_B', title: fileName }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
|
||||
};
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('byse.sx', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
||||
(err) => err.hosterTransient === true
|
||||
);
|
||||
});
|
||||
|
||||
function stubBysePost(response) {
|
||||
requestRouter = async (url, opts) => {
|
||||
const u = String(url);
|
||||
|
||||
@@ -17,36 +17,10 @@ Module._load = function load(request, parent, isMain) {
|
||||
const ConfigStore = require('../lib/config-store');
|
||||
require('../lib/secret-store').encryptField('test-initialization');
|
||||
Module._load = originalLoad;
|
||||
const { createCollectors } = require('../lib/diagnostics-collectors');
|
||||
const { createAgent } = require('../lib/diagnostics-agent');
|
||||
const support = require('../lib/support-bundle');
|
||||
const stats = require('../lib/stats');
|
||||
|
||||
let tmpDir;
|
||||
let store;
|
||||
|
||||
function thrownBy(fn) {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
assert.fail('Expected function to throw');
|
||||
}
|
||||
|
||||
function createDiagnosticsAgent(configStore, logDir) {
|
||||
return createAgent(createCollectors({
|
||||
loadConfig: () => configStore.loadDiagnosticsConfig(),
|
||||
loadHistory: () => configStore.loadDiagnosticsHistory(),
|
||||
getAllLogPaths: () => ({ logDir }),
|
||||
support,
|
||||
stats,
|
||||
appInfo: () => ({}),
|
||||
systemInfo: () => ({}),
|
||||
agentInfo: () => ({})
|
||||
}));
|
||||
}
|
||||
|
||||
function createStore() {
|
||||
const fakeApp = {
|
||||
isPackaged: false,
|
||||
@@ -120,18 +94,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.globalSettings.scaleParallelUploads, false);
|
||||
assert.equal(config.globalSettings.lastBrowseDirectory, '');
|
||||
assert.equal(config.globalSettings.pendingQueue, null);
|
||||
assert.deepEqual(config.globalSettings.filenameFilter, {
|
||||
enabled: false,
|
||||
action: 'include',
|
||||
matchMode: 'all',
|
||||
conditions: []
|
||||
});
|
||||
assert.deepEqual(config.globalSettings.uploadSchedule, {
|
||||
enabled: false,
|
||||
weekdays: [1, 2, 3, 4, 5, 6, 0],
|
||||
start: '00:00',
|
||||
end: '23:59'
|
||||
});
|
||||
assert.deepEqual(config.history, []);
|
||||
});
|
||||
|
||||
@@ -299,39 +261,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(history[104].id, 'batch-104');
|
||||
});
|
||||
|
||||
it('durably syncs migrated history before replacing the live file', async () => {
|
||||
store._historyMigrated = true;
|
||||
fs.writeFileSync(store.historyPath, '[]', 'utf-8');
|
||||
const originalOpen = fs.promises.open;
|
||||
const originalRename = fs.promises.rename;
|
||||
let synced = false;
|
||||
let renamed = false;
|
||||
fs.promises.open = async (...args) => {
|
||||
const handle = await originalOpen(...args);
|
||||
const originalSync = handle.sync.bind(handle);
|
||||
handle.sync = async () => {
|
||||
await originalSync();
|
||||
synced = true;
|
||||
};
|
||||
return handle;
|
||||
};
|
||||
fs.promises.rename = async (...args) => {
|
||||
assert.equal(synced, true);
|
||||
renamed = true;
|
||||
return originalRename(...args);
|
||||
};
|
||||
|
||||
try {
|
||||
await store.appendHistory({ id: 'durable', files: [] });
|
||||
} finally {
|
||||
fs.promises.open = originalOpen;
|
||||
fs.promises.rename = originalRename;
|
||||
}
|
||||
|
||||
assert.equal(renamed, true);
|
||||
assert.deepEqual(store.loadHistory().map(entry => entry.id), ['durable']);
|
||||
});
|
||||
|
||||
it('clearHistory empties the array', async () => {
|
||||
await store.appendHistory({ id: 'test', files: [] });
|
||||
assert.equal(store.loadHistory().length, 1);
|
||||
@@ -389,36 +318,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.globalSettings.logFilePath, '');
|
||||
});
|
||||
|
||||
it('normalizes upload schedules across load and save boundaries', async () => {
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||
globalSettings: {
|
||||
uploadSchedule: { enabled: true, weekdays: [0, 1, 1, 9], start: ' 22:00 ', end: '06:00' }
|
||||
}
|
||||
}), 'utf-8');
|
||||
|
||||
assert.deepEqual(store.load().globalSettings.uploadSchedule, {
|
||||
enabled: true,
|
||||
weekdays: [1, 0],
|
||||
start: '22:00',
|
||||
end: '06:00'
|
||||
});
|
||||
|
||||
const current = store.load();
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
...current.globalSettings,
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '08:00' }
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(store.load().globalSettings.uploadSchedule, {
|
||||
enabled: true,
|
||||
weekdays: [],
|
||||
start: '08:00',
|
||||
end: '08:00'
|
||||
});
|
||||
});
|
||||
|
||||
it('concurrent saves preserve both sections', async () => {
|
||||
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
|
||||
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
|
||||
@@ -614,51 +513,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(store.load().globalSettings.lastBrowseDirectory, selectedDirectory);
|
||||
});
|
||||
|
||||
it('merges the fallback log path after earlier queued settings without reverting them', async () => {
|
||||
assert.equal(typeof store.saveFallbackLogPath, 'function');
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
alwaysOnTop: false,
|
||||
webhookUrl: 'https://before.invalid',
|
||||
logFilePath: ''
|
||||
}
|
||||
});
|
||||
|
||||
const originalAtomicWrite = store._atomicWrite.bind(store);
|
||||
let releaseSettingsWrite;
|
||||
let signalSettingsWriteStarted;
|
||||
const settingsWriteStarted = new Promise(resolve => { signalSettingsWriteStarted = resolve; });
|
||||
store._atomicWrite = (data) => {
|
||||
const settings = JSON.parse(data).globalSettings;
|
||||
if (!releaseSettingsWrite && settings.webhookUrl === 'https://concurrent.invalid') {
|
||||
signalSettingsWriteStarted();
|
||||
return new Promise((resolve, reject) => {
|
||||
releaseSettingsWrite = () => originalAtomicWrite(data).then(resolve, reject);
|
||||
});
|
||||
}
|
||||
return originalAtomicWrite(data);
|
||||
};
|
||||
|
||||
const current = store.load();
|
||||
const settingsSave = store.save({
|
||||
globalSettings: {
|
||||
...current.globalSettings,
|
||||
alwaysOnTop: true,
|
||||
webhookUrl: 'https://concurrent.invalid'
|
||||
}
|
||||
});
|
||||
await settingsWriteStarted;
|
||||
const fallbackPath = path.join(tmpDir, 'fallback', 'fileuploader.log');
|
||||
const fallbackSave = store.saveFallbackLogPath(fallbackPath);
|
||||
releaseSettingsWrite();
|
||||
await Promise.all([settingsSave, fallbackSave]);
|
||||
|
||||
const saved = store.load().globalSettings;
|
||||
assert.equal(saved.alwaysOnTop, true);
|
||||
assert.equal(saved.webhookUrl, 'https://concurrent.invalid');
|
||||
assert.equal(saved.logFilePath, fallbackPath);
|
||||
});
|
||||
|
||||
it('merges remote settings in the write queue and returns the canonical token', async () => {
|
||||
await store.save({
|
||||
globalSettings: {
|
||||
@@ -734,68 +588,6 @@ describe('ConfigStore', () => {
|
||||
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
|
||||
});
|
||||
|
||||
it('strict diagnostics config loading rejects primary failures while normal loading keeps recovery', () => {
|
||||
assert.equal(typeof store.loadDiagnosticsConfig, 'function');
|
||||
|
||||
fs.writeFileSync(store.filePath + '.bak', JSON.stringify({
|
||||
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
|
||||
hosterSettings: {},
|
||||
globalSettings: {},
|
||||
history: []
|
||||
}), 'utf-8');
|
||||
fs.writeFileSync(store.filePath, '{broken-config', 'utf-8');
|
||||
|
||||
assert.equal(store.load().hosters['doodstream.com'][0].apiKey, 'from-backup');
|
||||
assert.throws(() => store.loadDiagnosticsConfig());
|
||||
|
||||
fs.rmSync(store.filePath);
|
||||
assert.equal(store.load().globalSettings.language, 'en');
|
||||
assert.throws(() => store.loadDiagnosticsConfig());
|
||||
});
|
||||
|
||||
it('strict diagnostics config errors never expose malformed JSON or file paths', () => {
|
||||
const opaque = 'opaque-config-42';
|
||||
fs.writeFileSync(store.filePath, opaque, 'utf-8');
|
||||
|
||||
let error = thrownBy(() => store.loadDiagnosticsConfig());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_INVALID');
|
||||
assert.equal(error.message, 'Die Diagnosekonfiguration ist ungültig');
|
||||
assert.ok(!error.message.includes(opaque));
|
||||
|
||||
fs.writeFileSync(store.filePath, '{}', 'utf-8');
|
||||
error = thrownBy(() => store.loadDiagnosticsConfig());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_INVALID');
|
||||
assert.equal(error.message, 'Die Diagnosekonfiguration ist ungültig');
|
||||
|
||||
fs.rmSync(store.filePath);
|
||||
error = thrownBy(() => store.loadDiagnosticsConfig());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_CONFIG_READ_FAILED');
|
||||
assert.equal(error.message, 'Die Diagnosekonfiguration konnte nicht gelesen werden');
|
||||
assert.ok(!error.message.includes(store.filePath));
|
||||
});
|
||||
|
||||
it('strict diagnostics config loading decrypts current credentials and propagates decryption failures', () => {
|
||||
assert.equal(typeof store.loadDiagnosticsConfig, 'function');
|
||||
|
||||
const encrypted = `enc:v1:${Buffer.from('test-protected:diagnostic-secret').toString('base64')}`;
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||
hosters: { 'doodstream.com': [{ id: 'diag-1', authType: 'api', apiKey: encrypted }] },
|
||||
hosterSettings: {},
|
||||
globalSettings: {},
|
||||
history: []
|
||||
}), 'utf-8');
|
||||
|
||||
assert.equal(store.loadDiagnosticsConfig().hosters['doodstream.com'][0].apiKey, 'diagnostic-secret');
|
||||
|
||||
const originalDecryptString = safeStorage.decryptString;
|
||||
safeStorage.decryptString = () => { throw new Error('diagnostic decrypt failure'); };
|
||||
try {
|
||||
assert.throws(() => store.loadDiagnosticsConfig(), /Gespeicherte Zugangsdaten konnten nicht entschlüsselt werden/);
|
||||
} finally {
|
||||
safeStorage.decryptString = originalDecryptString;
|
||||
}
|
||||
});
|
||||
|
||||
it('wipe-guard: a settings-only save recovers accounts from .bak when the live config validly has none', async () => {
|
||||
// Post-wipe state: live config parses fine but has empty hosters; a backup still holds the accounts.
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, hosterSettings: {}, globalSettings: {}, history: [] }), 'utf-8');
|
||||
@@ -860,65 +652,6 @@ describe('ConfigStore history split (electron-history.json)', () => {
|
||||
assert.equal(s.loadHistory().length, 30);
|
||||
});
|
||||
|
||||
it('strict diagnostics history accepts valid empty history and rejects failed or corrupt dedicated reads', () => {
|
||||
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
||||
writeConfigWithHistory(7);
|
||||
s._migrateHistory();
|
||||
|
||||
fs.writeFileSync(s.historyPath, '[]', 'utf-8');
|
||||
assert.deepEqual(s.loadDiagnosticsHistory(), []);
|
||||
|
||||
for (const invalidHistory of ['null', '{}', '{"history":null}', '{broken-history']) {
|
||||
fs.writeFileSync(s.historyPath, invalidHistory, 'utf-8');
|
||||
const error = thrownBy(() => s.loadDiagnosticsHistory());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_HISTORY_INVALID');
|
||||
assert.equal(error.message, 'Die Diagnoseverlaufsdatei ist ungültig');
|
||||
assert.ok(!error.message.includes(invalidHistory));
|
||||
}
|
||||
|
||||
fs.rmSync(s.historyPath);
|
||||
fs.mkdirSync(s.historyPath);
|
||||
const error = thrownBy(() => s.loadDiagnosticsHistory());
|
||||
assert.equal(error.code, 'DIAGNOSTIC_HISTORY_READ_FAILED');
|
||||
assert.equal(error.message, 'Die Diagnoseverlaufsdatei konnte nicht gelesen werden');
|
||||
assert.ok(!error.message.includes(s.historyPath));
|
||||
});
|
||||
|
||||
it('real diagnostics agent never returns opaque corrupt dedicated history content', () => {
|
||||
const opaque = 'opaque42';
|
||||
writeConfigWithHistory(0);
|
||||
fs.writeFileSync(s.historyPath, opaque, 'utf-8');
|
||||
const agent = createDiagnosticsAgent(s, dir);
|
||||
|
||||
for (const operation of ['get_history', 'list_errors', 'server_health']) {
|
||||
const response = agent.handle(operation, {});
|
||||
assert.deepEqual(response, { ok: false, error: 'Die Diagnoseverlaufsdatei ist ungültig' });
|
||||
assert.ok(!JSON.stringify(response).includes(opaque));
|
||||
}
|
||||
});
|
||||
|
||||
it('strict diagnostics history never falls back to stale config history when a dedicated file exists', () => {
|
||||
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
||||
writeConfigWithHistory(7);
|
||||
fs.writeFileSync(s.historyPath, 'null', 'utf-8');
|
||||
|
||||
assert.equal(s._historyMigrated, false);
|
||||
assert.equal(s.loadHistory().length, 7);
|
||||
assert.throws(() => s.loadDiagnosticsHistory());
|
||||
});
|
||||
|
||||
it('strict diagnostics history preserves the valid pre-migration history path', () => {
|
||||
assert.equal(typeof s.loadDiagnosticsHistory, 'function');
|
||||
writeConfigWithHistory(7);
|
||||
|
||||
assert.equal(s.loadDiagnosticsHistory().length, 7);
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(s.filePath, 'utf-8'));
|
||||
config.history = null;
|
||||
fs.writeFileSync(s.filePath, JSON.stringify(config), 'utf-8');
|
||||
assert.throws(() => s.loadDiagnosticsHistory());
|
||||
});
|
||||
|
||||
it('appendHistory writes to history.json; the next config write strips stale history from the config file', async () => {
|
||||
writeConfigWithHistory(10);
|
||||
s._migrateHistory();
|
||||
|
||||
+5
-118
@@ -1,124 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRestartController, createWatchedPaths, formatChangeMessage } = require('../scripts/dev-runner.cjs');
|
||||
|
||||
function createDeferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
test('dev runner cannot let an old child exit clear the current Electron process', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'scripts', 'dev-runner.cjs'), 'utf8');
|
||||
|
||||
function createHarness(stopChild) {
|
||||
const started = [];
|
||||
const stopped = [];
|
||||
const unexpectedExits = [];
|
||||
let nextPid = 1;
|
||||
const controller = createRestartController({
|
||||
startChild() {
|
||||
const child = new EventEmitter();
|
||||
child.pid = nextPid;
|
||||
nextPid += 1;
|
||||
started.push(child);
|
||||
return child;
|
||||
},
|
||||
stopChild(child) {
|
||||
stopped.push(child);
|
||||
return stopChild(child);
|
||||
},
|
||||
onUnexpectedExit(code, signal) {
|
||||
unexpectedExits.push({ code, signal });
|
||||
}
|
||||
});
|
||||
return { controller, started, stopped, unexpectedExits };
|
||||
}
|
||||
|
||||
test('three changes during an open kill produce exactly one replacement Electron tree', async () => {
|
||||
const kill = createDeferred();
|
||||
const harness = createHarness(() => kill.promise);
|
||||
const original = harness.controller.start();
|
||||
const firstRestart = harness.controller.restart();
|
||||
|
||||
const pendingChanges = [
|
||||
harness.controller.restart(),
|
||||
harness.controller.restart(),
|
||||
harness.controller.restart()
|
||||
];
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.strictEqual(harness.stopped[0], original);
|
||||
assert.strictEqual(harness.controller.start(), original);
|
||||
assert.equal(harness.started.length, 1);
|
||||
|
||||
kill.resolve();
|
||||
await Promise.all([firstRestart, ...pendingChanges]);
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.equal(harness.started.length, 2);
|
||||
assert.notStrictEqual(harness.started[1], original);
|
||||
});
|
||||
|
||||
test('a failed kill cannot start Electron over the still-running tree', async () => {
|
||||
const harness = createHarness(async () => {
|
||||
throw new Error('taskkill failed');
|
||||
});
|
||||
const original = harness.controller.start();
|
||||
|
||||
await assert.rejects(harness.controller.restart(), /taskkill failed/u);
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.equal(harness.started.length, 1);
|
||||
assert.strictEqual(harness.controller.start(), original);
|
||||
});
|
||||
|
||||
test('shutdown during an open restart kill prevents its completion from starting Electron', async () => {
|
||||
const kill = createDeferred();
|
||||
const harness = createHarness(() => kill.promise);
|
||||
harness.controller.start();
|
||||
const restart = harness.controller.restart();
|
||||
const shutdown = harness.controller.shutdown();
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.equal(harness.started.length, 1);
|
||||
|
||||
kill.resolve();
|
||||
await Promise.all([restart, shutdown]);
|
||||
|
||||
assert.equal(harness.stopped.length, 1);
|
||||
assert.equal(harness.started.length, 1);
|
||||
assert.equal(harness.controller.start(), null);
|
||||
});
|
||||
|
||||
test('an old child exit cannot clear the replacement Electron child', async () => {
|
||||
const harness = createHarness(async () => {});
|
||||
const original = harness.controller.start();
|
||||
|
||||
await harness.controller.restart();
|
||||
const replacement = harness.started[1];
|
||||
original.emit('exit', 0, 'SIGTERM');
|
||||
|
||||
assert.equal(harness.started.length, 2);
|
||||
assert.strictEqual(harness.controller.start(), replacement);
|
||||
assert.deepEqual(harness.unexpectedExits, []);
|
||||
});
|
||||
|
||||
test('watch paths still cover main, preloads, lib and renderer', () => {
|
||||
const projectRoot = path.resolve('C:\\project');
|
||||
|
||||
assert.deepEqual([...createWatchedPaths(projectRoot)], [
|
||||
path.join(projectRoot, 'main.js'),
|
||||
path.join(projectRoot, 'preload.js'),
|
||||
path.join(projectRoot, 'preload-drop-target.js'),
|
||||
path.join(projectRoot, 'lib'),
|
||||
path.join(projectRoot, 'renderer')
|
||||
]);
|
||||
});
|
||||
|
||||
test('change log identifies every watched file without calling it a renderer change', () => {
|
||||
assert.equal(formatChangeMessage('C:\\project\\lib\\hosters.js'), '[hotdev] change detected: C:\\project\\lib\\hosters.js\n');
|
||||
assert.match(source, /const startedChild = spawn\(electron/u);
|
||||
assert.match(source, /if \(child !== startedChild\) return;\s*child = null;/u);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createAgent } = require('../lib/diagnostics-agent');
|
||||
const { valueScrub } = require('../lib/support-bundle');
|
||||
|
||||
function stubCollectors() {
|
||||
const calls = [];
|
||||
@@ -31,9 +28,6 @@ test('agent rejects unknown ops and any write/exec-shaped op', () => {
|
||||
assert.equal(r.ok, false, `${bad} must be rejected`);
|
||||
assert.match(r.error, /unknown or non-readonly/);
|
||||
}
|
||||
const pathShaped = agent.handle(['C:', 'Users', 'PrivateProfile', 'operation'].join('\\'), {});
|
||||
assert.ok(!pathShaped.error.includes('PrivateProfile'));
|
||||
assert.match(pathShaped.error, /<redacted-path>/);
|
||||
});
|
||||
|
||||
test('agent rejects inherited Object.prototype members (no whitelist bypass via the prototype chain)', () => {
|
||||
@@ -59,54 +53,10 @@ test('agent maps each whitelisted op to its collector and is read-only only', ()
|
||||
for (const op of agent.ops) assert.ok(!/write|delete|set_|exec|restart|cancel|retry/.test(op), `${op} must be read-only`);
|
||||
});
|
||||
|
||||
test('agent redacts collector failures and thrown errors at the response boundary', () => {
|
||||
const drivePath = ['C:', 'Users', 'PrivateProfile', 'secret.log'].join('\\');
|
||||
const uncPath = '\\\\?\\UNC\\private-server\\secret-share\\secret.log';
|
||||
const agent = createAgent({
|
||||
readLog: () => ({ ok: false, error: `cannot read ${drivePath}` }),
|
||||
getSystemInfo: () => { throw new Error(`boom at ${uncPath}`); }
|
||||
});
|
||||
test('agent surfaces a collector ok:false verbatim and never throws', () => {
|
||||
const agent = createAgent({ readLog: () => ({ ok: false, error: 'unknown or non-readable log: x' }), getSystemInfo: () => { throw new Error('boom'); } });
|
||||
assert.equal(agent.handle('read_log', { name: 'x' }).ok, false);
|
||||
assert.ok(!JSON.stringify(agent.handle('read_log', { name: 'x' })).includes('PrivateProfile'));
|
||||
const thrown = agent.handle('get_system_info', {});
|
||||
assert.equal(thrown.ok, false);
|
||||
assert.match(thrown.error, /boom/);
|
||||
assert.ok(!thrown.error.includes('private-server'));
|
||||
assert.match(thrown.error, /<redacted-path>/);
|
||||
});
|
||||
|
||||
test('agent redacts every successful response with configured secrets at the boundary', () => {
|
||||
const secret = 'configured-secret-123';
|
||||
const slashUnc = '//private-server/secret-share/secret.log';
|
||||
const collectors = stubCollectors();
|
||||
collectors.getSystemInfo = () => ({ nested: { message: `token ${secret}`, path: slashUnc } });
|
||||
collectors.redactResponse = value => valueScrub(value, [secret]);
|
||||
const result = createAgent(collectors).handle('get_system_info', {});
|
||||
const json = JSON.stringify(result);
|
||||
assert.equal(result.ok, true);
|
||||
assert.ok(!json.includes(secret));
|
||||
assert.ok(!json.includes('private-server'));
|
||||
assert.match(json, /<redacted>/);
|
||||
assert.match(json, /<redacted-path>/);
|
||||
});
|
||||
|
||||
test('agent fails closed when response redaction fails', () => {
|
||||
const agent = createAgent({
|
||||
getSystemInfo: () => ({ token: 'must-not-leak' }),
|
||||
redactResponse: () => { throw new Error('redactor unavailable'); }
|
||||
});
|
||||
const result = agent.handle('get_system_info', {});
|
||||
assert.deepEqual(result, { ok: false, error: 'diagnostic response could not be safely returned' });
|
||||
assert.ok(!JSON.stringify(result).includes('must-not-leak'));
|
||||
});
|
||||
|
||||
test('main process keeps diagnostics local and fails closed at its final reply boundary', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
assert.match(source, /function _diagBindHost\(\)\s*{\s*return '127\.0\.0\.1'/);
|
||||
assert.match(source, /function _diagPublicHost\(\)\s*{\s*return '127\.0\.0\.1'/);
|
||||
assert.match(source, /function _diagAllowlist\(\)\s*{\s*return \[\]/);
|
||||
assert.match(source, /bindMode: 'local'/);
|
||||
assert.match(source, /catch\s*{\s*result = { ok: false, error: 'diagnostic response could not be safely returned' }/);
|
||||
assert.match(source, /loadConfig:\s*\(\)\s*=>\s*configStore\.loadDiagnosticsConfig\(\)/);
|
||||
assert.match(source, /loadHistory:\s*\(\)\s*=>\s*configStore\.loadDiagnosticsHistory\(\)/);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ function makeFixture() {
|
||||
crashLog: path.join(dir, 'crash.log'),
|
||||
logDir: dir
|
||||
};
|
||||
fs.writeFileSync(paths.debug, `boot ok\nsource ${path.join(dir, 'private-source.mkv')}\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
|
||||
fs.writeFileSync(paths.debug, `boot ok\nuploading file with token ${fixtureAlpha} inline\nAuthorization: Bearer ${fixtureBeta}\n`);
|
||||
fs.writeFileSync(paths.uploadAudit, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
|
||||
fs.writeFileSync(paths.doodstreamDebug, `api_key=${fixtureGamma} sess=abc\n`);
|
||||
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
||||
@@ -87,134 +87,8 @@ test('getHistory falls back to loadConfig().history when loadHistory is absent (
|
||||
assert.equal(c.getHistory({ limit: 10 }).totalBatches, 1, 'legacy path reads load().history when loadHistory not injected');
|
||||
});
|
||||
|
||||
test('getHistory, listErrors and serverHealth share loadHistory after migration', () => {
|
||||
let historyRevision = 0;
|
||||
const c = createCollectors({
|
||||
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
|
||||
loadHistory: () => {
|
||||
historyRevision++;
|
||||
const timestamp = `2026-01-${String(historyRevision).padStart(2, '0')}T00:00:00.000Z`;
|
||||
return [{ timestamp, files: [{ name: `failed-${historyRevision}.mkv`, results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }] }] }];
|
||||
},
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
const health = c.serverHealth({ errorLimit: 5 });
|
||||
const history = c.getHistory({ limit: 5 });
|
||||
const errors = c.listErrors({ limit: 5 });
|
||||
assert.deepEqual({
|
||||
historyBatches: history.totalBatches,
|
||||
listedErrors: errors.total,
|
||||
healthBatches: health.recentBatches.length,
|
||||
healthErrors: health.errors.total
|
||||
}, {
|
||||
historyBatches: 1,
|
||||
listedErrors: 1,
|
||||
healthBatches: 1,
|
||||
healthErrors: 1
|
||||
});
|
||||
assert.equal(health.recentBatches[0].timestamp, health.errors.errors[0].ts, 'serverHealth must summarize one history snapshot');
|
||||
});
|
||||
|
||||
test('getHistory, listErrors and serverHealth leave the supplied history snapshot unchanged', () => {
|
||||
const original = [
|
||||
{ timestamp: '2026-01-02T00:00:00.000Z', files: [{ name: 'newer.mkv', results: [{ hoster: 'voe.sx', status: 'done' }] }] },
|
||||
{ timestamp: '2026-01-01T00:00:00.000Z', files: [{ name: 'older.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }] }] }
|
||||
];
|
||||
const sortingStats = {
|
||||
...stats,
|
||||
summarizePerHoster: history => {
|
||||
history.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||
return stats.summarizePerHoster(history);
|
||||
}
|
||||
};
|
||||
for (const [name, invoke] of [
|
||||
['listErrors', c => c.listErrors({})],
|
||||
['getHistory', c => c.getHistory({ includeFiles: true })],
|
||||
['serverHealth', c => c.serverHealth({})]
|
||||
]) {
|
||||
const snapshot = JSON.parse(JSON.stringify(original));
|
||||
const c = createCollectors({
|
||||
loadConfig: () => ({ hosters: {}, globalSettings: {}, history: [] }),
|
||||
loadHistory: () => snapshot,
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats: sortingStats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
invoke(c);
|
||||
assert.deepEqual(snapshot, original, `${name} must not mutate the supplied history snapshot`);
|
||||
}
|
||||
});
|
||||
|
||||
test('dedicated history reader failures never report healthy empty history or use stale config history', () => {
|
||||
const sensitive = 'reader-private-value-38152';
|
||||
const config = {
|
||||
hosters: { 'voe.sx': [{ apiKey: sensitive }] },
|
||||
globalSettings: {},
|
||||
history: [{ timestamp: '2025-12-31T00:00:00.000Z', files: [{ name: 'stale.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'stale error' }] }] }]
|
||||
};
|
||||
for (const [scenario, loadHistory] of [
|
||||
['throwing reader', () => { throw new Error(`history reader failed with ${sensitive}`); }],
|
||||
['invalid reader result', () => ({ history: [] })]
|
||||
]) {
|
||||
const c = createCollectors({
|
||||
loadConfig: () => JSON.parse(JSON.stringify(config)),
|
||||
loadHistory,
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
for (const [name, invoke] of [
|
||||
['getHistory', () => c.getHistory({ includeFiles: true })],
|
||||
['listErrors', () => c.listErrors({})],
|
||||
['serverHealth', () => c.serverHealth({})]
|
||||
]) {
|
||||
assert.throws(invoke, /history/i, `${name} must reject a ${scenario}`);
|
||||
}
|
||||
const agent = createAgent(c);
|
||||
for (const op of ['get_history', 'list_errors', 'server_health']) {
|
||||
const response = agent.handle(op, { includeFiles: true });
|
||||
const json = JSON.stringify(response);
|
||||
assert.equal(response.ok, false, `${op} must report the ${scenario} as unhealthy`);
|
||||
assert.match(response.error, /history/i);
|
||||
assert.ok(!json.includes(sensitive));
|
||||
assert.ok(!json.includes('stale.mkv'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('history operations fail closed when configured secrets cannot be loaded', () => {
|
||||
const sensitive = 'history-private-value-27491';
|
||||
let historyReads = 0;
|
||||
const c = createCollectors({
|
||||
loadConfig: () => { throw new Error(`secret decryption failed near ${sensitive}`); },
|
||||
loadHistory: () => {
|
||||
historyReads++;
|
||||
return [{ timestamp: '2026-01-04T00:00:00.000Z', files: [{ name: `${sensitive}.mkv`, results: [{ hoster: 'voe.sx', status: 'error', error: `opaque ${sensitive}` }] }] }];
|
||||
},
|
||||
getAllLogPaths: () => ({ logDir: os.tmpdir() }),
|
||||
support, stats,
|
||||
appInfo: () => ({}), systemInfo: () => ({}), agentInfo: () => ({})
|
||||
});
|
||||
for (const [name, invoke] of [
|
||||
['getHistory', () => c.getHistory({ includeFiles: true })],
|
||||
['listErrors', () => c.listErrors({})],
|
||||
['serverHealth', () => c.serverHealth({})]
|
||||
]) {
|
||||
assert.throws(invoke, /secret decryption failed/, `${name} must fail without the configured redaction secrets`);
|
||||
}
|
||||
assert.equal(historyReads, 0, 'history must not be read before the redaction secrets are available');
|
||||
const agent = createAgent(c);
|
||||
for (const op of ['get_history', 'list_errors', 'server_health']) {
|
||||
const response = agent.handle(op, { includeFiles: true });
|
||||
assert.deepEqual(response, { ok: false, error: 'diagnostic response could not be safely returned' });
|
||||
assert.ok(!JSON.stringify(response).includes(sensitive));
|
||||
}
|
||||
});
|
||||
|
||||
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
|
||||
const { collectors, dir, paths } = makeFixture();
|
||||
const { collectors } = makeFixture();
|
||||
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
|
||||
const audit = collectors.readLog({ name: 'uploadAudit', tailKb: 64 });
|
||||
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
|
||||
@@ -223,10 +97,6 @@ test('readLog redacts a planted token and a Bearer line; doodstream is NOT reada
|
||||
assert.ok(!audit.content.includes('SECRETTOKEN123456'), 'source cleanup audit is readable only through the redacted diagnostics path');
|
||||
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
|
||||
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
|
||||
assert.ok(!JSON.stringify(dbg).includes(dir), 'read log content and metadata must not expose its absolute directory');
|
||||
const rejectedAbsolutePath = collectors.readLog({ name: paths.debug });
|
||||
assert.ok(!rejectedAbsolutePath.error.includes(paths.debug), 'rejected log identifiers must not be echoed as absolute paths');
|
||||
assert.ok(!rejectedAbsolutePath.error.includes(path.basename(paths.debug)), 'rejected log identifiers must not echo path components');
|
||||
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
|
||||
});
|
||||
|
||||
@@ -234,21 +104,11 @@ test('rotated audit backups are listed and readable with the rotation naming con
|
||||
const { collectors, paths, fixtureAlpha } = makeFixture();
|
||||
const backupPath = path.join(path.dirname(paths.uploadAudit), 'upload-audit.1.log');
|
||||
fs.writeFileSync(backupPath, `# SOURCE-CLEANUP {"token":"${fixtureAlpha}"}\n`);
|
||||
const logList = collectors.listLogs();
|
||||
const listed = logList.files.find(file => file.name === 'uploadAudit');
|
||||
assert.equal(logList.dir, undefined);
|
||||
assert.equal(listed.id, 'uploadAudit');
|
||||
assert.equal(listed.fileName, 'upload-audit.log');
|
||||
assert.equal(listed.path, undefined);
|
||||
const listed = collectors.listLogs().files.find(file => file.name === 'uploadAudit');
|
||||
assert.ok(listed.variants.some(variant => variant.backup === 1));
|
||||
assert.ok(listed.variants.every(variant => variant.fileName && !Object.hasOwn(variant, 'path')));
|
||||
assert.ok(!collectors.listLogs().otherLogs.some(file => file.name === 'upload-audit.1.log'));
|
||||
const backup = collectors.readLog({ name: 'uploadAudit', backup: 1, tailKb: 64 });
|
||||
assert.equal(backup.id, 'uploadAudit');
|
||||
assert.equal(backup.name, 'uploadAudit');
|
||||
assert.equal(backup.fileName, 'upload-audit.1.log');
|
||||
assert.equal(backup.path, undefined);
|
||||
assert.ok(!JSON.stringify({ logList, backup }).includes(path.dirname(paths.uploadAudit)));
|
||||
assert.equal(backup.path, backupPath);
|
||||
assert.ok(!backup.content.includes(fixtureAlpha));
|
||||
});
|
||||
|
||||
@@ -311,26 +171,9 @@ test('listErrors classifies via stats.classifyErrorCategory and redacts error te
|
||||
});
|
||||
|
||||
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
|
||||
const { collectors, dir, paths } = makeFixture();
|
||||
const { collectors } = makeFixture();
|
||||
const h = collectors.serverHealth({});
|
||||
const json = JSON.stringify(h);
|
||||
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
|
||||
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
|
||||
assert.ok(!json.includes(dir) && !json.includes(paths.debug), 'server_health must not expose absolute log paths');
|
||||
});
|
||||
|
||||
test('redactResponse scrubs configured secrets and absolute paths from arbitrary nested output', () => {
|
||||
const { collectors, fixtureAlpha } = makeFixture();
|
||||
const privatePath = ['C:', 'Users', 'PrivateProfile', 'secret.log'].join('\\');
|
||||
const value = {
|
||||
error: `token ${fixtureAlpha} at ${privatePath}`,
|
||||
nested: [{ source: '\\\\?\\UNC\\private-server\\secret-share\\secret.log' }]
|
||||
};
|
||||
const out = collectors.redactResponse(value);
|
||||
const json = JSON.stringify(out);
|
||||
assert.ok(!json.includes(fixtureAlpha));
|
||||
assert.ok(!json.includes('PrivateProfile'));
|
||||
assert.ok(!json.includes('private-server'));
|
||||
assert.match(json, /<redacted>/);
|
||||
assert.match(json, /<redacted-path>/);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const os = require('os');
|
||||
const WebSocket = require('ws');
|
||||
const RemoteServer = require('../lib/remote-server');
|
||||
|
||||
const TOKEN = 'a'.repeat(64);
|
||||
|
||||
function firstLanIpv4() {
|
||||
for (const entry of Object.values(os.networkInterfaces())) {
|
||||
for (const net of (entry || [])) {
|
||||
if (net && net.family === 'IPv4' && !net.internal && net.address) return net.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function startAgent(onDiagnosticRequest, extra) {
|
||||
const srv = new RemoteServer();
|
||||
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
|
||||
@@ -78,11 +88,19 @@ test('a loopback diagnostic client connects even with a non-matching allowlist (
|
||||
ws.close(); agent.stop();
|
||||
});
|
||||
|
||||
test('diagnostic server rejects wildcard and non-loopback binds before opening a listener', async () => {
|
||||
for (const host of ['0.0.0.0', '::', '192.0.2.10']) {
|
||||
const server = new RemoteServer();
|
||||
await assert.rejects(server.start({ port: 0, host, token: TOKEN, diagnosticMode: true }), /requires a loopback host/);
|
||||
assert.equal(server._wss, null);
|
||||
test('network bind (0.0.0.0): an allowlisted non-loopback peer connects over a real socket (the Tailscale path)', async (t) => {
|
||||
const lan = firstLanIpv4();
|
||||
if (!lan) { t.skip('no non-internal IPv4 interface available'); return; }
|
||||
const agent = await startAgent(() => {}, { host: '0.0.0.0', allowlist: [lan] });
|
||||
const port = agent.getPort();
|
||||
const ws = new WebSocket(`ws://${lan}:${port}`);
|
||||
try {
|
||||
await new Promise((resolve, reject) => { ws.on('open', resolve); ws.on('error', reject); });
|
||||
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
|
||||
const ok = await once(ws, 'auth-ok');
|
||||
assert.ok(ok.clientId, 'allowlisted LAN peer authed over the 0.0.0.0 bind');
|
||||
} finally {
|
||||
ws.close(); agent.stop();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function routeWith(uploadBody, listBodies = []) {
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return { statusCode: uploadBody.status, headers: { 'content-type': uploadBody.contentType || 'application/json' }, body: { text: async () => uploadBody.body } };
|
||||
return { statusCode: uploadBody.status, headers: { 'content-type': 'application/json' }, body: { text: async () => uploadBody.body } };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,95 +103,3 @@ test('doodstream API upload: codeless + file never appears → throws hosterTran
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('doodstream API upload never recovers an old file after a failed baseline', async () => {
|
||||
stubUploadServer();
|
||||
const abort = new AbortController();
|
||||
const fileName = path.basename(tmpFile);
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, opts) => {
|
||||
if (/\/api\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
if (listCalls === 1) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
body: { text: async () => '<html>baseline-token=SYNTHETIC_BASELINE_SECRET</html>' }
|
||||
};
|
||||
}
|
||||
abort.abort();
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, result: { files: [{ file_code: 'OLD_DOOD_123', title: fileName }] } }) }
|
||||
};
|
||||
}
|
||||
if (opts && opts.body && typeof opts.body[Symbol.asyncIterator] === 'function') {
|
||||
for await (const chunk of opts.body) { if (chunk && chunk.length === -1) break; }
|
||||
}
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => JSON.stringify({ status: 200, msg: 'OK' }) }
|
||||
};
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, abort.signal, null),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_BASELINE_SECRET/);
|
||||
assert.equal(err.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(listCalls, 1);
|
||||
});
|
||||
|
||||
test('doodstream API recovery rejects ambiguous same-title candidates', async () => {
|
||||
stubUploadServer();
|
||||
const fileName = path.basename(tmpFile);
|
||||
requestRouter = routeWith(
|
||||
{ status: 200, body: JSON.stringify({ status: 200, msg: 'OK' }) },
|
||||
[
|
||||
'{"status":200,"result":{"files":[]}}',
|
||||
JSON.stringify({
|
||||
status: 200,
|
||||
result: {
|
||||
files: [
|
||||
{ file_code: 'PARALLEL_A', title: fileName },
|
||||
{ file_code: 'PARALLEL_B', title: fileName }
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
|
||||
(err) => err.hosterTransient === true
|
||||
);
|
||||
});
|
||||
|
||||
test('doodstream API upload errors expose safe structured diagnostics', async () => {
|
||||
stubUploadServer();
|
||||
requestRouter = routeWith({
|
||||
status: 502,
|
||||
contentType: 'text/html; charset=utf-8',
|
||||
body: '<html>upstream-token=SYNTHETIC_UPLOAD_SECRET https://node.invalid/upload?session=SYNTHETIC_SESSION</html>'
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => uploadFile('doodstream.com', tmpFile, 'VALIDKEY', null, null, null),
|
||||
(err) => {
|
||||
assert.equal(err.transientNetwork, true);
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_UPLOAD_SECRET|SYNTHETIC_SESSION|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'upload-response');
|
||||
assert.equal(err.diagnostic.http, 502);
|
||||
assert.equal(err.diagnostic.contentType, 'text/html; charset=utf-8');
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
assert.doesNotMatch(err.diagnostic.payloadSnippet, /SYNTHETIC_UPLOAD_SECRET|SYNTHETIC_SESSION/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const DoodstreamUploader = require('../lib/doodstream-upload');
|
||||
|
||||
// The CDN hands back an XFileSharing form. `fn` is the filecode, `st` is the
|
||||
@@ -67,39 +64,6 @@ test('happy path: link in result page wins', async () => {
|
||||
assert.equal(res.file_code, 'jjsuhr931ds9');
|
||||
});
|
||||
|
||||
test('JSON results rebuild canonical Doodstream URLs from the file code', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
assert.deepEqual(
|
||||
up._extractFromJson({
|
||||
status: 200,
|
||||
result: {
|
||||
filecode: 'CANONICAL123',
|
||||
download_url: 'http://edge.dsvplay.com/result/CANONICAL123?token=SYNTHETIC_SECRET',
|
||||
protected_embed: 'https://dood.to/arbitrary/CANONICAL123'
|
||||
}
|
||||
}),
|
||||
{
|
||||
file_code: 'CANONICAL123',
|
||||
download_url: 'https://doodstream.com/d/CANONICAL123',
|
||||
embed_url: 'https://doodstream.com/e/CANONICAL123'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid web upload results expose safe structured diagnostics', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
await assert.rejects(
|
||||
() => up._parseUploadResponse('<html><input name="api_key" value="SYNTHETIC_WEB_SECRET"> https://doodstream.com/?session=SYNTHETIC_WEB_SESSION</html>'),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_WEB_SECRET|SYNTHETIC_WEB_SESSION|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'upload-result');
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
assert.doesNotMatch(err.diagnostic.payloadSnippet, /SYNTHETIC_WEB_SECRET|SYNTHETIC_WEB_SESSION/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// --- _parseUploadFormFields: replicate the current upload form faithfully ---
|
||||
test('_parseUploadFormFields extracts the real form fields and excludes the file input', () => {
|
||||
const up = new DoodstreamUploader();
|
||||
@@ -248,116 +212,3 @@ test('getUploadServer: throws (no silent dead fallback) when discovery fails', a
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('getUploadServer: failures expose safe structured diagnostics without response secrets', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async (url) => {
|
||||
if (/op=upload_server/.test(url)) {
|
||||
return fakeRes('<html>upstream-token=SYNTHETIC_DISCOVERY_SECRET</html>', { status: 503, ctype: 'text/html; charset=utf-8' });
|
||||
}
|
||||
return fakeRes('<input name="sess_id" value="SYNTHETIC_DISCOVERY_SESSION"><a href="https://node.invalid/upload?token=SYNTHETIC_QUERY">x</a>');
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => up._getUploadServer(),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_DISCOVERY_SECRET|SYNTHETIC_DISCOVERY_SESSION|SYNTHETIC_QUERY|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'upload-server');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
assert.equal(err.diagnostic.contentType, 'text/html; charset=utf-8');
|
||||
assert.equal(err.diagnostic.safeEndpointHost, 'doodstream.com');
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('upload response read failure is marked as an uncertain remote commit', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-dood-response-'));
|
||||
const file = path.join(root, 'episode.mkv');
|
||||
fs.writeFileSync(file, Buffer.alloc(16, 1));
|
||||
const up = new DoodstreamUploader();
|
||||
up.sessId = 'SESSION';
|
||||
up._getUploadServer = async () => 'https://node.example/upload/01';
|
||||
up._requestUpload = async () => ({
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: { text: async () => { throw new Error('socket closed'); } }
|
||||
});
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => up.upload(file),
|
||||
(err) => {
|
||||
assert.equal(err.remoteCommitUncertain, true);
|
||||
assert.equal(err.diagnostic.phase, 'upload-response-read');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('redirect fetch failure after upload is marked as an uncertain remote commit', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-dood-redirect-'));
|
||||
const file = path.join(root, 'episode.mkv');
|
||||
fs.writeFileSync(file, Buffer.alloc(16, 1));
|
||||
const up = new DoodstreamUploader();
|
||||
up.sessId = 'SESSION';
|
||||
up._getUploadServer = async () => 'https://node.example/upload/01';
|
||||
up._requestUpload = async () => ({
|
||||
statusCode: 302,
|
||||
headers: { location: 'https://doodstream.com/upload-result' },
|
||||
body: { text: async () => '' }
|
||||
});
|
||||
up._fetch = async () => {
|
||||
const error = new Error('redirect fetch failed');
|
||||
error.diagnostic = { phase: 'web-request' };
|
||||
error.transientNetwork = true;
|
||||
throw error;
|
||||
};
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => up.upload(file),
|
||||
(err) => {
|
||||
assert.equal(err.remoteCommitUncertain, true);
|
||||
assert.equal(err.diagnostic.phase, 'web-request');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('fallback form fetch failure after upload is marked as an uncertain remote commit', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async () => {
|
||||
throw new Error('fallback request failed');
|
||||
};
|
||||
await assert.rejects(
|
||||
() => up._parseUploadResponse('<form action="https://doodstream.com/result"></form>'),
|
||||
(err) => {
|
||||
assert.equal(err.remoteCommitUncertain, true);
|
||||
assert.equal(err.diagnostic.phase, 'upload-result-submit');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('fallback form body failure after upload is marked as an uncertain remote commit', async () => {
|
||||
const up = new DoodstreamUploader();
|
||||
up._fetch = async () => ({
|
||||
text: async () => {
|
||||
throw new Error('fallback body failed');
|
||||
}
|
||||
});
|
||||
await assert.rejects(
|
||||
() => up._parseUploadResponse('<form action="https://doodstream.com/result"></form>'),
|
||||
(err) => {
|
||||
assert.equal(err.remoteCommitUncertain, true);
|
||||
assert.equal(err.diagnostic.phase, 'upload-result-submit');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
normalizeFilenameFilter,
|
||||
evaluateFilenameFilter,
|
||||
applyFilenameFilter
|
||||
} = require('../lib/filename-filter');
|
||||
|
||||
describe('filename filter', () => {
|
||||
it('accepts every file when the filter is disabled or has no usable conditions', () => {
|
||||
const disabled = applyFilenameFilter(['Episode.1080p.mkv'], {
|
||||
enabled: false,
|
||||
action: 'exclude',
|
||||
conditions: [{ operator: 'contains', value: '1080p' }]
|
||||
});
|
||||
const empty = applyFilenameFilter(['Episode.1080p.mkv'], {
|
||||
enabled: true,
|
||||
action: 'include',
|
||||
conditions: [{ operator: 'contains', value: ' ' }]
|
||||
});
|
||||
|
||||
assert.deepEqual(disabled.accepted, ['Episode.1080p.mkv']);
|
||||
assert.deepEqual(disabled.excluded, []);
|
||||
assert.equal(disabled.active, false);
|
||||
assert.deepEqual(empty.accepted, ['Episode.1080p.mkv']);
|
||||
assert.equal(empty.active, false);
|
||||
});
|
||||
|
||||
it('includes only filenames that satisfy every condition without case sensitivity', () => {
|
||||
const filter = {
|
||||
enabled: true,
|
||||
action: 'include',
|
||||
matchMode: 'all',
|
||||
conditions: [
|
||||
{ operator: 'contains', value: '720P' },
|
||||
{ operator: 'notContains', value: 'sample' }
|
||||
]
|
||||
};
|
||||
|
||||
const result = applyFilenameFilter([
|
||||
{ path: 'C:/Shows/Episode.720p.mkv', name: 'Episode.720p.mkv' },
|
||||
{ path: 'C:/Shows/Episode.720p.Sample.mkv', name: 'Episode.720p.Sample.mkv' },
|
||||
{ path: 'C:/Shows/Episode.1080p.mkv', name: 'Episode.1080p.mkv' }
|
||||
], filter);
|
||||
|
||||
assert.deepEqual(result.accepted.map(file => file.name), ['Episode.720p.mkv']);
|
||||
assert.deepEqual(result.excluded.map(file => file.name), ['Episode.720p.Sample.mkv', 'Episode.1080p.mkv']);
|
||||
assert.equal(result.total, 3);
|
||||
assert.equal(result.active, true);
|
||||
});
|
||||
|
||||
it('supports matching any condition and excluding matching filenames', () => {
|
||||
const filter = {
|
||||
enabled: true,
|
||||
action: 'exclude',
|
||||
matchMode: 'any',
|
||||
conditions: [
|
||||
{ operator: 'contains', value: '1080p' },
|
||||
{ operator: 'contains', value: 'sample' }
|
||||
]
|
||||
};
|
||||
|
||||
assert.equal(evaluateFilenameFilter('Episode.720p.mkv', filter).accepted, true);
|
||||
assert.equal(evaluateFilenameFilter('Episode.1080p.mkv', filter).accepted, false);
|
||||
assert.equal(evaluateFilenameFilter('Episode.720p.Sample.mkv', filter).accepted, false);
|
||||
});
|
||||
|
||||
it('normalizes unsupported values and derives names from paths', () => {
|
||||
const normalized = normalizeFilenameFilter({
|
||||
enabled: true,
|
||||
action: 'unknown',
|
||||
matchMode: 'unknown',
|
||||
conditions: [
|
||||
{ operator: 'unknown', value: ' 720p ' },
|
||||
null,
|
||||
{ operator: 'contains', value: '' }
|
||||
]
|
||||
});
|
||||
const result = applyFilenameFilter(['C:\\Shows\\Episode.720p.mkv', '/shows/Episode.1080p.mkv'], normalized);
|
||||
|
||||
assert.deepEqual(normalized, {
|
||||
enabled: true,
|
||||
action: 'include',
|
||||
matchMode: 'all',
|
||||
conditions: [{ operator: 'contains', value: '720p' }]
|
||||
});
|
||||
assert.deepEqual(result.accepted, ['C:\\Shows\\Episode.720p.mkv']);
|
||||
assert.deepEqual(result.excluded, ['/shows/Episode.1080p.mkv']);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { installHiddenElectronWindowHarness } = require('./support/hidden-electron-window');
|
||||
|
||||
test('every Electron UI smoke window stays offscreen without native reveal or focus paths', () => {
|
||||
const nativeCalls = [];
|
||||
class TestBrowserWindow {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
show() { nativeCalls.push('show'); }
|
||||
showInactive() { nativeCalls.push('showInactive'); }
|
||||
focus() { nativeCalls.push('focus'); }
|
||||
restore() { nativeCalls.push('restore'); }
|
||||
moveTop() { nativeCalls.push('moveTop'); }
|
||||
setAlwaysOnTop(value) { nativeCalls.push(`setAlwaysOnTop:${value}`); }
|
||||
setIgnoreMouseEvents(value) { this.ignoresMouse = value; }
|
||||
isVisible() { return false; }
|
||||
isFocused() { return false; }
|
||||
}
|
||||
|
||||
const targetGlobal = {};
|
||||
const harness = installHiddenElectronWindowHarness({
|
||||
BrowserWindow: TestBrowserWindow,
|
||||
targetGlobal
|
||||
});
|
||||
const windows = [
|
||||
new targetGlobal.__mhuBrowserWindowConstructor({
|
||||
show: true,
|
||||
focusable: true,
|
||||
skipTaskbar: false,
|
||||
alwaysOnTop: true,
|
||||
webPreferences: { contextIsolation: true }
|
||||
}),
|
||||
new targetGlobal.__mhuBrowserWindowConstructor({ alwaysOnTop: true }),
|
||||
new targetGlobal.__mhuBrowserWindowConstructor({ show: true })
|
||||
];
|
||||
|
||||
windows[0].show();
|
||||
windows[1].showInactive();
|
||||
windows[2].focus();
|
||||
windows[0].restore();
|
||||
windows[1].moveTop();
|
||||
windows[2].setAlwaysOnTop(true);
|
||||
|
||||
assert.deepEqual(windows[0].options, {
|
||||
show: false,
|
||||
focusable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: false,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
offscreen: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
});
|
||||
assert.equal(windows.every(window => window.options.show === false && window.options.alwaysOnTop === false && window.ignoresMouse === true), true);
|
||||
assert.deepEqual(nativeCalls, []);
|
||||
assert.equal(harness.isAlwaysOnTopRequested(windows[2]), true);
|
||||
assert.equal(harness.isNativeSurfaceSuppressed({ isVisible: () => false, isFocused: () => false }), true);
|
||||
assert.equal(harness.areNativeSurfacesSuppressed(windows), true);
|
||||
assert.deepEqual(harness.getWindows(), windows);
|
||||
});
|
||||
|
||||
test('Main routes every BrowserWindow construction through the hidden test constructor', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /new BrowserWindow\s*\(/u);
|
||||
assert.equal((source.match(/RuntimeBrowserWindow/g) || []).length >= 4, true);
|
||||
});
|
||||
|
||||
test('UI smoke never constructs an original BrowserWindow', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, 'ui-smoke.js'), 'utf8');
|
||||
assert.doesNotMatch(source, /new BrowserWindow\s*\(/u);
|
||||
assert.doesNotMatch(source, /BrowserWindow\.getAllWindows\(\)/u);
|
||||
assert.equal((source.match(/areNativeSurfacesSuppressed/g) || []).length >= 2, true);
|
||||
});
|
||||
@@ -1,232 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const VoeUploader = require('../lib/voe-upload');
|
||||
const VidmolyUploader = require('../lib/vidmoly-upload');
|
||||
const { createRecoveryClaimRegistry } = require('../lib/hosters');
|
||||
|
||||
function response(body, status = 200, contentType = 'application/json') {
|
||||
return {
|
||||
status,
|
||||
headers: { get: (name) => name.toLowerCase() === 'content-type' ? contentType : null },
|
||||
text: async () => body
|
||||
};
|
||||
}
|
||||
|
||||
test('VOE recovery rejects an unrelated singleton candidate', async () => {
|
||||
const uploader = new VoeUploader();
|
||||
uploader._fetchFileList = async () => [{ file_code: 'OTHER999', title: 'foreign-upload' }];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.equal(await uploader._resolveUploadedFile('wanted-video.mkv', new Set(), null), null);
|
||||
});
|
||||
|
||||
test('VOE recovery rejects ambiguous exact-title candidates', async () => {
|
||||
const uploader = new VoeUploader();
|
||||
uploader._fetchFileList = async () => [
|
||||
{ file_code: 'VOE_FIRST', title: 'wanted-video' },
|
||||
{ file_code: 'VOE_SECOND', title: 'wanted-video' }
|
||||
];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.equal(await uploader._resolveUploadedFile('wanted-video.mkv', new Set(), null), null);
|
||||
});
|
||||
|
||||
test('VOE recovery accepts one new exact-title candidate with a file extension', async () => {
|
||||
const uploader = new VoeUploader();
|
||||
uploader._fetchFileList = async () => [{ file_code: 'VOE_EXACT', title: 'wanted-video.mkv' }];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.deepEqual(await uploader._resolveUploadedFile('wanted-video.mkv', new Set(), null), {
|
||||
file_code: 'VOE_EXACT',
|
||||
download_url: 'https://voe.sx/VOE_EXACT',
|
||||
embed_url: 'https://voe.sx/e/VOE_EXACT'
|
||||
});
|
||||
});
|
||||
|
||||
test('VOE preserves a failed recovery baseline as a safe structured error', async () => {
|
||||
const uploader = new VoeUploader();
|
||||
uploader._fetch = async () => response(
|
||||
'<html>api_key=SYNTHETIC_VOE_SECRET https://voe.sx/list?session=SYNTHETIC_VOE_SESSION</html>',
|
||||
503,
|
||||
'text/html'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => uploader._captureFileCodes(),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_VOE_SECRET|SYNTHETIC_VOE_SESSION|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('Vidmoly recovery rejects a matching code already present in the baseline', async () => {
|
||||
const uploader = new VidmolyUploader();
|
||||
uploader._fetchVmList = async () => [{ file_code: ' OLDVID123456 ', full_title: 'wanted-video' }];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.equal(
|
||||
await uploader._resolveUploadedFileFromVmApi('wanted-video.mkv', new Set(['OLDVID123456']), null),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test('Vidmoly recovery rejects ambiguous exact-title candidates', async () => {
|
||||
const uploader = new VidmolyUploader();
|
||||
uploader._fetchVmList = async () => [
|
||||
{ file_code: 'NEWVID123456', full_title: 'wanted-video' },
|
||||
{ file_code: 'NEWVID654321', full_title: 'wanted-video' }
|
||||
];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.equal(await uploader._resolveUploadedFileFromVmApi('wanted-video.mkv', new Set(), null), null);
|
||||
});
|
||||
|
||||
test('Vidmoly recovery accepts one new exact-title candidate with a file extension', async () => {
|
||||
const uploader = new VidmolyUploader();
|
||||
uploader._fetchVmList = async () => [{ file_code: 'NEWVID123456', full_title: 'wanted-video.mkv' }];
|
||||
uploader._sleep = async () => {};
|
||||
|
||||
assert.deepEqual(await uploader._resolveUploadedFileFromVmApi('wanted-video.mkv', new Set(), null), {
|
||||
file_code: 'NEWVID123456',
|
||||
download_url: 'https://vidmoly.me/w/NEWVID123456',
|
||||
embed_url: 'https://vidmoly.me/embed-NEWVID123456.html'
|
||||
});
|
||||
});
|
||||
|
||||
test('Vidmoly preserves a failed recovery baseline as a safe structured error', async () => {
|
||||
const uploader = new VidmolyUploader();
|
||||
uploader._fetch = async () => response(
|
||||
'<html>sess_id=SYNTHETIC_VIDMOLY_SECRET https://vidmoly.me/?token=SYNTHETIC_VIDMOLY_SESSION</html>',
|
||||
503,
|
||||
'text/html'
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => uploader._captureVmFileCodes(),
|
||||
(err) => {
|
||||
assert.doesNotMatch(err.message, /SYNTHETIC_VIDMOLY_SECRET|SYNTHETIC_VIDMOLY_SESSION|<html>/);
|
||||
assert.equal(err.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(err.diagnostic.http, 503);
|
||||
assert.equal(err.diagnostic.responseKind, 'html');
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('VOE concurrent same-name recovery claims one remote entry only once', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = new VoeUploader(registry.forUpload('voe.sx', 'ACCOUNT', 'Shared Episode.mkv'));
|
||||
const second = new VoeUploader(registry.forUpload('voe.sx', 'ACCOUNT', 'shared-episode.mp4'));
|
||||
const remoteFiles = [{ file_code: 'VOE_SHARED', title: 'shared episode' }];
|
||||
first._fetchFileList = async () => remoteFiles;
|
||||
second._fetchFileList = async () => remoteFiles;
|
||||
first._sleep = async () => {};
|
||||
second._sleep = async () => {};
|
||||
|
||||
const results = await Promise.all([
|
||||
first._resolveUploadedFile('C:\\source-a\\Shared Episode.mkv', new Set(), null),
|
||||
second._resolveUploadedFile('D:\\source-b\\shared-episode.mp4', new Set(), null)
|
||||
]);
|
||||
|
||||
assert.deepEqual(results.filter(Boolean).map(result => result.file_code), ['VOE_SHARED']);
|
||||
});
|
||||
|
||||
test('VOE concurrent same-name recovery accepts distinct remote codes', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = new VoeUploader(registry.forUpload('voe.sx', 'ACCOUNT', 'Shared Episode.mkv'));
|
||||
const second = new VoeUploader(registry.forUpload('voe.sx', 'ACCOUNT', 'shared-episode.mp4'));
|
||||
first._fetchFileList = async () => [{ file_code: 'VOE_FIRST', title: 'shared episode' }];
|
||||
second._fetchFileList = async () => [{ file_code: 'VOE_SECOND', title: 'shared episode' }];
|
||||
first._sleep = async () => {};
|
||||
second._sleep = async () => {};
|
||||
|
||||
const results = await Promise.all([
|
||||
first._resolveUploadedFile('C:\\source-a\\Shared Episode.mkv', new Set(), null),
|
||||
second._resolveUploadedFile('D:\\source-b\\shared-episode.mp4', new Set(), null)
|
||||
]);
|
||||
|
||||
assert.deepEqual(results.map(result => result.file_code), ['VOE_FIRST', 'VOE_SECOND']);
|
||||
});
|
||||
|
||||
test('Vidmoly concurrent same-name recovery claims one remote entry only once', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = new VidmolyUploader(registry.forUpload('vidmoly.me', 'ACCOUNT', 'Shared Episode.mkv'));
|
||||
const second = new VidmolyUploader(registry.forUpload('vidmoly.me', 'ACCOUNT', 'shared-episode.mp4'));
|
||||
const remoteFiles = [{ file_code: 'VIDSHARED001', full_title: 'shared episode' }];
|
||||
first._fetchVmList = async () => remoteFiles;
|
||||
second._fetchVmList = async () => remoteFiles;
|
||||
first._sleep = async () => {};
|
||||
second._sleep = async () => {};
|
||||
|
||||
const results = await Promise.all([
|
||||
first._resolveUploadedFileFromVmApi('C:\\source-a\\Shared Episode.mkv', new Set(), null),
|
||||
second._resolveUploadedFileFromVmApi('D:\\source-b\\shared-episode.mp4', new Set(), null)
|
||||
]);
|
||||
|
||||
assert.deepEqual(results.filter(Boolean).map(result => result.file_code), ['VIDSHARED001']);
|
||||
});
|
||||
|
||||
test('Vidmoly concurrent same-name recovery accepts distinct remote codes', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = new VidmolyUploader(registry.forUpload('vidmoly.me', 'ACCOUNT', 'Shared Episode.mkv'));
|
||||
const second = new VidmolyUploader(registry.forUpload('vidmoly.me', 'ACCOUNT', 'shared-episode.mp4'));
|
||||
first._fetchVmList = async () => [{ file_code: 'VIDFIRST0001', full_title: 'shared episode' }];
|
||||
second._fetchVmList = async () => [{ file_code: 'VIDSECOND001', full_title: 'shared episode' }];
|
||||
first._sleep = async () => {};
|
||||
second._sleep = async () => {};
|
||||
|
||||
const results = await Promise.all([
|
||||
first._resolveUploadedFileFromVmApi('C:\\source-a\\Shared Episode.mkv', new Set(), null),
|
||||
second._resolveUploadedFileFromVmApi('D:\\source-b\\shared-episode.mp4', new Set(), null)
|
||||
]);
|
||||
|
||||
assert.deepEqual(results.map(result => result.file_code), ['VIDFIRST0001', 'VIDSECOND001']);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
label: 'VOE',
|
||||
hoster: 'voe.sx',
|
||||
Uploader: VoeUploader,
|
||||
sharedCode: 'VOE_UNCERTAIN',
|
||||
lateCode: 'VOE_LATE_CODE',
|
||||
build(uploader, code) {
|
||||
return uploader._buildUrls(code);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Vidmoly',
|
||||
hoster: 'vidmoly.me',
|
||||
Uploader: VidmolyUploader,
|
||||
sharedCode: 'VIDUNCERTAIN',
|
||||
lateCode: 'VIDLATECODE1',
|
||||
build(uploader, code) {
|
||||
return uploader._buildUrlsFromCode(code);
|
||||
}
|
||||
}
|
||||
]) {
|
||||
test(`${scenario.label} marks a duplicate direct identity uncertain and blocks a later title match`, async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const firstClaim = registry.forUpload(scenario.hoster, 'ACCOUNT', 'First Episode.mkv');
|
||||
const uncertainClaim = registry.forUpload(scenario.hoster, 'ACCOUNT', 'Second Episode.mkv');
|
||||
const first = new scenario.Uploader(firstClaim);
|
||||
const uncertain = new scenario.Uploader(uncertainClaim);
|
||||
|
||||
scenario.build(first, scenario.sharedCode);
|
||||
assert.throws(
|
||||
() => scenario.build(uncertain, scenario.sharedCode),
|
||||
err => err.remoteIdentityClaimed === true && err.remoteCommitUncertain === true
|
||||
);
|
||||
|
||||
const laterClaim = registry.forUpload(scenario.hoster, 'ACCOUNT', 'Second Episode.mp4');
|
||||
await assert.rejects(
|
||||
() => laterClaim.runExclusive(async () => scenario.build(new scenario.Uploader(laterClaim), scenario.lateCode)),
|
||||
err => err.remoteCommitUncertain === true
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
const { after, before, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
let requestRouter = async () => ({ statusCode: 200, headers: {}, body: { text: async () => '{}' } });
|
||||
const undici = require('undici');
|
||||
const originalRequest = undici.request;
|
||||
undici.request = (...args) => requestRouter(...args);
|
||||
delete require.cache[require.resolve('../lib/hosters')];
|
||||
const hosters = require('../lib/hosters');
|
||||
|
||||
let tempRoot;
|
||||
let uploadPath;
|
||||
let originalFetch;
|
||||
|
||||
before(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-recovery-safety-'));
|
||||
uploadPath = path.join(tempRoot, 'Shared Episode.mkv');
|
||||
fs.writeFileSync(uploadPath, Buffer.alloc(2048, 7));
|
||||
originalFetch = global.fetch;
|
||||
hosters.__test.DOODSTREAM_POLL.attempts = 1;
|
||||
hosters.__test.DOODSTREAM_POLL.delayMs = 0;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
global.fetch = originalFetch;
|
||||
undici.request = originalRequest;
|
||||
delete require.cache[require.resolve('../lib/hosters')];
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function stubUploadServer() {
|
||||
global.fetch = async () => ({
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ status: 200, result: 'https://node1.cloudatacdn.com/upload/01' })
|
||||
});
|
||||
}
|
||||
|
||||
function response(body, statusCode = 200) {
|
||||
return {
|
||||
statusCode,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { text: async () => typeof body === 'string' ? body : JSON.stringify(body) }
|
||||
};
|
||||
}
|
||||
|
||||
async function drain(body) {
|
||||
if (!body || typeof body[Symbol.asyncIterator] !== 'function') return;
|
||||
for await (const chunk of body) {
|
||||
if (chunk && chunk.length === -1) break;
|
||||
}
|
||||
}
|
||||
|
||||
function createClaim() {
|
||||
const codes = new Set();
|
||||
return {
|
||||
has: (code) => codes.has(String(code)),
|
||||
reserve(code) {
|
||||
const normalized = String(code || '').trim();
|
||||
if (!normalized || codes.has(normalized)) return false;
|
||||
codes.add(normalized);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('parallel same-name recovery cannot reuse a directly confirmed remote file', async () => {
|
||||
stubUploadServer();
|
||||
const recoveryClaim = createClaim();
|
||||
let uploadCalls = 0;
|
||||
requestRouter = async (url, options) => {
|
||||
if (/\/api\/file\/list/.test(String(url))) {
|
||||
return response({
|
||||
status: 200,
|
||||
result: { files: [{ file_code: 'SHARED_REMOTE_CODE', title: path.basename(uploadPath) }] }
|
||||
});
|
||||
}
|
||||
await drain(options && options.body);
|
||||
uploadCalls++;
|
||||
if (uploadCalls === 1) {
|
||||
return response({
|
||||
status: 200,
|
||||
result: [{ filecode: 'SHARED_REMOTE_CODE', download_url: 'https://doodstream.com/d/SHARED_REMOTE_CODE' }]
|
||||
});
|
||||
}
|
||||
return response({ status: 200, msg: 'OK' });
|
||||
};
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
hosters.uploadFile('doodstream.com', uploadPath, 'VALIDKEY', null, null, null, {
|
||||
doodBaseline: new Set(),
|
||||
recoveryClaim
|
||||
}),
|
||||
hosters.uploadFile('doodstream.com', uploadPath, 'VALIDKEY', null, null, null, {
|
||||
doodBaseline: new Set(),
|
||||
recoveryClaim
|
||||
})
|
||||
]);
|
||||
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||
assert.equal(results.find(result => result.status === 'fulfilled').value.file_code, 'SHARED_REMOTE_CODE');
|
||||
assert.equal(results.find(result => result.status === 'rejected').reason.hosterTransient, true);
|
||||
});
|
||||
|
||||
test('a direct response rejects a remote code already reserved in its recovery scope', async () => {
|
||||
stubUploadServer();
|
||||
const recoveryClaim = createClaim();
|
||||
recoveryClaim.reserve('ALREADY_RESERVED_CODE');
|
||||
requestRouter = async (url, options) => {
|
||||
if (/\/api\/file\/list/.test(String(url))) {
|
||||
return response({ status: 200, result: { files: [] } });
|
||||
}
|
||||
await drain(options && options.body);
|
||||
return response({
|
||||
status: 200,
|
||||
result: [{
|
||||
filecode: 'ALREADY_RESERVED_CODE',
|
||||
download_url: 'https://doodstream.com/d/ALREADY_RESERVED_CODE'
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => hosters.uploadFile('doodstream.com', uploadPath, 'VALIDKEY', null, null, null, {
|
||||
doodBaseline: new Set(),
|
||||
recoveryClaim
|
||||
}),
|
||||
error => {
|
||||
assert.equal(error.hosterTransient, true);
|
||||
assert.equal(error.diagnostic.phase, 'upload-result');
|
||||
assert.equal(error.diagnostic.http, 200);
|
||||
assert.doesNotMatch(error.message, /ALREADY_RESERVED_CODE/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
for (const fixture of [
|
||||
{ name: 'semantic error payload', payload: { status: 'error', msg: 'invalid key' } },
|
||||
{ name: 'missing files list', payload: { status: 200, result: {} } }
|
||||
]) {
|
||||
test(`doodstream rejects a ${fixture.name} as a recovery baseline`, async () => {
|
||||
stubUploadServer();
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, options) => {
|
||||
if (/\/api\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
return response(fixture.payload);
|
||||
}
|
||||
await drain(options && options.body);
|
||||
return response({ status: 200, msg: 'OK' });
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => hosters.uploadFile('doodstream.com', uploadPath, 'VALIDKEY', null, null, null),
|
||||
error => {
|
||||
assert.equal(error.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(error.diagnostic.http, 200);
|
||||
assert.equal(error.hosterTransient, true);
|
||||
assert.doesNotMatch(error.message, /invalid key/i);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(listCalls, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('byse rejects a missing files list as a recovery baseline', async () => {
|
||||
global.fetch = async () => ({
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ status: 200, result: 'https://byse-upload.invalid/upload/01' })
|
||||
});
|
||||
let listCalls = 0;
|
||||
requestRouter = async (url, options) => {
|
||||
if (/\/file\/list/.test(String(url))) {
|
||||
listCalls++;
|
||||
return response({ status: 200, result: {} });
|
||||
}
|
||||
await drain(options && options.body);
|
||||
return response({ status: 200, msg: 'OK' });
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => hosters.uploadFile('byse.sx', uploadPath, 'VALIDKEY', null, null, null),
|
||||
error => {
|
||||
assert.equal(error.diagnostic.phase, 'recovery-baseline');
|
||||
assert.equal(error.diagnostic.http, 200);
|
||||
assert.equal(error.hosterTransient, true);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(listCalls, 1);
|
||||
});
|
||||
|
||||
test('a cancelled recovery lock waiter exits before the active lease finishes', async () => {
|
||||
const registry = hosters.createRecoveryClaimRegistry();
|
||||
const claim = registry.forUpload('doodstream.com', 'ACCOUNT_KEY', 'Shared Episode.mkv');
|
||||
let releaseFirst;
|
||||
const firstBlocked = new Promise(resolve => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let markFirstEntered;
|
||||
const firstEntered = new Promise(resolve => {
|
||||
markFirstEntered = resolve;
|
||||
});
|
||||
const first = claim.runExclusive(async () => {
|
||||
markFirstEntered();
|
||||
await firstBlocked;
|
||||
});
|
||||
await firstEntered;
|
||||
const abortController = new AbortController();
|
||||
const second = claim.runExclusive(async () => 'unexpected', abortController.signal);
|
||||
abortController.abort();
|
||||
|
||||
try {
|
||||
const outcome = await Promise.race([
|
||||
second.then(value => ({ status: 'fulfilled', value }), error => ({ status: 'rejected', error })),
|
||||
new Promise(resolve => setTimeout(() => resolve({ status: 'timeout' }), 100))
|
||||
]);
|
||||
assert.equal(outcome.status, 'rejected');
|
||||
assert.equal(outcome.error.name, 'AbortError');
|
||||
} finally {
|
||||
releaseFirst();
|
||||
await first;
|
||||
await second.catch(() => {});
|
||||
}
|
||||
});
|
||||
+4
-89
@@ -1,7 +1,7 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { __test, createRecoveryClaimRegistry, normalizeRecoveryTitle } = require('../lib/hosters');
|
||||
const { __test } = require('../lib/hosters');
|
||||
|
||||
describe('hosters helpers', () => {
|
||||
it('extracts VOE file_code from nested result payloads', () => {
|
||||
@@ -46,13 +46,12 @@ describe('hosters helpers', () => {
|
||||
it('parseDoodstreamResult handles result-as-array and result-as-object', () => {
|
||||
const arr = __test.parseDoodstreamResult({ result: [{ filecode: 'AB1', protected_dl: 'https://x/1', protected_embed: 'https://x/e/1' }] });
|
||||
assert.equal(arr.file_code, 'AB1');
|
||||
assert.equal(arr.download_url, 'https://doodstream.com/d/AB1');
|
||||
assert.equal(arr.embed_url, 'https://doodstream.com/e/AB1');
|
||||
assert.equal(arr.download_url, 'https://x/1');
|
||||
assert.equal(arr.embed_url, 'https://x/e/1');
|
||||
|
||||
const obj = __test.parseDoodstreamResult({ result: { filecode: 'OBJ1', download_url: 'https://x/2' } });
|
||||
assert.equal(obj.file_code, 'OBJ1');
|
||||
assert.equal(obj.download_url, 'https://doodstream.com/d/OBJ1');
|
||||
assert.equal(obj.embed_url, 'https://doodstream.com/e/OBJ1');
|
||||
assert.equal(obj.download_url, 'https://x/2');
|
||||
});
|
||||
|
||||
it('parseByseResult tolerates null/non-object payload without throwing', () => {
|
||||
@@ -94,87 +93,3 @@ describe('hosters helpers', () => {
|
||||
assert.equal(r.embed_url, 'https://byse.sx/e/GOOD123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recovery claim registry', () => {
|
||||
it('keeps symbol-only titles distinct while matching Unicode-equivalent forms', () => {
|
||||
const gear = normalizeRecoveryTitle('⚙.mkv');
|
||||
const emojiGear = normalizeRecoveryTitle('⚙️.mp4');
|
||||
const fire = normalizeRecoveryTitle('🔥.mkv');
|
||||
const joined = normalizeRecoveryTitle('👩💻.mkv');
|
||||
const unjoined = normalizeRecoveryTitle('👩💻.mkv');
|
||||
|
||||
assert.ok(gear);
|
||||
assert.equal(emojiGear, gear);
|
||||
assert.notEqual(fire, gear);
|
||||
assert.notEqual(joined, unjoined);
|
||||
});
|
||||
|
||||
it('claims remote codes across every title of one normalized hoster account', () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = registry.forUpload(' VOE.SX ', 'ACCOUNT', 'First Episode.mkv');
|
||||
const differentTitle = registry.forUpload('voe.sx', 'ACCOUNT', 'Second Episode.mp4');
|
||||
const differentAccount = registry.forUpload('voe.sx', 'ACCOUNT-B', 'Second Episode.mp4');
|
||||
|
||||
assert.equal(first.reserve('REMOTE-CODE'), true);
|
||||
assert.equal(differentTitle.reserve('REMOTE-CODE'), false);
|
||||
assert.equal(differentAccount.reserve('REMOTE-CODE'), true);
|
||||
});
|
||||
|
||||
it('serializes canonically equivalent titles without blocking an independent title', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const composed = registry.forUpload('voe.sx', 'ACCOUNT', 'Café.mkv');
|
||||
const decomposed = registry.forUpload('voe.sx', 'ACCOUNT', 'Cafe\u0301.mp4');
|
||||
const independent = registry.forUpload('voe.sx', 'ACCOUNT', 'Other Episode.mkv');
|
||||
const events = [];
|
||||
let releaseFirst;
|
||||
const firstGate = new Promise(resolve => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
assert.equal(composed.reserve('UNICODE-CODE'), true);
|
||||
assert.equal(decomposed.reserve('UNICODE-CODE'), false);
|
||||
|
||||
const first = composed.runExclusive(async () => {
|
||||
events.push('first-started');
|
||||
await firstGate;
|
||||
events.push('first-finished');
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const equivalent = decomposed.runExclusive(async () => {
|
||||
events.push('equivalent-started');
|
||||
});
|
||||
const other = independent.runExclusive(async () => {
|
||||
events.push('independent-started');
|
||||
});
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(events, ['first-started', 'independent-started']);
|
||||
releaseFirst();
|
||||
await Promise.all([first, equivalent, other]);
|
||||
assert.deepEqual(events, ['first-started', 'independent-started', 'first-finished', 'equivalent-started']);
|
||||
});
|
||||
|
||||
it('fails closed for later jobs after a title becomes uncertain', async () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = registry.forUpload('vidmoly.me', 'ACCOUNT', 'Shared Episode.mkv');
|
||||
const later = registry.forUpload('vidmoly.me', 'ACCOUNT', 'shared-episode.mp4');
|
||||
const error = first.markUncertain(new Error('Remote commit could not be confirmed'));
|
||||
|
||||
assert.equal(error.remoteCommitUncertain, true);
|
||||
assert.equal(error.hosterTransient, true);
|
||||
await assert.rejects(
|
||||
() => later.runExclusive(async () => 'unsafe-success'),
|
||||
err => err.remoteCommitUncertain === true && err.hosterTransient === true
|
||||
);
|
||||
});
|
||||
|
||||
it('drops every claim when the registry is cleared', () => {
|
||||
const registry = createRecoveryClaimRegistry();
|
||||
const first = registry.forUpload('voe.sx', 'ACCOUNT', 'Episode.mkv');
|
||||
assert.equal(first.reserve('REMOTE-CODE'), true);
|
||||
|
||||
registry.clear();
|
||||
|
||||
const nextBatch = registry.forUpload('voe.sx', 'ACCOUNT', 'Episode.mkv');
|
||||
assert.equal(nextBatch.reserve('REMOTE-CODE'), true);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-48
@@ -74,17 +74,6 @@ test('translates duplicate desktop drop feedback to English', () => {
|
||||
assert.equal(translateText('Auswahl ist bereits in den Upload-Aufträgen.', 'en'), 'The selection is already in the upload jobs.');
|
||||
});
|
||||
|
||||
test('translates filename filter controls and import counts in both directions', () => {
|
||||
const german = '1 von 3 Dateien werden hinzugefügt. 2 durch den Dateinamenfilter ausgeschlossen.';
|
||||
const english = '1 of 3 files will be added. 2 excluded by the filename filter.';
|
||||
|
||||
assert.equal(translateText('Dateinamen beim Hinzufügen filtern', 'en'), 'Filter filenames when adding files');
|
||||
assert.equal(translateText('Dateiname filtern', 'en'), 'Filter file name');
|
||||
assert.equal(translateText('enthält nicht', 'en'), 'does not contain');
|
||||
assert.equal(translateText(german, 'en'), english);
|
||||
assert.equal(translateText(english, 'de'), german);
|
||||
});
|
||||
|
||||
test('rare account, backup, update, and confirmation states translate in both directions', () => {
|
||||
const cases = [
|
||||
['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'],
|
||||
@@ -130,11 +119,6 @@ test('rare account, backup, update, and confirmation states translate in both di
|
||||
['Backup importiert', 'Backup imported'],
|
||||
['Import fehlgeschlagen', 'Import failed'],
|
||||
['Upload-Start fehlgeschlagen', 'Failed to start upload'],
|
||||
['Initialisierung fehlgeschlagen', 'Initialization failed'],
|
||||
['Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden', 'Import applied. The queue could not be saved completely'],
|
||||
['Jobs konnten nicht hinzugefügt werden', 'Jobs could not be added'],
|
||||
['Test fehlgeschlagen', 'Test failed'],
|
||||
['. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).', '. Remote access is only available through a tunnel (for example, Tailscale/SSH).'],
|
||||
['erneut versuchbar', 'retryable'],
|
||||
['manuell', 'manual'],
|
||||
['Abgebrochen.', 'Canceled.'],
|
||||
@@ -160,40 +144,12 @@ test('interpolated rare errors translate without leaking German copy', () => {
|
||||
const cases = [
|
||||
['Login ok, Upload-Form bereit (Dateifeld: file)', 'Login successful, upload form ready (file field: file)'],
|
||||
['Klartext-Backup ist kein gültiges JSON: Unexpected token', 'Plain JSON backup is not valid JSON: Unexpected token'],
|
||||
['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Access denied'],
|
||||
['Import fehlgeschlagen: Datei beschädigt', 'Import failed: File is damaged'],
|
||||
['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Configuration is missing']
|
||||
['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Zugriff verweigert'],
|
||||
['Import fehlgeschlagen: Datei beschädigt', 'Import failed: Datei beschädigt'],
|
||||
['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Konfiguration fehlt']
|
||||
];
|
||||
|
||||
for (const [german, english] of cases) {
|
||||
assert.equal(translateText(german, 'en'), english, german);
|
||||
assert.equal(translateText(english, 'de'), german, english);
|
||||
}
|
||||
});
|
||||
|
||||
test('resume, queue, job log, and session report feedback translate in both languages', () => {
|
||||
const cases = [
|
||||
['1 unterbrochener Upload kann fortgesetzt werden.', '1 interrupted upload can be resumed.'],
|
||||
['3 unterbrochene Uploads können fortgesetzt werden.', '3 interrupted uploads can be resumed.'],
|
||||
['Wiederhergestellte Warteschlange startet in 5 s (3 Jobs).', 'Restored queue starts in 5 s (3 jobs).'],
|
||||
['2 hinzugefügt', '2 added'],
|
||||
['1 bereits im Batch', '1 already in the batch'],
|
||||
['3 ohne gültigen Account', '3 without a valid account'],
|
||||
['Keine Jobs hinzugefügt', 'No jobs added'],
|
||||
['Keine startbaren Jobs ausgewählt (alle laufen schon oder sind fertig).', 'No startable jobs selected because all are already running or completed.'],
|
||||
['Hoster', 'Host'],
|
||||
['Versuch', 'Attempt'],
|
||||
['Diagnose', 'Diagnostics'],
|
||||
['Sitzungsbericht mit 1 Upload exportiert', 'Session report with 1 upload exported'],
|
||||
['Sitzungsbericht mit 4 Uploads exportiert', 'Session report with 4 uploads exported'],
|
||||
['Sitzungsbericht konnte nicht exportiert werden.', 'The session report could not be exported.'],
|
||||
['Warteschlange konnte vor dem Upload-Start nicht gespeichert werden', 'The queue could not be saved before starting the upload']
|
||||
];
|
||||
|
||||
for (const [german, english] of cases) {
|
||||
assert.equal(translateText(german, 'en'), english, german);
|
||||
assert.equal(translateText(english, 'de'), german, english);
|
||||
}
|
||||
for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german);
|
||||
});
|
||||
|
||||
test('main-process user-facing copy contains no mojibake', () => {
|
||||
|
||||
+56
-166
@@ -1,172 +1,62 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
getEligibleImportHosters,
|
||||
inspectImportEntries,
|
||||
inspectReadableImportPath,
|
||||
summarizeImportPlan
|
||||
} = require('../lib/import-preflight');
|
||||
|
||||
describe('import preflight', () => {
|
||||
it('accounts for candidates, existing and repeated paths, filename filters, unreadable entries and accepted files', async () => {
|
||||
const sizes = new Map([
|
||||
['C:\\incoming\\duplicate-new.bin', 2 * 1024 * 1024],
|
||||
['C:\\incoming\\missing.bin', null],
|
||||
['C:\\incoming\\unreadable.bin', 'unreadable'],
|
||||
['C:\\incoming\\empty.bin', 0],
|
||||
['C:\\incoming\\accepted.xyz', 5 * 1024 * 1024]
|
||||
]);
|
||||
const inspection = await inspectImportEntries([
|
||||
'C:/queue/existing.bin',
|
||||
'C:/incoming/duplicate-new.bin',
|
||||
'C:\\incoming\\duplicate-new.bin',
|
||||
'C:/incoming/skip.sample.bin',
|
||||
'C:/incoming/missing.bin',
|
||||
'C:/incoming/unreadable.bin',
|
||||
'C:/incoming/empty.bin',
|
||||
'C:/incoming/accepted.xyz'
|
||||
], {
|
||||
existingPaths: ['C:\\QUEUE\\existing.bin'],
|
||||
filenameFilter: {
|
||||
enabled: true,
|
||||
action: 'exclude',
|
||||
conditions: [{ operator: 'contains', value: '.sample.' }]
|
||||
},
|
||||
inspectPath: async filePath => {
|
||||
const value = sizes.get(filePath);
|
||||
if (value === null) return { exists: false };
|
||||
if (value === 'unreadable') return { exists: true, readable: false, size: 10 };
|
||||
return { exists: true, readable: true, size: value };
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(inspection.candidateCount, 8);
|
||||
assert.equal(inspection.duplicateCount, 2);
|
||||
assert.equal(inspection.filteredCount, 1);
|
||||
assert.equal(inspection.unavailableCount, 3);
|
||||
assert.equal(inspection.acceptedCount, 2);
|
||||
assert.deepEqual(inspection.unavailable.map(entry => entry.reason).sort(), ['empty', 'missing', 'unreadable']);
|
||||
assert.deepEqual(inspection.accepted.map(entry => entry.name).sort(), ['accepted.xyz', 'duplicate-new.bin']);
|
||||
assert.equal(inspection.accepted.find(entry => entry.name === 'accepted.xyz').size, 5 * 1024 * 1024);
|
||||
test('inspects duplicates, unavailable files, accepted files, and configured size-limit pairs', async () => {
|
||||
const { inspectImportEntries, summarizeImportPlan } = require('../lib/import-preflight');
|
||||
const inspection = await inspectImportEntries([
|
||||
{ path: 'C:\\queue\\duplicate.mkv', name: 'duplicate.mkv' },
|
||||
{ path: 'C:\\queue\\accepted.mkv', name: 'accepted.mkv' },
|
||||
{ path: 'C:\\queue\\empty.mkv', name: 'empty.mkv' },
|
||||
{ path: 'C:\\queue\\missing.mkv', name: 'missing.mkv' }
|
||||
], {
|
||||
existingPaths: ['C:\\queue\\duplicate.mkv'],
|
||||
inspectPath: async filePath => {
|
||||
if (filePath.endsWith('accepted.mkv')) return { exists: true, readable: true, size: 2 * 1024 * 1024 };
|
||||
if (filePath.endsWith('empty.mkv')) return { exists: true, readable: true, size: 0 };
|
||||
return { exists: false };
|
||||
}
|
||||
});
|
||||
const plan = summarizeImportPlan({
|
||||
inspection,
|
||||
selectedHosters: ['doodstream.com', 'voe.sx'],
|
||||
hosterSettings: {
|
||||
'doodstream.com': { maxSizeMb: 1 },
|
||||
'voe.sx': { maxSizeMb: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
it('counts jobs and only removes jobs blocked by configured host maximum sizes', () => {
|
||||
const summary = summarizeImportPlan({
|
||||
inspection: {
|
||||
candidateCount: 8,
|
||||
duplicateCount: 2,
|
||||
filteredCount: 1,
|
||||
unavailableCount: 3,
|
||||
accepted: [
|
||||
{ path: 'C:\\incoming\\small.bin', name: 'small.bin', size: 2 * 1024 * 1024 },
|
||||
{ path: 'C:\\incoming\\large.custom', name: 'large.custom', size: 5 * 1024 * 1024 }
|
||||
]
|
||||
},
|
||||
selectedHosters: ['unlimited.example', 'limited.example', 'unlimited.example'],
|
||||
hosterSettings: {
|
||||
'unlimited.example': { maxSizeMb: 0 },
|
||||
'limited.example': { maxSizeMb: 3 },
|
||||
'unknown.example': { maxSizeMb: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
assert.deepEqual(summary, {
|
||||
candidateCount: 8,
|
||||
duplicateCount: 2,
|
||||
filteredCount: 1,
|
||||
unavailableCount: 3,
|
||||
acceptedCount: 2,
|
||||
targetCount: 2,
|
||||
jobCount: 3,
|
||||
sizeLimitedJobCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the same configured size eligibility for summaries and queue admission', () => {
|
||||
const file = { path: 'C:\\incoming\\large.custom', name: 'large.custom', size: 5 * 1024 * 1024 };
|
||||
const selectedHosters = ['limited.example', 'unlimited.example', 'limited.example'];
|
||||
const hosterSettings = {
|
||||
'limited.example': { maxSizeMb: 3 },
|
||||
'unlimited.example': { maxSizeMb: 0 }
|
||||
};
|
||||
|
||||
assert.deepEqual(getEligibleImportHosters(file, selectedHosters, hosterSettings), ['unlimited.example']);
|
||||
assert.deepEqual(summarizeImportPlan({
|
||||
inspection: { candidateCount: 1, accepted: [file] },
|
||||
selectedHosters,
|
||||
hosterSettings
|
||||
}), {
|
||||
candidateCount: 1,
|
||||
duplicateCount: 0,
|
||||
filteredCount: 0,
|
||||
unavailableCount: 0,
|
||||
acceptedCount: 1,
|
||||
targetCount: 2,
|
||||
jobCount: 1,
|
||||
sizeLimitedJobCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates Windows drive and UNC namespace aliases canonically', async () => {
|
||||
const inspectedPaths = [];
|
||||
const inspection = await inspectImportEntries([
|
||||
'\\\\?\\C:\\incoming\\same.bin',
|
||||
'C:\\incoming\\same.bin',
|
||||
'\\\\?\\UNC\\server\\share\\same.bin',
|
||||
'\\\\server\\share\\same.bin'
|
||||
], {
|
||||
caseInsensitive: true,
|
||||
inspectPath: async filePath => {
|
||||
inspectedPaths.push(filePath);
|
||||
return { exists: true, readable: true, size: 1 };
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(inspection.acceptedCount, 2);
|
||||
assert.equal(inspection.duplicateCount, 2);
|
||||
assert.deepEqual(inspectedPaths, ['C:\\incoming\\same.bin', '\\\\server\\share\\same.bin']);
|
||||
});
|
||||
|
||||
it('inspects type and size through one opened read handle and closes it', async () => {
|
||||
const calls = [];
|
||||
const fileHandle = {
|
||||
stat: async () => {
|
||||
calls.push('stat');
|
||||
return { isFile: () => true, size: 42 };
|
||||
},
|
||||
close: async () => {
|
||||
calls.push('close');
|
||||
}
|
||||
};
|
||||
|
||||
const result = await inspectReadableImportPath('C:\\incoming\\readable.bin', async (filePath, flags) => {
|
||||
calls.push(['open', filePath, flags]);
|
||||
return fileHandle;
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { exists: true, readable: true, size: 42 });
|
||||
assert.deepEqual(calls, [['open', 'C:\\incoming\\readable.bin', 'r'], 'stat', 'close']);
|
||||
});
|
||||
|
||||
it('limits concurrent file inspections', async () => {
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
const inspection = await inspectImportEntries(
|
||||
Array.from({ length: 12 }, (_, index) => `C:/incoming/file-${index}.bin`),
|
||||
{
|
||||
concurrency: 3,
|
||||
inspectPath: async () => {
|
||||
active++;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
active--;
|
||||
return { exists: true, readable: true, size: 1 };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(inspection.acceptedCount, 12);
|
||||
assert.equal(maximumActive, 3);
|
||||
assert.deepEqual({
|
||||
candidates: inspection.candidateCount,
|
||||
duplicates: inspection.duplicateCount,
|
||||
unavailable: inspection.unavailableCount,
|
||||
accepted: inspection.acceptedCount
|
||||
}, { candidates: 4, duplicates: 1, unavailable: 2, accepted: 1 });
|
||||
assert.deepEqual(plan, {
|
||||
candidateCount: 4,
|
||||
duplicateCount: 1,
|
||||
unavailableCount: 2,
|
||||
acceptedCount: 1,
|
||||
targetCount: 2,
|
||||
jobCount: 1,
|
||||
sizeLimitedJobCount: 1
|
||||
});
|
||||
});
|
||||
|
||||
test('connects the import preflight through the main process, preload, renderer, and hoster dialog', () => {
|
||||
const root = path.join(__dirname, '..');
|
||||
const main = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
|
||||
const preload = fs.readFileSync(path.join(root, 'preload.js'), 'utf8');
|
||||
const renderer = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8');
|
||||
const html = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
|
||||
const css = fs.readFileSync(path.join(root, 'renderer', 'styles.css'), 'utf8');
|
||||
|
||||
assert.match(main, /ipcMain\.handle\('inspect-import-files'/);
|
||||
assert.match(preload, /inspectImportFiles/);
|
||||
assert.match(renderer, /coordinateImportEntries/);
|
||||
assert.match(renderer, /isImportPairEligible/);
|
||||
assert.match(renderer, /toLocaleString\(getUiLocale\(\)\)/);
|
||||
assert.match(html, /id="importPlanSummary"/);
|
||||
assert.match(css, /hoster-modal-list \+ \.import-plan-summary \+ \.modal-hint/);
|
||||
});
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function loadRemoteInputHandler(sendInputEvent, debugLog = () => {}) {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const handlerStart = source.indexOf("ipcMain.on('remote:input-event'");
|
||||
const handlerEnd = source.indexOf('\nfunction buildModifiers', handlerStart);
|
||||
const modifiersEnd = source.indexOf('\n// IPC: Get capture source ID', handlerEnd);
|
||||
assert.notEqual(handlerStart, -1);
|
||||
assert.notEqual(handlerEnd, -1);
|
||||
assert.notEqual(modifiersEnd, -1);
|
||||
|
||||
let inputHandler;
|
||||
const mainWindow = {
|
||||
isDestroyed: () => false,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1100, height: 750 }),
|
||||
getContentBounds: () => ({ x: 7, y: 30, width: 1086, height: 713 }),
|
||||
webContents: { sendInputEvent }
|
||||
};
|
||||
const context = vm.createContext({
|
||||
ipcMain: {
|
||||
on(channel, handler) {
|
||||
if (channel === 'remote:input-event') inputHandler = handler;
|
||||
}
|
||||
},
|
||||
mainWindow,
|
||||
configStore: {
|
||||
load: () => ({ globalSettings: { remote: { allowInput: true } } })
|
||||
},
|
||||
debugLog,
|
||||
process: { platform: 'win32' },
|
||||
isFinite
|
||||
});
|
||||
|
||||
vm.runInContext(source.slice(handlerStart, handlerEnd) + source.slice(handlerEnd, modifiersEnd), context);
|
||||
assert.equal(typeof inputHandler, 'function');
|
||||
return inputHandler;
|
||||
}
|
||||
|
||||
test('authenticated keyboard input without a string key is discarded without throwing', () => {
|
||||
const sent = [];
|
||||
const logs = [];
|
||||
const handler = loadRemoteInputHandler(event => sent.push(event), (...args) => logs.push(args));
|
||||
const invalidPayloads = [
|
||||
{ role: 'admin', type: 'keydown' },
|
||||
{ role: 'admin', type: 'keydown', key: null },
|
||||
{ role: 'admin', type: 'keydown', key: 1 },
|
||||
{ role: 'admin', type: 'keydown', key: '' },
|
||||
{ role: 'admin', type: 'keyup' },
|
||||
{ role: 'admin', type: 'keyup', key: {} }
|
||||
];
|
||||
|
||||
for (const payload of invalidPayloads) {
|
||||
assert.doesNotThrow(() => handler({}, payload));
|
||||
}
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
assert.deepEqual(logs, []);
|
||||
});
|
||||
|
||||
test('authenticated keyboard input with a string key keeps normal keydown and keyup behavior', () => {
|
||||
const sent = [];
|
||||
const handler = loadRemoteInputHandler(event => sent.push(event));
|
||||
|
||||
handler({}, { role: 'admin', type: 'keydown', key: 'a', ctrl: true });
|
||||
handler({}, { role: 'admin', type: 'keyup', key: 'a', ctrl: true });
|
||||
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(sent)), [
|
||||
{ type: 'keyDown', keyCode: 'a', modifiers: ['control'] },
|
||||
{ type: 'char', keyCode: 'a', modifiers: ['control'] },
|
||||
{ type: 'keyUp', keyCode: 'a', modifiers: ['control'] }
|
||||
]);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
@@ -11,60 +10,6 @@ test('packages every Electron preload referenced by the main process', () => {
|
||||
assert.equal(packageJson.build.win.signAndEditExecutable, false);
|
||||
});
|
||||
|
||||
test('both release repositories run the real Electron UI audit in CI', () => {
|
||||
const forgeWorkflow = [['.gi', 'tea'].join(''), 'workflows', 'ci.yml'].join('/');
|
||||
for (const relativePath of ['.github/workflows/ci.yml', forgeWorkflow]) {
|
||||
const workflow = fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
|
||||
assert.match(workflow, /RUN_UI_SMOKE:\s*['"]1['"]/);
|
||||
assert.match(workflow, /run:\s*npm run verify/);
|
||||
}
|
||||
});
|
||||
|
||||
test('packaged identity is exact while public artifact routing remains stable', () => {
|
||||
assert.equal(packageJson.build.productName, 'Multi Hoster Uploader');
|
||||
assert.equal(packageJson.build.win.executableName, 'Multi Hoster Uploader');
|
||||
assert.equal(packageJson.build.nsis.artifactName, 'Multi-Hoster-Upload Setup ${version}.${ext}');
|
||||
assert.equal(packageJson.build.portable.artifactName, 'Multi-Hoster-Upload ${version}.${ext}');
|
||||
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
assert.match(source, /app\.setPath\('userData', path\.join\(app\.getPath\('appData'\), 'multi-hoster-uploader'\)\)/);
|
||||
assert.match(source, /app\.setName\('Multi Hoster Uploader'\)/);
|
||||
assert.match(source, /app\.setAppUserModelId\('com\.multihoster\.uploader'\)/);
|
||||
assert.doesNotMatch(source, /updateTrayTooltip\('Multi-Hoster-Upload'\)/);
|
||||
});
|
||||
|
||||
test('floating drop target resolves native paths through Electron webUtils', () => {
|
||||
let exposedApi = null;
|
||||
const nativeFile = { name: 'fixture.mkv' };
|
||||
const electronMock = {
|
||||
contextBridge: {
|
||||
exposeInMainWorld: (_name, api) => { exposedApi = api; }
|
||||
},
|
||||
ipcRenderer: {
|
||||
send: () => {}
|
||||
},
|
||||
webUtils: {
|
||||
getPathForFile: file => file === nativeFile ? 'C:\\fixtures\\fixture.mkv' : ''
|
||||
}
|
||||
};
|
||||
const originalLoad = Module._load;
|
||||
const preloadPath = require.resolve('../preload-drop-target');
|
||||
delete require.cache[preloadPath];
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === 'electron') return electronMock;
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
try {
|
||||
require(preloadPath);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[preloadPath];
|
||||
}
|
||||
|
||||
assert.equal(typeof exposedApi.getPathForFile, 'function');
|
||||
assert.equal(exposedApi.getPathForFile(nativeFile), 'C:\\fixtures\\fixture.mkv');
|
||||
});
|
||||
|
||||
test('afterPack brands the executable metadata shown by Windows', async () => {
|
||||
let editCall = null;
|
||||
const originalLoad = Module._load;
|
||||
@@ -80,45 +25,25 @@ test('afterPack brands the executable metadata shown by Windows', async () => {
|
||||
const afterPack = require(afterPackPath);
|
||||
await afterPack({
|
||||
appOutDir: 'C:\\release',
|
||||
packager: { appInfo: { productFilename: 'Multi Hoster Uploader', version: '9.8.7' } }
|
||||
packager: { appInfo: { productFilename: 'Multi-Hoster-Upload', version: '9.8.7' } }
|
||||
});
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[afterPackPath];
|
||||
}
|
||||
|
||||
assert.equal(editCall.exePath, path.join('C:\\release', 'Multi Hoster Uploader.exe'));
|
||||
assert.equal(editCall.exePath, path.join('C:\\release', 'Multi-Hoster-Upload.exe'));
|
||||
assert.equal(editCall.options['file-version'], '9.8.7');
|
||||
assert.equal(editCall.options['product-version'], '9.8.7');
|
||||
assert.deepEqual(editCall.options['version-string'], {
|
||||
CompanyName: 'Sucukdeluxe',
|
||||
FileDescription: 'Multi Hoster Uploader',
|
||||
InternalName: 'Multi Hoster Uploader',
|
||||
OriginalFilename: 'Multi Hoster Uploader.exe',
|
||||
InternalName: 'Multi-Hoster-Upload',
|
||||
OriginalFilename: 'Multi-Hoster-Upload.exe',
|
||||
ProductName: 'Multi Hoster Uploader'
|
||||
});
|
||||
});
|
||||
|
||||
test('afterPack fails the build when executable branding fails', async () => {
|
||||
const originalLoad = Module._load;
|
||||
const afterPackPath = require.resolve('../scripts/afterPack.cjs');
|
||||
delete require.cache[afterPackPath];
|
||||
Module._load = function (request, parent, isMain) {
|
||||
if (request === 'rcedit') return async () => { throw new Error('branding failed'); };
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
try {
|
||||
const afterPack = require(afterPackPath);
|
||||
await assert.rejects(afterPack({
|
||||
appOutDir: 'C:\\release',
|
||||
packager: { appInfo: { productFilename: 'Multi Hoster Uploader', version: '9.8.7' } }
|
||||
}), /branding failed/);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[afterPackPath];
|
||||
}
|
||||
});
|
||||
|
||||
test('close readiness is signaled only after the renderer explicitly finishes initialization', () => {
|
||||
const listeners = new Map();
|
||||
const sent = [];
|
||||
@@ -159,10 +84,8 @@ test('close readiness is signaled only after the renderer explicitly finishes in
|
||||
assert.deepEqual(sent, [['app:close-preparation-started', 7]]);
|
||||
|
||||
exposedApi.signalCloseHandshakeReady();
|
||||
exposedApi.signalRendererInitializationFailed({ message: 'init failed' });
|
||||
assert.deepEqual(sent, [
|
||||
['app:close-preparation-started', 7],
|
||||
['app:close-handshake-ready'],
|
||||
['app:renderer-initialization-failed', { message: 'init failed' }]
|
||||
['app:close-handshake-ready']
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,19 @@ const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const rootFiles = [
|
||||
'.gitignore',
|
||||
'README.md',
|
||||
'SECURITY.md',
|
||||
'eslint.config.mjs',
|
||||
'main.js',
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'preload-drop-target.js',
|
||||
'preload.js'
|
||||
];
|
||||
const directoryRoots = [`.${['gi', 'tea'].join('')}`, `.${['git', 'hub'].join('')}`, 'assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests'];
|
||||
const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs'];
|
||||
const screenshotFiles = [
|
||||
'assets/product-overview.png',
|
||||
'docs/screenshots/upload-workspace.png',
|
||||
@@ -15,19 +28,42 @@ const screenshotFiles = [
|
||||
];
|
||||
const currentVersion = require('../package.json').version;
|
||||
|
||||
function copyDirectory(source, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
||||
if (/^_ui-inject\..+\.tmp\.js$/.test(entry.name)) continue;
|
||||
const sourcePath = path.join(source, entry.name);
|
||||
const destinationPath = path.join(destination, entry.name);
|
||||
if (entry.isDirectory()) copyDirectory(sourcePath, destinationPath);
|
||||
else if (entry.isFile()) fs.copyFileSync(sourcePath, destinationPath);
|
||||
}
|
||||
}
|
||||
|
||||
function copyDocumentationScreenshots(stage) {
|
||||
for (const relativePath of screenshotFiles.slice(1)) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, screenshotFiles[0]), destination);
|
||||
}
|
||||
}
|
||||
|
||||
function createStage() {
|
||||
const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-public-verifier-'));
|
||||
const tracked = spawnSync('git', ['ls-files', '-z'], {
|
||||
cwd: root,
|
||||
encoding: 'buffer'
|
||||
});
|
||||
assert.equal(tracked.status, 0, tracked.stderr?.toString('utf8'));
|
||||
const trackedFiles = tracked.stdout.toString('utf8').split('\0').filter(Boolean);
|
||||
for (const relativePath of trackedFiles) {
|
||||
for (const relativePath of rootFiles) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
||||
}
|
||||
for (const relativePath of directoryRoots) {
|
||||
if (relativePath === 'docs') copyDocumentationScreenshots(stage);
|
||||
else copyDirectory(path.join(root, relativePath), path.join(stage, relativePath));
|
||||
}
|
||||
for (const relativePath of scriptFiles) {
|
||||
const destination = path.join(stage, relativePath);
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(path.join(root, relativePath), destination);
|
||||
}
|
||||
fs.rmSync(path.join(stage, 'assets', 'product-overview.png'), { force: true });
|
||||
return stage;
|
||||
}
|
||||
|
||||
@@ -41,26 +77,6 @@ function verify(stage, version = currentVersion, sourceOnly = true) {
|
||||
});
|
||||
}
|
||||
|
||||
function verifyTracked(stage) {
|
||||
return spawnSync(process.execPath, [
|
||||
'scripts/verify-public-release.mjs',
|
||||
'--source-only',
|
||||
'--tracked',
|
||||
'--package-version'
|
||||
], {
|
||||
cwd: stage,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
}
|
||||
|
||||
function git(stage, args) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd: stage,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout);
|
||||
}
|
||||
|
||||
test('public release verifier accepts only the exact source manifest and target version', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
@@ -86,102 +102,6 @@ test('public release verifier accepts only the exact source manifest and target
|
||||
assert.match(wrongVersion.stderr, /package\.json\tpackage-version-target/);
|
||||
});
|
||||
|
||||
test('public source verification runs against the actual checkout at its package version', () => {
|
||||
const executable = process.platform === 'win32' ? process.env.ComSpec : 'npm';
|
||||
const args = process.platform === 'win32'
|
||||
? ['/d', '/s', '/c', 'npm run --silent verify:public-source']
|
||||
: ['run', '--silent', 'verify:public-source'];
|
||||
const result = spawnSync(executable, args, {
|
||||
cwd: root,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.error?.message || result.stderr || result.stdout);
|
||||
assert.match(result.stdout, new RegExp(`version=${currentVersion.replaceAll('.', '\\.')}\\b`));
|
||||
assert.match(result.stdout, /layout=exact/);
|
||||
});
|
||||
|
||||
test('public release verifier rejects known credential patterns in approved text files', async (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
const target = path.join(stage, 'README.md');
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
const assignment = (name, value) => `${name} = "${value}"`;
|
||||
const fixtures = [
|
||||
['access token', ['gh', 'p_'].join('') + 'SYNTHETICNOTREAL'.padEnd(36, '0')],
|
||||
['fine-grained access token', ['github', 'pat'].join('_') + '_' + 'A'.repeat(82)],
|
||||
['forge token assignment', assignment(['access', 'token'].join('_'), 'a'.repeat(40))],
|
||||
['npm token', ['npm', ''].join('_') + 'A'.repeat(36)],
|
||||
['Slack token', ['xox', 'b'].join('') + '-' + ['1'.repeat(12), '2'.repeat(12), 'A'.repeat(24)].join('-')],
|
||||
['JWT', ['eyJ', 'hbGciOiJIUzI1NiJ9'].join('') + '.' + ['eyJ', 'zdWIiOiIxMjM0NTY3ODkwIn0'].join('') + '.signaturevalue'],
|
||||
['Bearer token', ['Bear', 'er '].join('') + 'A'.repeat(32)],
|
||||
['password assignment', assignment(['pass', 'word'].join(''), 'CorrectHorseBatteryStaple')],
|
||||
['API key assignment', assignment(['api', 'key'].join('_'), 'A1b2C3d4E5f6G7h8')],
|
||||
['unquoted password assignment', ['pass', 'word'].join('') + ': CorrectHorseBatteryStaple'],
|
||||
['unquoted API key assignment', ['api', 'key'].join('_') + ': abcdefghijklmnop'],
|
||||
['cookie assignment', assignment(['cook', 'ie'].join(''), 'session-value-123456789')],
|
||||
['session assignment', assignment(['sess', 'ion'].join(''), 'session-value-123456789')],
|
||||
['private key', ['-----BEGIN ', 'PRIVATE KEY-----'].join('') + '\nSYNTHETICNOTAKEY\n-----END PRIVATE KEY-----'],
|
||||
['secret', ['aws', '_secret_access_key'].join('') + ' = ' + 'SYNTHETICNOTREALSECRET'.padEnd(40, '0')]
|
||||
];
|
||||
|
||||
for (const [name, fixture] of fixtures) {
|
||||
await t.test(name, () => {
|
||||
fs.writeFileSync(target, `${original}\n${fixture}\n`);
|
||||
const result = verify(stage);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /README\.md\tcredential-pattern/);
|
||||
fs.writeFileSync(target, original);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('public release verifier rejects realistic credentials inside approved test files', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
const target = path.join(stage, 'tests', 'account-status.test.js');
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
const fixture = ['pass', 'word'].join('') + ': CorrectHorseBatteryStaple';
|
||||
fs.writeFileSync(target, `${original}\n${fixture}\n`);
|
||||
|
||||
const result = verify(stage);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /tests\/account-status\.test\.js\tcredential-pattern/);
|
||||
});
|
||||
|
||||
test('--tracked validates tracked source and every untracked Electron build input', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
git(stage, ['init', '--quiet']);
|
||||
git(stage, ['add', '--all']);
|
||||
|
||||
fs.writeFileSync(path.join(stage, 'tests', 'unexpected.json'), '{}');
|
||||
fs.writeFileSync(path.join(stage, 'tests', 'untracked.json'), '{}');
|
||||
fs.writeFileSync(path.join(stage, 'renderer', 'private-config.js'), 'module.exports = {};');
|
||||
git(stage, ['add', '--', 'tests/unexpected.json']);
|
||||
const result = verifyTracked(stage);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /tests\/unexpected\.json\tsource-layout-allowlist/);
|
||||
assert.match(result.stderr, /renderer\/private-config\.js\tsource-layout-allowlist/);
|
||||
assert.doesNotMatch(result.stderr, /tests\/untracked\.json/);
|
||||
});
|
||||
|
||||
test('public release verifier detects private updater endpoints hidden by string concatenation', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
const target = path.join(stage, 'README.md');
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
const hiddenEndpoint = String.fromCharCode(
|
||||
99, 111, 110, 115, 116, 32, 104, 111, 115, 116, 32, 61, 32, 39, 103, 105, 116, 39, 32, 43, 32, 39, 46,
|
||||
50, 52, 45, 39, 32, 43, 32, 39, 109, 117, 115, 105, 99, 46, 100, 101, 39, 59
|
||||
);
|
||||
fs.writeFileSync(target, `${original}\n${hiddenEndpoint}\n`);
|
||||
|
||||
const result = verify(stage);
|
||||
assert.equal(result.status, 1, result.stderr);
|
||||
assert.match(result.stderr, /README\.md\tupdater-endpoint-scope/);
|
||||
});
|
||||
|
||||
test('public release verifier requires and validates every approved screenshot', (t) => {
|
||||
const stage = createStage();
|
||||
t.after(() => fs.rmSync(stage, { recursive: true, force: true }));
|
||||
|
||||
@@ -73,32 +73,6 @@ test('empty/missing inputs do not throw', () => {
|
||||
|
||||
const T = (s) => Date.parse(s.replace(' ', 'T'));
|
||||
|
||||
test('history fallback keeps a serialized successful terminal result through log deduplication', () => {
|
||||
const result = {
|
||||
download_url: 'https://voe.sx/e/exact-link',
|
||||
embed_url: 'https://voe.sx/e/exact-embed',
|
||||
file_code: 'exact-code'
|
||||
};
|
||||
const restored = JSON.parse(JSON.stringify({
|
||||
savedAt: T('2026-08-13 12:00:00'),
|
||||
queueJobs: [{
|
||||
id: 'history-fallback-done',
|
||||
file: 'C:/dl/episode.mkv',
|
||||
fileName: 'episode.mkv',
|
||||
hoster: 'voe.sx',
|
||||
status: 'done',
|
||||
result
|
||||
}]
|
||||
}));
|
||||
const log = [{ fileName: 'episode.mkv', hoster: 'voe.sx', ts: T('2026-08-13 12:00:05') }];
|
||||
|
||||
const { kept, removed } = partitionRestoredJobsByLog(restored.queueJobs, log, restored.savedAt);
|
||||
|
||||
assert.deepEqual(removed, []);
|
||||
assert.deepEqual(kept, restored.queueJobs);
|
||||
assert.deepEqual(kept[0].result, result);
|
||||
});
|
||||
|
||||
test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => {
|
||||
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
||||
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
|
||||
|
||||
test('release planning rejects omitted or blank English release notes', async () => {
|
||||
const { parseReleaseArgs } = await import(releasePlanUrl);
|
||||
|
||||
assert.throws(
|
||||
() => parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20']),
|
||||
/English release notes are required/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', ' ']),
|
||||
/English release notes are required/
|
||||
);
|
||||
});
|
||||
|
||||
test('release planning preserves dual-host asset names with English release notes', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs([
|
||||
'2.1.20',
|
||||
'--transport-tag',
|
||||
'v2.1.20',
|
||||
'Security hardening and reliability fixes.'
|
||||
]));
|
||||
|
||||
assert.equal(plan.releaseBody, 'Security hardening and reliability fixes.');
|
||||
assert.deepEqual(plan.expectedArtifacts, [
|
||||
'Multi-Hoster-Upload Setup 2.1.20.exe',
|
||||
'Multi-Hoster-Upload 2.1.20.exe',
|
||||
'Multi-Hoster-Upload Setup 2.1.20.exe.blockmap',
|
||||
'latest.yml'
|
||||
]);
|
||||
assert.deepEqual(plan.githubExpectedArtifacts, [
|
||||
'Multi-Hoster-Upload.Setup.2.1.20.exe',
|
||||
'Multi-Hoster-Upload.2.1.20.exe',
|
||||
'Multi-Hoster-Upload.Setup.2.1.20.exe.blockmap',
|
||||
'latest.yml'
|
||||
]);
|
||||
});
|
||||
@@ -1,7 +1,5 @@
|
||||
const { describe, it, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// Test the module can be required and has the expected API
|
||||
describe('RemoteServer', () => {
|
||||
@@ -41,54 +39,4 @@ describe('RemoteServer', () => {
|
||||
assert.strictEqual(server.getClientCount(), 0);
|
||||
server.stop();
|
||||
});
|
||||
|
||||
it('binds to loopback when no host is supplied', async () => {
|
||||
const RemoteServer = require('../lib/remote-server');
|
||||
const server = new RemoteServer();
|
||||
try {
|
||||
await server.start({
|
||||
port: 0,
|
||||
token: 'test-token-123',
|
||||
onSignalingToCapture: () => {},
|
||||
onCreateCaptureWindow: () => {},
|
||||
onDestroyCaptureWindow: () => {}
|
||||
});
|
||||
assert.strictEqual(server._wss.address().address, '127.0.0.1');
|
||||
} finally {
|
||||
server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the application remote-control listener on loopback', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const start = source.indexOf('async function startRemoteServer()');
|
||||
const end = source.indexOf("ipcMain.on('remote:signaling-from-capture'", start);
|
||||
assert.notStrictEqual(start, -1);
|
||||
assert.notStrictEqual(end, -1);
|
||||
assert.match(source.slice(start, end), /host:\s*'127\.0\.0\.1'/);
|
||||
});
|
||||
|
||||
it('rejects non-object JSON before authentication without throwing', () => {
|
||||
const { EventEmitter } = require('node:events');
|
||||
const RemoteServer = require('../lib/remote-server');
|
||||
const server = new RemoteServer();
|
||||
const socket = new EventEmitter();
|
||||
socket.close = (code) => {
|
||||
socket.closeCode = code;
|
||||
};
|
||||
socket.send = () => {};
|
||||
server._config = {
|
||||
token: 'test-token-123',
|
||||
allowlist: [],
|
||||
diagnosticMode: false,
|
||||
onCreateCaptureWindow: () => {},
|
||||
onDestroyCaptureWindow: () => {},
|
||||
onSignalingToCapture: () => {}
|
||||
};
|
||||
|
||||
server._handleConnection(socket, { socket: { remoteAddress: '127.0.0.1' } });
|
||||
assert.doesNotThrow(() => socket.emit('message', Buffer.from('null')));
|
||||
assert.strictEqual(socket.closeCode, 4002);
|
||||
socket.emit('close');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,13 +48,13 @@ test('prepareGroups creates one Windows manifest with a stable token and immutab
|
||||
token: 'cleanup-1',
|
||||
file: 'C:\\Uploads\\Movie.MKV',
|
||||
requiredHosters: ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'],
|
||||
confirmedHosters: [],
|
||||
completedHosters: ['doodstream.com', 'voe.sx'],
|
||||
fingerprint: null,
|
||||
jobs: [
|
||||
{ jobId: 'job-doodstream', hoster: 'doodstream.com', status: 'done', currentRound: false },
|
||||
{ jobId: 'job-voe', hoster: 'voe.sx', status: 'done', currentRound: false },
|
||||
{ jobId: 'job-vidmoly', hoster: 'vidmoly.me', status: 'error', currentRound: true },
|
||||
{ jobId: 'job-byse', hoster: 'byse.sx', status: 'preview', currentRound: false }
|
||||
{ jobId: 'job-doodstream', hoster: 'doodstream.com', status: 'done' },
|
||||
{ jobId: 'job-voe', hoster: 'voe.sx', status: 'done' },
|
||||
{ jobId: 'job-vidmoly', hoster: 'vidmoly.me', status: 'error' },
|
||||
{ jobId: 'job-byse', hoster: 'byse.sx', status: 'preview' }
|
||||
]
|
||||
});
|
||||
assert.deepEqual(queueJobs.map((job) => job.sourceCleanupToken), [
|
||||
@@ -74,13 +74,11 @@ test('prepareGroups creates one Windows manifest with a stable token and immutab
|
||||
assert.doesNotThrow(() => JSON.stringify(prepared.groups));
|
||||
});
|
||||
|
||||
test('prepareGroups reuses confirmed metadata across a partial retry', () => {
|
||||
test('prepareGroups reuses persisted metadata across a partial retry', () => {
|
||||
const queueJobs = queueFixture();
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
|
||||
for (const job of queueJobs) {
|
||||
job.sourceCleanupMetadataVersion = 2;
|
||||
job.sourceCleanupConfirmedHosters = ['doodstream.com', 'voe.sx'];
|
||||
}
|
||||
queueJobs[0].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx'];
|
||||
queueJobs[1].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx'];
|
||||
queueJobs[0].status = 'preview';
|
||||
queueJobs[1].status = 'preview';
|
||||
|
||||
@@ -95,7 +93,7 @@ test('prepareGroups reuses confirmed metadata across a partial retry', () => {
|
||||
'vidmoly.me',
|
||||
'byse.sx'
|
||||
]);
|
||||
assert.deepEqual(prepared.groups[0].confirmedHosters, ['doodstream.com', 'voe.sx']);
|
||||
assert.deepEqual(prepared.groups[0].completedHosters, ['doodstream.com', 'voe.sx']);
|
||||
assert.equal(prepared.groups[0].jobs.find((job) => job.hoster === 'vidmoly.me').status, 'error');
|
||||
});
|
||||
|
||||
@@ -151,7 +149,7 @@ test('removing a failed job never relaxes the stored requirements', () => {
|
||||
|
||||
test('removeRequirement drops only an explicitly discarded unstarted hoster', () => {
|
||||
const queueJobs = queueFixture();
|
||||
policy.prepareGroups(queueJobs, queueJobs.slice(0, 3), () => 'cleanup-1', 'win32');
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
|
||||
|
||||
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[3], 'win32');
|
||||
|
||||
@@ -165,7 +163,7 @@ test('removeRequirement drops only an explicitly discarded unstarted hoster', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('markCompleted keeps successful hosters provisional for the current round', () => {
|
||||
test('markCompleted preserves successful hosters for later retries', () => {
|
||||
const queueJobs = queueFixture();
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
|
||||
|
||||
@@ -173,108 +171,10 @@ test('markCompleted keeps successful hosters provisional for the current round',
|
||||
policy.markCompleted(queueJobs, queueJobs[2], 'win32');
|
||||
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, []);
|
||||
assert.deepEqual(job.sourceCleanupProvisionalHosters, ['vidmoly.me']);
|
||||
}
|
||||
});
|
||||
|
||||
test('a started cleanup requirement survives abort finalization persistence and restart', async () => {
|
||||
const queueJobs = [
|
||||
{ id: 'job-complete', file: 'C:\\Uploads\\Protected.mkv', hoster: 'doodstream.com', status: 'preview' },
|
||||
{ id: 'job-started', file: 'C:\\Uploads\\Protected.mkv', hoster: 'voe.sx', status: 'preview' }
|
||||
];
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-protected', 'win32');
|
||||
queueJobs[0].status = 'done';
|
||||
policy.markCompleted(queueJobs, queueJobs[0], 'win32');
|
||||
queueJobs[1].status = 'aborted';
|
||||
await policy.persistRoundCompletions(queueJobs, { historyPersisted: true, persist: async () => true });
|
||||
|
||||
const restored = structuredClone(queueJobs);
|
||||
restored[1].status = 'preview';
|
||||
restored[1].interrupted = false;
|
||||
const touchedJobs = policy.removeRequirement(restored, restored[1], 'win32');
|
||||
|
||||
assert.deepEqual(touchedJobs, []);
|
||||
assert.deepEqual(restored.map(job => job.sourceCleanupStartedHosters), [
|
||||
['doodstream.com', 'voe.sx'],
|
||||
['doodstream.com', 'voe.sx']
|
||||
]);
|
||||
assert.deepEqual(restored.map(job => job.sourceCleanupRequiredHosters), [
|
||||
['doodstream.com', 'voe.sx'],
|
||||
['doodstream.com', 'voe.sx']
|
||||
]);
|
||||
});
|
||||
|
||||
test('legacy version two cleanup metadata without started hosters fails closed', () => {
|
||||
const queueJobs = [
|
||||
{
|
||||
id: 'legacy-complete',
|
||||
file: 'C:\\Uploads\\Legacy.mkv',
|
||||
hoster: 'doodstream.com',
|
||||
status: 'done',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-legacy',
|
||||
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||
sourceCleanupConfirmedHosters: ['doodstream.com']
|
||||
},
|
||||
{
|
||||
id: 'legacy-candidate',
|
||||
file: 'C:\\Uploads\\Legacy.mkv',
|
||||
hoster: 'voe.sx',
|
||||
status: 'preview',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-legacy',
|
||||
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||
sourceCleanupConfirmedHosters: ['doodstream.com']
|
||||
}
|
||||
];
|
||||
|
||||
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[1], 'win32');
|
||||
|
||||
assert.deepEqual(touchedJobs, []);
|
||||
assert.deepEqual(queueJobs.map(job => job.sourceCleanupRequiredHosters), [
|
||||
['doodstream.com', 'voe.sx'],
|
||||
['doodstream.com', 'voe.sx']
|
||||
]);
|
||||
});
|
||||
|
||||
test('removeRequirement never relaxes requirements for started or interrupted jobs', () => {
|
||||
for (const candidate of [
|
||||
{ status: 'queued' },
|
||||
{ status: 'getting-server' },
|
||||
{ status: 'uploading' },
|
||||
{ status: 'retrying' },
|
||||
{ status: 'preview', interrupted: true }
|
||||
]) {
|
||||
const queueJobs = [
|
||||
{
|
||||
id: 'job-complete',
|
||||
file: 'C:\\Uploads\\Protected.mkv',
|
||||
hoster: 'doodstream.com',
|
||||
status: 'done',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-protected',
|
||||
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||
sourceCleanupConfirmedHosters: ['doodstream.com']
|
||||
},
|
||||
{
|
||||
id: 'job-candidate',
|
||||
file: 'C:\\Uploads\\Protected.mkv',
|
||||
hoster: 'voe.sx',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-protected',
|
||||
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||
sourceCleanupConfirmedHosters: ['doodstream.com'],
|
||||
...candidate
|
||||
}
|
||||
];
|
||||
|
||||
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[1], 'win32');
|
||||
|
||||
assert.deepEqual(touchedJobs, []);
|
||||
assert.deepEqual(queueJobs.map(job => job.sourceCleanupRequiredHosters), [
|
||||
['doodstream.com', 'voe.sx'],
|
||||
['doodstream.com', 'voe.sx']
|
||||
assert.deepEqual(job.sourceCleanupCompletedHosters, [
|
||||
'doodstream.com',
|
||||
'voe.sx',
|
||||
'vidmoly.me'
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -303,127 +203,3 @@ test('applyFingerprints attaches Main fingerprints by token and includes them in
|
||||
assert.notEqual(job.sourceCleanupFingerprint, fingerprint);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps round successes provisional until history and queue persistence succeed', async () => {
|
||||
const queueJobs = [
|
||||
{ id: 'job-voe', file: 'C:\\Uploads\\Round.bin', hoster: 'voe.sx', status: 'preview' },
|
||||
{ id: 'job-byse', file: 'C:\\Uploads\\Round.bin', hoster: 'byse.sx', status: 'error' }
|
||||
];
|
||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-round', 'win32');
|
||||
queueJobs[0].status = 'done';
|
||||
|
||||
policy.markCompleted(queueJobs, queueJobs[0], 'win32');
|
||||
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, []);
|
||||
assert.deepEqual(job.sourceCleanupProvisionalHosters, ['voe.sx']);
|
||||
}
|
||||
|
||||
let persistedHosters = null;
|
||||
const queuePersisted = await policy.persistRoundCompletions(queueJobs, {
|
||||
historyPersisted: true,
|
||||
persist: async () => {
|
||||
persistedHosters = queueJobs.map((job) => [...job.sourceCleanupConfirmedHosters]);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(queuePersisted, true);
|
||||
assert.deepEqual(persistedHosters, [['voe.sx'], ['voe.sx']]);
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, ['voe.sx']);
|
||||
assert.deepEqual(job.sourceCleanupProvisionalHosters, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('rolls back provisional promotion when the final queue save fails', async () => {
|
||||
const queueJobs = [
|
||||
{
|
||||
id: 'job-voe',
|
||||
file: 'C:\\Uploads\\Partial.bin',
|
||||
hoster: 'voe.sx',
|
||||
status: 'done',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-partial',
|
||||
sourceCleanupRequiredHosters: ['voe.sx', 'byse.sx'],
|
||||
sourceCleanupConfirmedHosters: ['voe.sx']
|
||||
},
|
||||
{
|
||||
id: 'job-byse',
|
||||
file: 'C:\\Uploads\\Partial.bin',
|
||||
hoster: 'byse.sx',
|
||||
status: 'preview',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-partial',
|
||||
sourceCleanupRequiredHosters: ['voe.sx', 'byse.sx'],
|
||||
sourceCleanupConfirmedHosters: ['voe.sx']
|
||||
}
|
||||
];
|
||||
policy.prepareGroups(queueJobs, [queueJobs[1]], () => 'unused', 'win32');
|
||||
queueJobs[1].status = 'done';
|
||||
policy.markCompleted(queueJobs, queueJobs[1], 'win32');
|
||||
|
||||
const queuePersisted = await policy.persistRoundCompletions(queueJobs, {
|
||||
historyPersisted: true,
|
||||
persist: async () => false
|
||||
});
|
||||
|
||||
assert.equal(queuePersisted, false);
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, ['voe.sx']);
|
||||
assert.deepEqual(job.sourceCleanupProvisionalHosters, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores legacy v2.1.19 completed markers when preparing a retry', () => {
|
||||
const queueJobs = [{
|
||||
id: 'job-voe',
|
||||
file: 'C:\\Uploads\\Legacy.bin',
|
||||
hoster: 'voe.sx',
|
||||
status: 'error',
|
||||
sourceCleanupToken: 'cleanup-legacy',
|
||||
sourceCleanupRequiredHosters: ['voe.sx'],
|
||||
sourceCleanupCompletedHosters: ['voe.sx']
|
||||
}];
|
||||
|
||||
const prepared = policy.prepareGroups(queueJobs, queueJobs, () => 'unused', 'win32');
|
||||
|
||||
assert.deepEqual(prepared.groups[0].confirmedHosters, []);
|
||||
assert.equal(queueJobs[0].sourceCleanupMetadataVersion, 2);
|
||||
assert.deepEqual(queueJobs[0].sourceCleanupConfirmedHosters, []);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(queueJobs[0], 'sourceCleanupCompletedHosters'), false);
|
||||
});
|
||||
|
||||
test('selecting a confirmed hoster for retry revokes its durable completion', () => {
|
||||
const queueJobs = [
|
||||
{
|
||||
id: 'job-voe',
|
||||
file: 'C:\\Uploads\\Retry.bin',
|
||||
hoster: 'voe.sx',
|
||||
status: 'preview',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-retry',
|
||||
sourceCleanupRequiredHosters: ['voe.sx', 'byse.sx'],
|
||||
sourceCleanupConfirmedHosters: ['voe.sx', 'byse.sx']
|
||||
},
|
||||
{
|
||||
id: 'job-byse',
|
||||
file: 'C:\\Uploads\\Retry.bin',
|
||||
hoster: 'byse.sx',
|
||||
status: 'done',
|
||||
sourceCleanupMetadataVersion: 2,
|
||||
sourceCleanupToken: 'cleanup-retry',
|
||||
sourceCleanupRequiredHosters: ['voe.sx', 'byse.sx'],
|
||||
sourceCleanupConfirmedHosters: ['voe.sx', 'byse.sx']
|
||||
}
|
||||
];
|
||||
|
||||
const prepared = policy.prepareGroups(queueJobs, [queueJobs[0]], () => 'unused', 'win32');
|
||||
|
||||
assert.deepEqual(prepared.groups[0].confirmedHosters, ['byse.sx']);
|
||||
assert.equal(prepared.groups[0].jobs[0].currentRound, true);
|
||||
assert.equal(prepared.groups[0].jobs[1].currentRound, false);
|
||||
for (const job of queueJobs) {
|
||||
assert.deepEqual(job.sourceCleanupConfirmedHosters, ['byse.sx']);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ function group(file, overrides = {}) {
|
||||
token: 'cleanup-1',
|
||||
file,
|
||||
requiredHosters: ['voe.sx', 'byse.sx'],
|
||||
confirmedHosters: [],
|
||||
completedHosters: [],
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' }
|
||||
@@ -81,7 +81,7 @@ test('fingerprints a regular file and keeps the registered manifest immutable',
|
||||
assert.equal(typeof fingerprints['cleanup-1'].ino, 'number');
|
||||
|
||||
manifest.requiredHosters.splice(1, 1);
|
||||
manifest.confirmedHosters.push('byse.sx');
|
||||
manifest.completedHosters.push('byse.sx');
|
||||
manifest.jobs[1].hoster = 'voe.sx';
|
||||
await cleanup.settle({
|
||||
token: 'cleanup-1',
|
||||
@@ -182,13 +182,7 @@ test('combines previous successes with a successful retry without relaxing other
|
||||
await t.test('last retry completes the immutable group', async (subtest) => {
|
||||
const { file } = await makeSource(subtest, 'retry-completes.bin');
|
||||
const { cleanup } = makeCleanup();
|
||||
const manifest = group(file, {
|
||||
confirmedHosters: ['voe.sx'],
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: false },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending', currentRound: true }
|
||||
]
|
||||
});
|
||||
const manifest = group(file, { completedHosters: ['voe.sx'] });
|
||||
await cleanup.registerGroups([manifest]);
|
||||
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
|
||||
|
||||
@@ -202,11 +196,11 @@ test('combines previous successes with a successful retry without relaxing other
|
||||
const { cleanup, audits } = makeCleanup();
|
||||
const manifest = group(file, {
|
||||
requiredHosters: ['voe.sx', 'byse.sx', 'vidmoly.me'],
|
||||
confirmedHosters: ['voe.sx'],
|
||||
completedHosters: ['voe.sx'],
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: false },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending', currentRound: true },
|
||||
{ jobId: 'job-vidmoly', file, hoster: 'vidmoly.me', status: 'error', currentRound: false }
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' },
|
||||
{ jobId: 'job-vidmoly', file, hoster: 'vidmoly.me', status: 'error' }
|
||||
]
|
||||
});
|
||||
await cleanup.registerGroups([manifest]);
|
||||
@@ -473,118 +467,3 @@ test('rechecks the staged file and restores a replacement without deleting it',
|
||||
assert.equal((await fs.promises.readFile(file, 'utf-8')), 'replacement data');
|
||||
assert.equal(audits.at(-1).outcome, 'source-changed');
|
||||
});
|
||||
|
||||
test('keeps the source after done, failed history persistence, and an aborted retry of the same hoster', async (t) => {
|
||||
const { file } = await makeSource(t, 'history-barrier-retry.bin');
|
||||
const firstRound = makeCleanup();
|
||||
const firstManifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await firstRound.cleanup.registerGroups([firstManifest]);
|
||||
await firstRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
|
||||
|
||||
assert.deepEqual(await firstRound.cleanup.finishBatch({ historyPersisted: false, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
|
||||
const retryRound = makeCleanup();
|
||||
const retryManifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
completedHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await retryRound.cleanup.registerGroups([retryManifest]);
|
||||
await retryRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'aborted' });
|
||||
|
||||
assert.deepEqual(await retryRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
});
|
||||
|
||||
test('deletes after a persisted partial round and a successful retry of the remaining hoster', async (t) => {
|
||||
const { file } = await makeSource(t, 'partial-round-retry.bin');
|
||||
const firstRound = makeCleanup();
|
||||
const firstManifest = group(file, {
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending', currentRound: true }
|
||||
]
|
||||
});
|
||||
await firstRound.cleanup.registerGroups([firstManifest]);
|
||||
await firstRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
|
||||
await firstRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'error' });
|
||||
|
||||
assert.deepEqual(await firstRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
|
||||
const retryRound = makeCleanup();
|
||||
const retryManifest = group(file, {
|
||||
confirmedHosters: ['voe.sx'],
|
||||
jobs: [
|
||||
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: false },
|
||||
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending', currentRound: true }
|
||||
]
|
||||
});
|
||||
await retryRound.cleanup.registerGroups([retryManifest]);
|
||||
await retryRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
|
||||
|
||||
assert.deepEqual(await retryRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['deleted']);
|
||||
assert.equal(await exists(file), false);
|
||||
});
|
||||
|
||||
test('does not trust a legacy completion after a failed queue barrier and restart', async (t) => {
|
||||
const { file } = await makeSource(t, 'queue-barrier-restart.bin');
|
||||
const firstRound = makeCleanup();
|
||||
const firstManifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await firstRound.cleanup.registerGroups([firstManifest]);
|
||||
await firstRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
|
||||
|
||||
assert.deepEqual(await firstRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: false }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
|
||||
const restartedRound = makeCleanup();
|
||||
const restartedManifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
completedHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await restartedRound.cleanup.registerGroups([restartedManifest]);
|
||||
await restartedRound.cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'error' });
|
||||
|
||||
assert.deepEqual(await restartedRound.cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
});
|
||||
|
||||
test('lets a current non-done retry override an earlier confirmed completion', async (t) => {
|
||||
const { file } = await makeSource(t, 'confirmed-hoster-retry.bin');
|
||||
const { cleanup, audits } = makeCleanup();
|
||||
const manifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
confirmedHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done', currentRound: true }]
|
||||
});
|
||||
await cleanup.registerGroups([manifest]);
|
||||
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'aborted' });
|
||||
|
||||
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'voe.sx', status: 'aborted' }]);
|
||||
});
|
||||
|
||||
test('re-registering a hoster for the current round invalidates its earlier current success', async (t) => {
|
||||
const { file } = await makeSource(t, 'same-batch-retry.bin');
|
||||
const { cleanup, audits } = makeCleanup();
|
||||
const manifest = group(file, {
|
||||
requiredHosters: ['voe.sx'],
|
||||
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending', currentRound: true }]
|
||||
});
|
||||
await cleanup.registerGroups([manifest]);
|
||||
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
|
||||
await cleanup.registerGroups([manifest]);
|
||||
|
||||
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
|
||||
assert.equal(await exists(file), true);
|
||||
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'voe.sx', status: 'pending' }]);
|
||||
});
|
||||
|
||||
+7
-1233
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@ const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const {
|
||||
summarizePerHoster,
|
||||
summarizeHosterHealth,
|
||||
classifyErrorCategory,
|
||||
summarizeBatchErrors,
|
||||
isRetryableCategory,
|
||||
@@ -77,258 +76,6 @@ test('summarizePerHoster reports skipped uploads without lowering the host succe
|
||||
assert.strictEqual(summary.rate, 0.5);
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth keeps skipped results outside the success rate and calculates effective historical throughput', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = [{
|
||||
timestamp: '2026-08-16T10:00:00.000Z',
|
||||
files: [
|
||||
{ name: 'one.bin', size: 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 2 }] },
|
||||
{ name: 'two.bin', size: 2 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 2 }] },
|
||||
{ name: 'failed.bin', size: 8 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'error', durationSec: 1 }] },
|
||||
{ name: 'skipped.bin', size: 16 * 1024 * 1024, results: [{ hoster: 'voe.sx', status: 'skipped', durationSec: 1 }] }
|
||||
]
|
||||
}];
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now })['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
skipped: summary.skipped,
|
||||
successRate: summary.successRate,
|
||||
effectiveBytes: summary.effectiveBytes,
|
||||
effectiveDurationSec: summary.effectiveDurationSec,
|
||||
effectiveBytesPerSecond: summary.effectiveBytesPerSecond
|
||||
}, {
|
||||
sampleSize: 4,
|
||||
successful: 2,
|
||||
failed: 1,
|
||||
skipped: 1,
|
||||
successRate: 2 / 3,
|
||||
effectiveBytes: 3 * 1024 * 1024,
|
||||
effectiveDurationSec: 4,
|
||||
effectiveBytesPerSecond: 786432
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth reports the newest successful batch and failures in the current seven-day window', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = [
|
||||
makeBatch(Date.parse('2026-08-09T11:59:59.999Z'), [{ hoster: 'byse.sx', status: 'error' }]),
|
||||
makeBatch(Date.parse('2026-08-09T12:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }]),
|
||||
makeBatch(Date.parse('2026-08-15T09:00:00.000Z'), [{ hoster: 'byse.sx', status: 'done', durationSec: 4 }]),
|
||||
makeBatch(Date.parse('2026-08-16T11:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }]),
|
||||
makeBatch(Date.parse('2026-08-16T13:00:00.000Z'), [{ hoster: 'byse.sx', status: 'error' }])
|
||||
];
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now })['byse.sx'];
|
||||
|
||||
assert.strictEqual(summary.lastSuccessAt, '2026-08-15T09:00:00.000Z');
|
||||
assert.strictEqual(summary.failuresLast7Days, 2);
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth uses only the 50 chronologically newest batches', () => {
|
||||
const history = [makeBatch(1, [{ hoster: 'doodstream.com', status: 'done', durationSec: 1 }])];
|
||||
for (let timestamp = 2; timestamp <= 51; timestamp++) {
|
||||
history.push(makeBatch(timestamp, [{ hoster: 'doodstream.com', status: 'error' }]));
|
||||
}
|
||||
history.reverse();
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now: new Date(100000) })['doodstream.com'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
lastSuccessAt: summary.lastSuccessAt
|
||||
}, {
|
||||
sampleSize: 50,
|
||||
successful: 0,
|
||||
failed: 50,
|
||||
lastSuccessAt: null
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth counts seven-day failures across all valid batches outside the 50-batch sample', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = Array.from({ length: 60 }, (_, index) => makeBatch(
|
||||
now.getTime() - (index + 1) * 60 * 60 * 1000,
|
||||
[{ hoster: 'voe.sx', status: 'error' }]
|
||||
));
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now })['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
failed: summary.failed,
|
||||
failuresLast7Days: summary.failuresLast7Days
|
||||
}, {
|
||||
sampleSize: 50,
|
||||
failed: 50,
|
||||
failuresLast7Days: 60
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth excludes disabled accounts from every problem-state counter', () => {
|
||||
const summary = summarizeHosterHealth([], {
|
||||
hosters: {
|
||||
'voe.sx': [
|
||||
{ id: 'disabled-error', enabled: false, authType: 'api', apiKey: 'key' },
|
||||
{ id: 'disabled-unchecked', enabled: false, authType: 'api', apiKey: 'key' },
|
||||
{ id: 'disabled-checking', enabled: false, authType: 'api', apiKey: 'key' }
|
||||
]
|
||||
},
|
||||
accountStatuses: {
|
||||
'disabled-error': { status: 'error' },
|
||||
'disabled-unchecked': { status: 'unchecked' },
|
||||
'disabled-checking': { status: 'checking' }
|
||||
},
|
||||
sessionFailedKeys: new Set(['voe.sx:disabled-error'])
|
||||
})['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
configuredAccounts: summary.configuredAccounts,
|
||||
accountProblems: summary.accountProblems,
|
||||
uncheckedAccounts: summary.uncheckedAccounts,
|
||||
checkingAccounts: summary.checkingAccounts
|
||||
}, {
|
||||
configuredAccounts: 3,
|
||||
accountProblems: 0,
|
||||
uncheckedAccounts: 0,
|
||||
checkingAccounts: 0
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth selects the newest batches without mutating the full loaded snapshot', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = Array.from({ length: 60 }, (_, index) => ({
|
||||
...makeBatch(now.getTime() - (index + 1) * 60 * 60 * 1000, [{ hoster: 'voe.sx', status: 'error' }]),
|
||||
id: `loaded-${index}`
|
||||
}));
|
||||
const completed = {
|
||||
...makeBatch(now.getTime(), [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }]),
|
||||
id: 'completed'
|
||||
};
|
||||
|
||||
history.push(completed);
|
||||
const loadedOrder = [...history];
|
||||
const summary = summarizeHosterHealth(history, { now })['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual(history, loadedOrder);
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed
|
||||
}, {
|
||||
sampleSize: 50,
|
||||
successful: 1,
|
||||
failed: 49
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth preserves account health while history is unavailable', () => {
|
||||
const options = {
|
||||
hosters: {
|
||||
'voe.sx': [{ id: 'unavailable-history', enabled: true, authType: 'api', apiKey: 'key' }]
|
||||
},
|
||||
accountStatuses: {
|
||||
'unavailable-history': { status: 'error' }
|
||||
}
|
||||
};
|
||||
|
||||
for (const history of [null, undefined]) {
|
||||
const summary = summarizeHosterHealth(history, options)['voe.sx'];
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
configuredAccounts: summary.configuredAccounts,
|
||||
accountProblems: summary.accountProblems
|
||||
}, {
|
||||
sampleSize: 0,
|
||||
configuredAccounts: 1,
|
||||
accountProblems: 1
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth excludes invalid and future timestamps from every time statistic', () => {
|
||||
const now = new Date('2026-08-16T12:00:00.000Z');
|
||||
const history = [
|
||||
{
|
||||
id: 'invalid',
|
||||
timestamp: 'not-a-date',
|
||||
files: [{ name: 'invalid.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }]
|
||||
},
|
||||
makeBatch(Date.parse('2026-08-16T12:00:00.001Z'), [{ hoster: 'voe.sx', status: 'error' }])
|
||||
];
|
||||
|
||||
const summary = summarizeHosterHealth(history, { now, hosters: { 'voe.sx': [] } })['voe.sx'];
|
||||
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary.sampleSize,
|
||||
successful: summary.successful,
|
||||
failed: summary.failed,
|
||||
skipped: summary.skipped,
|
||||
lastSuccessAt: summary.lastSuccessAt,
|
||||
failuresLast7Days: summary.failuresLast7Days
|
||||
}, {
|
||||
sampleSize: 0,
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
lastSuccessAt: null,
|
||||
failuresLast7Days: 0
|
||||
});
|
||||
});
|
||||
|
||||
test('summarizeHosterHealth combines configured accounts, current statuses, and session failures without double counting', () => {
|
||||
const hosters = {
|
||||
'voe.sx': [
|
||||
{ id: 'ready', enabled: true, authType: 'login', username: 'ready@example.invalid', password: 'secret' },
|
||||
{ id: 'failed', enabled: true, authType: 'login', username: 'failed@example.invalid', password: 'secret' },
|
||||
{ id: 'unchecked', enabled: true, authType: 'login', username: 'unchecked@example.invalid', password: 'secret' },
|
||||
{ id: 'disabled', enabled: false, authType: 'login', username: 'disabled@example.invalid', password: 'secret' },
|
||||
{ id: 'session', enabled: true, authType: 'login', username: 'session@example.invalid', password: 'secret' }
|
||||
],
|
||||
'clouddrop.cc': []
|
||||
};
|
||||
const accountStatuses = {
|
||||
ready: { status: 'ok' },
|
||||
failed: { status: 'error' },
|
||||
unchecked: { status: 'unchecked' },
|
||||
disabled: { status: 'error' },
|
||||
session: { status: 'ok' }
|
||||
};
|
||||
|
||||
const summary = summarizeHosterHealth([], {
|
||||
now: new Date('2026-08-16T12:00:00.000Z'),
|
||||
hosters,
|
||||
accountStatuses,
|
||||
sessionFailedKeys: new Set(['voe.sx:failed', 'voe.sx:session'])
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({
|
||||
configuredAccounts: summary['voe.sx'].configuredAccounts,
|
||||
accountProblems: summary['voe.sx'].accountProblems,
|
||||
uncheckedAccounts: summary['voe.sx'].uncheckedAccounts,
|
||||
checkingAccounts: summary['voe.sx'].checkingAccounts
|
||||
}, {
|
||||
configuredAccounts: 5,
|
||||
accountProblems: 2,
|
||||
uncheckedAccounts: 1,
|
||||
checkingAccounts: 0
|
||||
});
|
||||
assert.deepStrictEqual({
|
||||
sampleSize: summary['clouddrop.cc'].sampleSize,
|
||||
configuredAccounts: summary['clouddrop.cc'].configuredAccounts,
|
||||
successRate: summary['clouddrop.cc'].successRate
|
||||
}, {
|
||||
sampleSize: 0,
|
||||
configuredAccounts: 0,
|
||||
successRate: null
|
||||
});
|
||||
});
|
||||
|
||||
test('classifyErrorCategory: file-rejected phrases', () => {
|
||||
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
|
||||
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
|
||||
@@ -422,27 +169,6 @@ test('mergeSkippedIntoSummary adds skipped jobs to totals and history files', ()
|
||||
});
|
||||
});
|
||||
|
||||
test('mergeSkippedIntoSummary keeps duplicate basenames separated by file key', () => {
|
||||
const summary = {
|
||||
total: 2,
|
||||
succeeded: 2,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
files: [
|
||||
{ name: 'same.mkv', fileKey: 'file-one', size: 10, results: [{ jobId: 'done-one', hoster: 'voe.sx', status: 'done' }] },
|
||||
{ name: 'same.mkv', fileKey: 'file-two', size: 20, results: [{ jobId: 'done-two', hoster: 'voe.sx', status: 'done' }] }
|
||||
]
|
||||
};
|
||||
const merged = mergeSkippedIntoSummary(summary, [
|
||||
{ jobId: 'skip-one', fileName: 'same.mkv', fileKey: 'file-one', hoster: 'byse.sx', reason: 'Kein Account' },
|
||||
{ jobId: 'skip-two', fileName: 'same.mkv', fileKey: 'file-two', hoster: 'doodstream.com', reason: 'Kein Account' }
|
||||
]);
|
||||
|
||||
assert.strictEqual(merged.files.length, 2);
|
||||
assert.deepStrictEqual(merged.files[0].results.map(result => result.jobId), ['done-one', 'skip-one']);
|
||||
assert.deepStrictEqual(merged.files[1].results.map(result => result.jobId), ['done-two', 'skip-two']);
|
||||
});
|
||||
|
||||
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
|
||||
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
|
||||
assert.strictEqual(isRetryableCategory('network'), true);
|
||||
|
||||
@@ -3,7 +3,7 @@ const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeConfig, collectSecretValues, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
||||
const { sanitizeConfig, collectFile, buildSupportBundleText, redactLogText, REDACTED } = require('../lib/support-bundle');
|
||||
|
||||
test('sanitizeConfig redacts known credential keys at any nesting depth', () => {
|
||||
const input = {
|
||||
@@ -85,74 +85,6 @@ test('redactLogText leaves a normal "session" word in prose alone', () => {
|
||||
assert.equal(redactLogText(benign, []), benign);
|
||||
});
|
||||
|
||||
test('redactLogText removes complete authorization, cookie, session, HTML credential, and query values', () => {
|
||||
const authorization = 'Digest username="private-user", realm="private-realm", response="private-response"';
|
||||
const cookie = 'sid=private-cookie; preferences=private-preferences';
|
||||
const sessionId = 's3';
|
||||
const htmlPassword = 'private-html-password';
|
||||
const htmlToken = 'private-html-token';
|
||||
const queryToken = 'q1';
|
||||
const input = [
|
||||
`Authorization: ${authorization}`,
|
||||
`Cookie: ${cookie}`,
|
||||
`session_id=${sessionId}`,
|
||||
`<input type="password" name="password" value="${htmlPassword}">`,
|
||||
`<input value="${htmlToken}" name="api_token" type="text">`,
|
||||
`https://example.invalid/upload?token=${queryToken}&next=ok`
|
||||
].join('\n');
|
||||
const out = redactLogText(input, []);
|
||||
for (const value of [authorization, 'private-user', 'private-realm', 'private-response', cookie, 'private-cookie', 'private-preferences', sessionId, htmlPassword, htmlToken, queryToken]) {
|
||||
assert.ok(!out.includes(value), `sensitive value survived: ${value}`);
|
||||
}
|
||||
assert.ok((out.match(/<redacted>/g) || []).length >= 6);
|
||||
});
|
||||
|
||||
test('redactLogText masks a one-character configured secret only as a complete sensitive value', () => {
|
||||
const out = redactLogText('status=diagnostics available\npassword=x\nfile=xylophone.mkv\nmarker=x', ['x']);
|
||||
assert.ok(out.includes('status=diagnostics available'));
|
||||
assert.ok(out.includes('file=xylophone.mkv'));
|
||||
assert.ok(!out.includes('password=x'));
|
||||
assert.ok(!out.includes('marker=x'));
|
||||
assert.ok(out.includes(`password=${REDACTED}`));
|
||||
assert.ok(out.includes(`marker=${REDACTED}`));
|
||||
});
|
||||
|
||||
test('redactLogText replaces configured secrets only as complete values', () => {
|
||||
const out = redactLogText([
|
||||
'password=orange',
|
||||
'configured token orange accepted',
|
||||
'file=orangejuice',
|
||||
'file=orange.mkv',
|
||||
'password=.',
|
||||
'version=2.1.20',
|
||||
'sentence finished.'
|
||||
].join('\n'), ['orange', '.']);
|
||||
assert.ok(!out.includes('password=orange'));
|
||||
assert.ok(!out.includes('token orange'));
|
||||
assert.ok(!out.includes('password=.'));
|
||||
assert.ok(out.includes('file=orangejuice'));
|
||||
assert.ok(out.includes('file=orange.mkv'));
|
||||
assert.ok(out.includes('version=2.1.20'));
|
||||
assert.ok(out.includes('sentence finished.'));
|
||||
});
|
||||
|
||||
test('redactLogText removes JSON-escaped configured secrets and quoted HTML credential values', () => {
|
||||
const jsonSecret = 'alpha"beta\\gamma';
|
||||
const password = 'abc>secret';
|
||||
const token = 'token>quoted';
|
||||
const input = [
|
||||
JSON.stringify({ note: jsonSecret, token: jsonSecret }),
|
||||
`<input type="password" value="${password}">`,
|
||||
`<input value='${token}' name='api_token' type='text'>`
|
||||
].join('\n');
|
||||
const out = redactLogText(input, [jsonSecret]);
|
||||
assert.ok(!out.includes(jsonSecret));
|
||||
assert.ok(!out.includes('alpha\\"beta\\\\gamma'));
|
||||
assert.ok(!out.includes(password));
|
||||
assert.ok(!out.includes(token));
|
||||
assert.ok((out.match(/<redacted>/g) || []).length >= 3);
|
||||
});
|
||||
|
||||
test('redactLogText removes complete local paths from structured and free-form log text', () => {
|
||||
const profilePath = ['C:', 'Users', 'ProfileFixture', 'Private Folder', 'episode.mkv'].join('\\');
|
||||
const drivePath = ['D:', 'Archive', 'Private Folder', 'source.mkv'].join('\\');
|
||||
@@ -171,23 +103,6 @@ test('redactLogText removes complete local paths from structured and free-form l
|
||||
assert.ok((out.match(/<redacted-path>/g) || []).length >= 4);
|
||||
});
|
||||
|
||||
test('redactLogText removes extended UNC, extended drive, UNC and slash-UNC paths', () => {
|
||||
const extendedUnc = '\\\\?\\UNC\\private-server\\secret-share\\hidden.log';
|
||||
const extendedDrive = ['\\\\?\\C:', 'Users', 'PrivateProfile', 'hidden.log'].join('\\');
|
||||
const unc = '\\\\private-server\\secret-share\\hidden.log';
|
||||
const slashUnc = '//private-server/secret-share/hidden.log';
|
||||
const out = redactLogText([
|
||||
`extended UNC failure: ${extendedUnc}`,
|
||||
`extended drive failure: ${extendedDrive}`,
|
||||
`UNC failure: ${unc}`,
|
||||
`slash UNC failure: ${slashUnc}`
|
||||
].join('\n'), []);
|
||||
for (const fragment of ['private-server', 'secret-share', 'PrivateProfile', 'hidden.log']) {
|
||||
assert.ok(!out.includes(fragment), `private path fragment survived: ${fragment}`);
|
||||
}
|
||||
assert.equal((out.match(/<redacted-path>/g) || []).length, 4);
|
||||
});
|
||||
|
||||
test('sanitizeConfig does not mutate input', () => {
|
||||
const input = { hosters: { 'voe.sx': [{ password: 'secret' }] } };
|
||||
const clone = JSON.parse(JSON.stringify(input));
|
||||
@@ -239,7 +154,7 @@ test('buildSupportBundleText produces structured output with header + config + f
|
||||
sanitizedConfig: { hosters: { 'voe.sx': [{ apiKey: '<redacted>' }] } },
|
||||
files: [{ label: 'debug.log', path: tmp }]
|
||||
});
|
||||
assert.match(text, /^=== Multi Hoster Uploader Support Bundle ===/);
|
||||
assert.match(text, /^=== Multi-Hoster-Upload Support Bundle ===/);
|
||||
assert.match(text, /Version: 3\.3\.41/);
|
||||
assert.match(text, /Platform: win32/);
|
||||
assert.match(text, /=== Config \(sanitized/);
|
||||
@@ -253,22 +168,10 @@ test('buildSupportBundleText produces structured output with header + config + f
|
||||
|
||||
test('buildSupportBundleText handles empty file list and missing header', () => {
|
||||
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [] });
|
||||
assert.match(text, /=== Multi Hoster Uploader Support Bundle ===/);
|
||||
assert.match(text, /=== Multi-Hoster-Upload Support Bundle ===/);
|
||||
assert.match(text, /=== Config/);
|
||||
});
|
||||
|
||||
test('buildSupportBundleText never uses an absolute source path as a section label', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-unlabeled-${Date.now()}.log`);
|
||||
fs.writeFileSync(tmp, 'safe content\n');
|
||||
try {
|
||||
const text = buildSupportBundleText({ sanitizedConfig: {}, files: [{ path: tmp }], secrets: [] });
|
||||
assert.ok(!text.includes(tmp));
|
||||
assert.ok(text.includes('=== log (size='));
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText redacts configured and pattern-detected secrets from included logs', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-secrets-${Date.now()}.log`);
|
||||
const configuredSecret = ['configured', 'Secret', '123456'].join('');
|
||||
@@ -297,73 +200,3 @@ test('buildSupportBundleText redacts configured and pattern-detected secrets fro
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText removes configured secrets of every non-empty length', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-short-secrets-${Date.now()}.log`);
|
||||
const config = {
|
||||
hosters: { 'voe.sx': [{ password: 'p1', apiKey: 'k2' }] },
|
||||
globalSettings: { diagnostics: { token: 't3' }, cookie: '', sessionId: null }
|
||||
};
|
||||
fs.writeFileSync(tmp, 'password=p1\napiKey=k2\ntoken=t3\n');
|
||||
try {
|
||||
const secrets = collectSecretValues(config);
|
||||
assert.deepEqual(new Set(secrets), new Set(['p1', 'k2', 't3']));
|
||||
const text = buildSupportBundleText({
|
||||
header: { Marker: 'p1-k2-t3' },
|
||||
sanitizedConfig: sanitizeConfig(config),
|
||||
secrets,
|
||||
files: [{ label: 'short-secrets.log', path: tmp }]
|
||||
});
|
||||
for (const secret of ['p1', 'k2', 't3']) assert.ok(!text.includes(secret), `configured secret survived: ${secret}`);
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText removes a one-character configured secret', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-one-character-secret-${Date.now()}.log`);
|
||||
const config = { hosters: { 'voe.sx': [{ password: 'x' }] } };
|
||||
fs.writeFileSync(tmp, 'password=x\n');
|
||||
try {
|
||||
const text = buildSupportBundleText({
|
||||
header: { Marker: 'secret:x' },
|
||||
sanitizedConfig: sanitizeConfig(config),
|
||||
secrets: collectSecretValues(config),
|
||||
files: [{ label: 'one-character.log', path: tmp }]
|
||||
});
|
||||
assert.ok(!text.includes('secret:x'));
|
||||
assert.ok(!text.includes('password=x'));
|
||||
assert.ok(text.includes('one-character.log'));
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
test('buildSupportBundleText contains no escaped secrets, credential HTML or absolute path variants', () => {
|
||||
const tmp = path.join(os.tmpdir(), `mhu-bundle-hard-redaction-${Date.now()}.log`);
|
||||
const secret = 'alpha"beta\\gamma';
|
||||
const paths = [
|
||||
'\\\\?\\UNC\\private-server\\secret-share\\hidden.log',
|
||||
'\\\\private-server\\secret-share\\hidden.log',
|
||||
'//private-server/secret-share/hidden.log',
|
||||
['C:', 'Users', 'PrivateProfile', 'hidden.log'].join('\\')
|
||||
];
|
||||
fs.writeFileSync(tmp, [
|
||||
JSON.stringify({ token: secret, path: paths[0] }),
|
||||
'<input type="password" value="abc>secret">',
|
||||
...paths
|
||||
].join('\n'));
|
||||
try {
|
||||
const text = buildSupportBundleText({
|
||||
header: { Source: paths[3] },
|
||||
sanitizedConfig: { marker: JSON.stringify(secret), path: paths[1] },
|
||||
secrets: [secret],
|
||||
files: [{ label: paths[2], path: tmp }]
|
||||
});
|
||||
for (const value of ['alpha', 'beta', 'gamma', 'abc>secret', 'private-server', 'secret-share', 'PrivateProfile', 'hidden.log', tmp]) {
|
||||
assert.ok(!text.includes(value), `support bundle leak survived: ${value}`);
|
||||
}
|
||||
} finally {
|
||||
fs.unlinkSync(tmp);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
function installHiddenElectronWindowHarness({ BrowserWindow, targetGlobal = globalThis }) {
|
||||
const requestedAlwaysOnTop = new WeakMap();
|
||||
const createdWindows = new Set();
|
||||
|
||||
class HiddenBrowserWindow extends BrowserWindow {
|
||||
constructor(options = {}) {
|
||||
super({
|
||||
...options,
|
||||
show: false,
|
||||
focusable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: false,
|
||||
paintWhenInitiallyHidden: true,
|
||||
webPreferences: {
|
||||
...(options.webPreferences || {}),
|
||||
offscreen: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
});
|
||||
createdWindows.add(this);
|
||||
if (typeof this.setIgnoreMouseEvents === 'function') this.setIgnoreMouseEvents(true);
|
||||
}
|
||||
|
||||
show() {}
|
||||
showInactive() {}
|
||||
focus() {}
|
||||
restore() {}
|
||||
moveTop() {}
|
||||
|
||||
setAlwaysOnTop(value) {
|
||||
requestedAlwaysOnTop.set(this, Boolean(value));
|
||||
}
|
||||
|
||||
isAlwaysOnTop() {
|
||||
return requestedAlwaysOnTop.get(this) === true;
|
||||
}
|
||||
}
|
||||
|
||||
targetGlobal.__mhuBrowserWindowConstructor = HiddenBrowserWindow;
|
||||
|
||||
return {
|
||||
getWindows() {
|
||||
return [...createdWindows].filter(window => typeof window.isDestroyed !== 'function' || !window.isDestroyed());
|
||||
},
|
||||
isAlwaysOnTopRequested(window) {
|
||||
return requestedAlwaysOnTop.get(window) === true;
|
||||
},
|
||||
isNativeSurfaceSuppressed(window) {
|
||||
return window.isVisible() === false && window.isFocused() === false;
|
||||
},
|
||||
areNativeSurfacesSuppressed(windows) {
|
||||
return windows.every(window => window.isVisible() === false && window.isFocused() === false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installHiddenElectronWindowHarness };
|
||||
+93
-1599
File diff suppressed because it is too large
Load Diff
@@ -23,75 +23,6 @@ test('release arguments reject a malformed transport tag', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('release notes accept English and reject German or French', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs } = await import(releasePlanUrl);
|
||||
for (const notes of [
|
||||
'Security improvements and more reliable updates.',
|
||||
'Uploads now resume correctly after network interruptions.',
|
||||
'Faster uploads, smoother recovery.',
|
||||
'Hardened credential redaction.'
|
||||
]) {
|
||||
assert.equal(
|
||||
createReleasePlan(parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', notes])).releaseBody,
|
||||
notes
|
||||
);
|
||||
}
|
||||
for (const notes of [
|
||||
'Sicherheitsverbesserungen und zuverlässigere Updates.',
|
||||
'Uploads werden nach Netzwerkunterbrechungen jetzt korrekt fortgesetzt.',
|
||||
'Améliorations de sécurité et mises à jour plus fiables.',
|
||||
'Les téléversements reprennent correctement après les interruptions réseau.',
|
||||
'Security update et corrections.',
|
||||
'Das Programm korrigiert Probleme im update.',
|
||||
'Uploads laufen wieder stabil.'
|
||||
]) {
|
||||
assert.throws(
|
||||
() => createReleasePlan(parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', notes])),
|
||||
/English release notes are required/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('release arguments reject unknown, misspelled, and duplicate options', async () => {
|
||||
const { parseReleaseArgs } = await import(releasePlanUrl);
|
||||
assert.deepEqual(
|
||||
parseReleaseArgs(['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Hardened credential redaction.', '--dry-run']),
|
||||
{
|
||||
version: '2.1.21',
|
||||
transportTag: 'v2.1.21',
|
||||
notes: 'Hardened credential redaction.',
|
||||
dryRun: true
|
||||
}
|
||||
);
|
||||
const invalidArgs = [
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--publish'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--dryrun'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '-dry-run'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '-notes', 'Release notes'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--dry-run', '--dry-run'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--notes', 'Release notes', '--notes', 'Other notes'],
|
||||
['2.1.21', '--transport-tag', 'v2.1.21', '--transport-tag', 'v2.1.22', '--notes', 'Release notes']
|
||||
];
|
||||
|
||||
for (const args of invalidArgs) {
|
||||
assert.throws(() => parseReleaseArgs(args), /option/i);
|
||||
}
|
||||
});
|
||||
|
||||
test(['GitHub and ', ['Gi', 'tea'].join(''), ' CI verify version tag pushes'].join(''), () => {
|
||||
const workflowPaths = ['.github/workflows/ci.yml', `${['.', ['gi', 'tea'].join('')].join('')}/workflows/ci.yml`];
|
||||
for (const relativePath of workflowPaths) {
|
||||
const workflow = fs.readFileSync(path.resolve(__dirname, '..', relativePath), 'utf8');
|
||||
const lines = workflow.split(/\r?\n/);
|
||||
const pushIndex = lines.indexOf(' push:');
|
||||
const pushTrigger = [];
|
||||
for (let index = pushIndex + 1; index < lines.length && lines[index].startsWith(' '); index++) {
|
||||
pushTrigger.push(lines[index]);
|
||||
}
|
||||
assert.ok(pushTrigger.includes(" tags: ['v*']"), relativePath);
|
||||
}
|
||||
});
|
||||
|
||||
test('matching GitHub release notes replace the private release body', async () => {
|
||||
const calls = [];
|
||||
const notes = await fetchGithubReleaseNotes('2.1.0', 'Private fallback', async (url, options) => {
|
||||
@@ -259,7 +190,7 @@ test('release plan keeps product artifacts separate from the transport tag', asy
|
||||
}, {
|
||||
version: '2.0.7',
|
||||
transportTag: 'v3.3.115',
|
||||
releaseTitle: 'Multi Hoster Uploader v2.0.7',
|
||||
releaseTitle: 'Multi-Hoster-Upload v2.0.7',
|
||||
releaseBody: 'Update visibility',
|
||||
expectedArtifacts: [
|
||||
'Multi-Hoster-Upload Setup 2.0.7.exe',
|
||||
@@ -271,29 +202,13 @@ test('release plan keeps product artifacts separate from the transport tag', asy
|
||||
});
|
||||
});
|
||||
|
||||
test('GitHub release metadata uses the normalized uploaded asset name', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs, renderLatestYml } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', 'Release notes']));
|
||||
const latestYml = renderLatestYml(plan, 'abc123', 456, '2026-08-13T12:00:00.000Z', plan.githubSetupName);
|
||||
|
||||
assert.deepEqual(plan.githubExpectedArtifacts, [
|
||||
'Multi-Hoster-Upload.Setup.2.1.20.exe',
|
||||
'Multi-Hoster-Upload.2.1.20.exe',
|
||||
'Multi-Hoster-Upload.Setup.2.1.20.exe.blockmap',
|
||||
'latest.yml'
|
||||
]);
|
||||
assert.match(latestYml, /url: Multi-Hoster-Upload\.Setup\.2\.1\.20\.exe/);
|
||||
assert.match(latestYml, /path: Multi-Hoster-Upload\.Setup\.2\.1\.20\.exe/);
|
||||
assert.doesNotMatch(latestYml, /Multi-Hoster-Upload Setup/);
|
||||
});
|
||||
|
||||
test('compatible existing release preserves the recovery id', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.0.1', '--transport-tag', 'v3.3.109', 'Bridge notes']));
|
||||
const release = {
|
||||
id: 81,
|
||||
tag_name: 'v3.3.109',
|
||||
name: 'Multi Hoster Uploader v2.0.1',
|
||||
name: 'Multi-Hoster-Upload v2.0.1',
|
||||
body: 'Bridge notes',
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
@@ -318,36 +233,6 @@ test('incompatible existing release title fails closed', async () => {
|
||||
|
||||
assert.throws(
|
||||
() => resolveExistingReleaseId(plan, release),
|
||||
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi Hoster Uploader v2\.0\.1"/
|
||||
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi-Hoster-Upload v2\.0\.1"/
|
||||
);
|
||||
});
|
||||
|
||||
test('existing release recovery rejects a mismatched transport tag', async () => {
|
||||
const { createReleasePlan, parseReleaseArgs, resolveExistingReleaseId } = await import(releasePlanUrl);
|
||||
const plan = createReleasePlan(parseReleaseArgs(['2.1.20', '--transport-tag', 'v2.1.20', 'Release notes']));
|
||||
const release = {
|
||||
id: 82,
|
||||
tag_name: 'v9.9.9',
|
||||
name: 'Multi Hoster Uploader v2.1.20'
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => resolveExistingReleaseId(plan, release),
|
||||
/existing release tag "v9\.9\.9" does not match "v2\.1\.20"/
|
||||
);
|
||||
});
|
||||
|
||||
test('checksum metadata accepts equivalent transport-specific installer separators', async () => {
|
||||
const sha = crypto.randomBytes(64).toString('base64');
|
||||
const metadata = await parseLatestYml('https://update.invalid/latest.yml', {
|
||||
version: '2.1.22',
|
||||
assetName: 'Multi-Hoster-Upload Setup 2.1.22.exe',
|
||||
assetSize: 456
|
||||
}, async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => `version: 2.1.22\npath: Multi-Hoster-Upload.Setup.2.1.22.exe\nsha512: ${sha}\nsize: 456\n`
|
||||
}));
|
||||
|
||||
assert.equal(metadata.path, 'Multi-Hoster-Upload.Setup.2.1.22.exe');
|
||||
});
|
||||
|
||||
+5
-228
@@ -6,17 +6,13 @@ const path = require('path');
|
||||
|
||||
test('internal audit records never contaminate the MDU session link log', async () => {
|
||||
let createUploadAuditWriter;
|
||||
let createUploadAuditEvents;
|
||||
try {
|
||||
({ createUploadAuditWriter, createUploadAuditEvents } = require('../lib/upload-audit'));
|
||||
({ createUploadAuditWriter } = require('../lib/upload-audit'));
|
||||
} catch {}
|
||||
assert.equal(typeof createUploadAuditWriter, 'function');
|
||||
assert.equal(typeof createUploadAuditEvents, 'function');
|
||||
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-'));
|
||||
const sessionLog = path.join(directory, '13-08-2026-mdu-session-14-20-123456.log');
|
||||
const debugLog = path.join(directory, 'upload-debug.log');
|
||||
fs.writeFileSync(debugLog, 'debug-before\r\n');
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
@@ -26,15 +22,13 @@ test('internal audit records never contaminate the MDU session link log', async
|
||||
reportError: () => {},
|
||||
retryDelays: [0]
|
||||
});
|
||||
const events = createUploadAuditEvents(writer, () => new Date('2026-08-13T12:00:00.000Z'));
|
||||
|
||||
await events.appendSourceCleanup({ outcome: 'deleted' });
|
||||
await events.appendUploadPlan({ fileCount: 2, destinationCount: 2, plannedUploadCount: 4 }, 'start');
|
||||
await writer.append('# SOURCE-CLEANUP {"outcome":"deleted"}\r\n', 'source-cleanup');
|
||||
await writer.append('# UPLOAD-PLAN {"plannedUploadCount":4}\r\n', 'upload-plan');
|
||||
|
||||
const auditLog = path.join(directory, 'upload-audit.log');
|
||||
assert.equal(fs.existsSync(sessionLog), false);
|
||||
assert.equal(fs.readFileSync(debugLog, 'utf8'), 'debug-before\r\n');
|
||||
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"timestamp":"2026-08-13T12:00:00.000Z","mode":"start","fileCount":2,"destinationCount":2,"plannedUploadCount":4}\r\n');
|
||||
assert.equal(fs.readFileSync(auditLog, 'utf8'), '# SOURCE-CLEANUP {"outcome":"deleted"}\r\n# UPLOAD-PLAN {"plannedUploadCount":4}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -57,7 +51,7 @@ test('audit writer reports the actual fallback file after a failed primary write
|
||||
}),
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); return true; },
|
||||
persistFallbackLogPath: async targetPath => { persistedFallbacks.push(targetPath); },
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0]
|
||||
});
|
||||
@@ -68,220 +62,3 @@ test('audit writer reports the actual fallback file after a failed primary write
|
||||
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('audit writer retries a safe target after sync or close durability failures', async (t) => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
for (const failedStage of ['sync', 'close']) {
|
||||
await t.test(failedStage, async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-upload-audit-${failedStage}-`));
|
||||
const primaryLog = path.join(directory, 'primary', 'fileuploader.log');
|
||||
const fallbackLog = path.join(directory, 'fallback', 'fileuploader.log');
|
||||
const primaryAudit = path.join(directory, 'primary', 'upload-audit.log');
|
||||
const fallbackAudit = path.join(directory, 'fallback', 'upload-audit.log');
|
||||
const syncCalls = [];
|
||||
const closeCalls = [];
|
||||
const reports = [];
|
||||
const durabilityFs = {
|
||||
...fs,
|
||||
promises: {
|
||||
...fs.promises,
|
||||
open: async (targetPath, flags) => {
|
||||
const handle = await fs.promises.open(targetPath, flags);
|
||||
return {
|
||||
appendFile: handle.appendFile.bind(handle),
|
||||
sync: async () => {
|
||||
syncCalls.push(targetPath);
|
||||
if (targetPath === primaryAudit && failedStage === 'sync') throw new Error('controlled sync failure');
|
||||
return handle.sync();
|
||||
},
|
||||
close: async () => {
|
||||
closeCalls.push(targetPath);
|
||||
await handle.close();
|
||||
if (targetPath === primaryAudit && failedStage === 'close') throw new Error('controlled close failure');
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
const writer = createUploadAuditWriter({
|
||||
fs: durabilityFs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
if (!excluded.has(primaryLog)) return { path: primaryLog, isFallback: false };
|
||||
if (!excluded.has(fallbackLog)) return { path: fallbackLog, isFallback: true };
|
||||
return null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async () => true,
|
||||
reportError: (label, error) => reports.push({ label, message: error.message }),
|
||||
retryDelays: [0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
|
||||
assert.equal(writer.getActivePath(), fallbackAudit);
|
||||
assert.deepEqual(syncCalls, [primaryAudit, fallbackAudit]);
|
||||
assert.deepEqual(closeCalls, [primaryAudit, fallbackAudit]);
|
||||
assert.equal(fs.readFileSync(fallbackAudit, 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
assert.equal(reports.some(report => report.label === 'upload-plan' && report.message.includes(failedStage)), true);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('audit writer fails closed when no target can sync the appended bytes', async () => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-sync-exhausted-'));
|
||||
const targets = ['first', 'second'].map(name => path.join(directory, name, 'fileuploader.log'));
|
||||
const durabilityFs = {
|
||||
...fs,
|
||||
promises: {
|
||||
...fs.promises,
|
||||
open: async (targetPath, flags) => {
|
||||
const handle = await fs.promises.open(targetPath, flags);
|
||||
return {
|
||||
appendFile: handle.appendFile.bind(handle),
|
||||
sync: async () => { throw new Error(`controlled sync failure: ${targetPath}`); },
|
||||
close: handle.close.bind(handle)
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
const writer = createUploadAuditWriter({
|
||||
fs: durabilityFs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
const targetPath = targets.find(candidate => !excluded.has(candidate));
|
||||
return targetPath ? { path: targetPath, isFallback: true } : null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async () => true,
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), false);
|
||||
assert.equal(writer.getActivePath(), null);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('audit writer rejects false and thrown fallback persistence before trying the next safe target', async (t) => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
for (const rejection of ['false', 'throw']) {
|
||||
await t.test(rejection, async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `mhu-upload-audit-${rejection}-`));
|
||||
const first = path.join(directory, 'first', 'fileuploader.log');
|
||||
const second = path.join(directory, 'second', 'fileuploader.log');
|
||||
const targets = [first, second].map(targetPath => ({ path: targetPath, isFallback: true }));
|
||||
const persistedFallbacks = [];
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
|
||||
return targets.find(target => !excludedPaths.has(target.path)) || null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => {
|
||||
persistedFallbacks.push(targetPath);
|
||||
if (targetPath === first) {
|
||||
if (rejection === 'throw') throw new Error('settings write failed');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), true);
|
||||
assert.equal(writer.getActivePath(), path.join(directory, 'second', 'upload-audit.log'));
|
||||
assert.deepEqual(persistedFallbacks, [first, second]);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
|
||||
assert.equal(fs.readFileSync(writer.getActivePath(), 'utf8'), '# UPLOAD-PLAN {}\r\n');
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('audit writer returns false only after every allowed fallback target is rejected', async () => {
|
||||
const { createUploadAuditWriter } = require('../lib/upload-audit');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-upload-audit-exhausted-'));
|
||||
const targets = ['first', 'second'].map(name => ({ path: path.join(directory, name, 'fileuploader.log'), isFallback: true }));
|
||||
const persistedFallbacks = [];
|
||||
const writer = createUploadAuditWriter({
|
||||
fs,
|
||||
path,
|
||||
resolveUploadLogTarget: excluded => {
|
||||
const excludedPaths = excluded instanceof Set ? excluded : new Set(excluded ? [excluded] : []);
|
||||
return targets.find(target => !excludedPaths.has(target.path)) || null;
|
||||
},
|
||||
rotateLogFile: () => {},
|
||||
invalidateUploadLogTarget: () => {},
|
||||
persistFallbackLogPath: async targetPath => {
|
||||
persistedFallbacks.push(targetPath);
|
||||
if (persistedFallbacks.length === 2) throw new Error('settings write failed');
|
||||
return false;
|
||||
},
|
||||
reportError: () => {},
|
||||
retryDelays: [0, 0, 0]
|
||||
});
|
||||
|
||||
assert.equal(await writer.append('# UPLOAD-PLAN {}\r\n', 'upload-plan'), false);
|
||||
assert.deepEqual(persistedFallbacks, targets.map(target => target.path));
|
||||
assert.equal(writer.getActivePath(), null);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'first', 'upload-audit.log')), false);
|
||||
assert.equal(fs.existsSync(path.join(directory, 'second', 'upload-audit.log')), false);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('durable audit gate never creates a manager or adds jobs after a false or thrown audit', async () => {
|
||||
const { runAfterDurableAudit } = require('../lib/upload-audit');
|
||||
assert.equal(typeof runAfterDurableAudit, 'function');
|
||||
for (const actionName of ['manager', 'addJobs']) {
|
||||
for (const audit of [async () => false, async () => { throw new Error('audit failed'); }]) {
|
||||
let actions = 0;
|
||||
const result = await runAfterDurableAudit(audit, () => { actions += 1; return actionName; });
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(actions, 0);
|
||||
}
|
||||
}
|
||||
|
||||
let actions = 0;
|
||||
const result = await runAfterDurableAudit(async () => true, () => { actions += 1; return 'started'; });
|
||||
assert.deepEqual(result, { ok: true, value: 'started' });
|
||||
assert.equal(actions, 1);
|
||||
});
|
||||
|
||||
test('audit failure message is localized and tells the user how to retry', () => {
|
||||
const { getUploadAuditFailureMessage } = require('../lib/upload-audit');
|
||||
const german = getUploadAuditFailureMessage('de');
|
||||
const english = getUploadAuditFailureMessage('en');
|
||||
assert.match(german, /Log-Pfad/);
|
||||
assert.match(german, /erneut/);
|
||||
assert.match(english, /log path/i);
|
||||
assert.match(english, /try again/i);
|
||||
assert.notEqual(german, english);
|
||||
});
|
||||
|
||||
test('main process audits batch plans before creating or mutating upload work', () => {
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const startHandler = mainSource.slice(
|
||||
mainSource.indexOf("ipcMain.handle('start-upload'"),
|
||||
mainSource.indexOf("ipcMain.handle('cancel-upload'")
|
||||
);
|
||||
const addHandler = mainSource.slice(
|
||||
mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"),
|
||||
mainSource.indexOf("ipcMain.handle('finish-after-active'")
|
||||
);
|
||||
|
||||
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('new UploadManager('));
|
||||
assert.ok(startHandler.indexOf("appendUploadPlanAudit(batchPlan, 'start')") < startHandler.indexOf('persistRotation(pick)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('persistRotation(pick)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('registerGroups(sourceCleanupGroups)'));
|
||||
assert.ok(addHandler.indexOf("appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add')") < addHandler.indexOf('batchManager.addJobs(tasks)'));
|
||||
assert.doesNotMatch(mainSource, /debugLog\(`source-cleanup:/);
|
||||
assert.doesNotMatch(mainSource, /debugLog\(`upload-plan:/);
|
||||
});
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { assertUploadConfirmation, selectPublicUploadUrl } = require('../lib/upload-confirmation');
|
||||
const { assertUploadConfirmation } = require('../lib/upload-confirmation');
|
||||
|
||||
test('selects only a real public URL for logs and exports', () => {
|
||||
assert.equal(selectPublicUploadUrl({ download_url: 'https://doodstream.com/d/abc123', file_code: 'abc123' }), 'https://doodstream.com/d/abc123');
|
||||
assert.equal(selectPublicUploadUrl({ embed_url: 'https://voe.sx/e/abc123', file_code: 'abc123' }), 'https://voe.sx/e/abc123');
|
||||
assert.equal(selectPublicUploadUrl({ download_url: 'javascript:alert(1)', file_code: 'abc123' }), '');
|
||||
assert.equal(selectPublicUploadUrl({ file_code: 'abc123' }), '');
|
||||
});
|
||||
|
||||
test('materializes canonical Doodstream URLs from a confirmed file code', () => {
|
||||
assert.deepEqual(
|
||||
assertUploadConfirmation({ file_code: 'AB1', download_url: null, embed_url: null }, 'doodstream.com'),
|
||||
{
|
||||
file_code: 'AB1',
|
||||
download_url: 'https://doodstream.com/d/AB1',
|
||||
embed_url: 'https://doodstream.com/e/AB1'
|
||||
}
|
||||
);
|
||||
test('accepts a host-confirmed file code without a public URL', () => {
|
||||
const result = { file_code: 'AB1', download_url: null, embed_url: null };
|
||||
assert.equal(assertUploadConfirmation(result, 'doodstream.com'), result);
|
||||
});
|
||||
|
||||
test('accepts upload URLs for every supported hoster and its subdomains', () => {
|
||||
@@ -31,15 +18,7 @@ test('accepts upload URLs for every supported hoster and its subdomains', () =>
|
||||
];
|
||||
for (const [hoster, downloadUrl] of cases) {
|
||||
const result = { file_code: 'abc123', download_url: downloadUrl };
|
||||
const confirmed = assertUploadConfirmation(result, hoster);
|
||||
if (hoster === 'doodstream.com') {
|
||||
assert.deepEqual(confirmed, {
|
||||
...result,
|
||||
embed_url: 'https://doodstream.com/e/abc123'
|
||||
});
|
||||
} else {
|
||||
assert.equal(confirmed, result);
|
||||
}
|
||||
assert.equal(assertUploadConfirmation(result, hoster), result);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,40 +48,6 @@ test('accepts the Doodstream result domain returned by the current upload servic
|
||||
});
|
||||
});
|
||||
|
||||
test('rebuilds every accepted Doodstream transport URL from the file code', () => {
|
||||
const variants = [
|
||||
'http://dsvplay.com/d/DOODCODE1234?token=SYNTHETIC_SECRET#fragment',
|
||||
'https://edge.dsvplay.com/result/DOODCODE1234?session=SYNTHETIC_SESSION',
|
||||
'https://dood.to/e/DOODCODE1234',
|
||||
'https://dood.la/arbitrary/DOODCODE1234'
|
||||
];
|
||||
|
||||
for (const downloadUrl of variants) {
|
||||
assert.deepEqual(
|
||||
assertUploadConfirmation({ file_code: 'DOODCODE1234', download_url: downloadUrl }, 'doodstream.com'),
|
||||
{
|
||||
file_code: 'DOODCODE1234',
|
||||
download_url: 'https://doodstream.com/d/DOODCODE1234',
|
||||
embed_url: 'https://doodstream.com/e/DOODCODE1234'
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects code-only confirmations for hosters without canonical materialization', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'BYSE123' }, 'byse.sx'),
|
||||
/Upload zu byse\.sx wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-HTTPS public URLs outside Doodstream transport normalization', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'VOE123', download_url: 'http://voe.sx/VOE123' }, 'voe.sx'),
|
||||
/Upload zu voe\.sx wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an upload URL from a different domain', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'https://attacker.invalid/file/abc123' }, 'voe.sx'),
|
||||
|
||||
@@ -25,40 +25,3 @@ test('bereinigt Zugangsdaten und mehrere URLs aus Hosterantworten', () => {
|
||||
|
||||
assert.equal(result.responseSnippet, 'token=[redacted] one.example/a password: [redacted] two.example/b');
|
||||
});
|
||||
|
||||
test('behält sichere Transportdetails für eine konkrete Fehleranalyse', () => {
|
||||
const result = normalizeFailureDetails({
|
||||
phase: 'web-upload-confirmation',
|
||||
http: 502,
|
||||
contentType: 'application/json',
|
||||
safeEndpointHost: 'upload.doodstream.com',
|
||||
responseKind: 'json',
|
||||
retryable: true,
|
||||
payloadSnippet: 'json response (148 bytes)'
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
phase: 'web-upload-confirmation',
|
||||
httpStatus: 502,
|
||||
contentType: 'application/json',
|
||||
endpointHost: 'upload.doodstream.com',
|
||||
responseKind: 'json',
|
||||
retryable: true,
|
||||
responseSnippet: 'json response (148 bytes)'
|
||||
});
|
||||
});
|
||||
|
||||
test('verwirft unsichere Transportfelder und unbenannte lange Geheimnisse', () => {
|
||||
const secret = 'SYNTHETIC_UNNAMED_SECRET_1234567890';
|
||||
const result = normalizeFailureDetails({
|
||||
phase: `upload ${secret}`,
|
||||
safeEndpointHost: `evil.example/${secret}`,
|
||||
responseKind: `json-${secret}`,
|
||||
payloadSnippet: `Authorization: Bearer ${secret}`
|
||||
});
|
||||
|
||||
assert.equal(result.phase, 'upload [redacted]');
|
||||
assert.equal(result.endpointHost, undefined);
|
||||
assert.equal(result.responseKind, undefined);
|
||||
assert.doesNotMatch(JSON.stringify(result), /SYNTHETIC_UNNAMED_SECRET/);
|
||||
});
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
|
||||
function loadMainFunction(name, nextName) {
|
||||
const start = mainSource.indexOf(`function ${name}(`);
|
||||
assert.notEqual(start, -1, `${name} must exist`);
|
||||
const end = mainSource.indexOf(`\nfunction ${nextName}(`, start + name.length);
|
||||
assert.notEqual(end, -1, `${nextName} must follow ${name}`);
|
||||
const context = vm.createContext({});
|
||||
vm.runInContext(`${mainSource.slice(start, end)}\nthis.loaded = ${name};`, context);
|
||||
return context.loaded;
|
||||
}
|
||||
|
||||
test('terminal finalization is delivered once per ready renderer generation and rejects stale acknowledgements', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
const sent = [];
|
||||
const savedQueues = [];
|
||||
const scheduled = new Set();
|
||||
let deliverySequence = 0;
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
sent.push(JSON.parse(JSON.stringify(payload)));
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => {
|
||||
savedQueues.push(JSON.parse(JSON.stringify(pendingQueue)));
|
||||
},
|
||||
schedule: callback => {
|
||||
const token = { callback };
|
||||
scheduled.add(token);
|
||||
return token;
|
||||
},
|
||||
cancelSchedule: token => scheduled.delete(token),
|
||||
createFinalizationId: () => 'finalization-1',
|
||||
createDeliveryId: () => `delivery-${++deliverySequence}`,
|
||||
timeoutMs: 15000
|
||||
});
|
||||
|
||||
const completion = coordinator.request({ id: 'batch-1', files: [] }, true);
|
||||
let settled = false;
|
||||
completion.then(() => { settled = true; });
|
||||
|
||||
assert.equal(sent.length, 0);
|
||||
assert.equal(coordinator.rendererReady(), 1);
|
||||
assert.equal(coordinator.rendererReady(), 1);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].finalizationId, 'finalization-1');
|
||||
assert.equal(sent[0].deliveryId, 'delivery-1');
|
||||
assert.equal(sent[0].historyPersisted, true);
|
||||
|
||||
coordinator.rendererBlocked();
|
||||
assert.equal(coordinator.rendererReady(), 2);
|
||||
assert.equal(sent.length, 2);
|
||||
assert.equal(sent[1].deliveryId, 'delivery-2');
|
||||
|
||||
const stale = await coordinator.complete({
|
||||
finalizationId: 'finalization-1',
|
||||
deliveryId: 'delivery-1',
|
||||
pendingQueue: { queueJobs: [{ id: 'stale' }] }
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(stale, false);
|
||||
assert.equal(settled, false);
|
||||
assert.equal(savedQueues.length, 0);
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'finalization-1',
|
||||
deliveryId: 'delivery-2',
|
||||
pendingQueue: { queueJobs: [{ id: 'terminal' }] }
|
||||
});
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(await completion, true);
|
||||
assert.deepEqual(savedQueues, [{ queueJobs: [{ id: 'terminal' }] }]);
|
||||
assert.equal(scheduled.size, 0);
|
||||
|
||||
coordinator.rendererBlocked();
|
||||
coordinator.rendererReady();
|
||||
assert.equal(sent.length, 2);
|
||||
});
|
||||
|
||||
test('history failure acknowledgement requires every terminal job in the durable queue snapshot', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
const savedQueues = [];
|
||||
let deliveryId = null;
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => 'history-failure-finalization',
|
||||
createDeliveryId: () => 'history-failure-delivery'
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [
|
||||
{ jobId: 'done-a', status: 'done' },
|
||||
{ jobId: 'error-b', status: 'error' }
|
||||
] }]
|
||||
}, false);
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'history-failure-finalization',
|
||||
deliveryId,
|
||||
pendingQueue: { queueJobs: [{ id: 'done-a', status: 'done' }] }
|
||||
});
|
||||
|
||||
assert.equal(accepted, false);
|
||||
assert.equal(await completion, false);
|
||||
assert.deepEqual(savedQueues, []);
|
||||
});
|
||||
|
||||
test('history failure acknowledgement requires exact terminal failure evidence', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
const cases = [
|
||||
{
|
||||
name: 'error',
|
||||
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
|
||||
queue: { error: 'different error', failureDetails: { reason: 'rate-limit', code: 429 }, remoteCommitUncertain: true }
|
||||
},
|
||||
{
|
||||
name: 'failureDetails',
|
||||
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
|
||||
queue: { error: 'upload rejected', failureDetails: { code: 500, reason: 'rate-limit' }, remoteCommitUncertain: true }
|
||||
},
|
||||
{
|
||||
name: 'remoteCommitUncertain',
|
||||
summary: { error: 'upload rejected', failureDetails: { code: 429, reason: 'rate-limit' }, remoteCommitUncertain: true },
|
||||
queue: { error: 'upload rejected', failureDetails: { reason: 'rate-limit', code: 429 }, remoteCommitUncertain: false }
|
||||
}
|
||||
];
|
||||
|
||||
for (const entry of cases) {
|
||||
let deliveryId = null;
|
||||
const savedQueues = [];
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => `failure-${entry.name}`,
|
||||
createDeliveryId: () => `delivery-${entry.name}`
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [{ jobId: 'error-job', status: 'error', ...entry.summary }] }]
|
||||
}, false);
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: `failure-${entry.name}`,
|
||||
deliveryId,
|
||||
pendingQueue: { queueJobs: [{ id: 'error-job', status: 'error', ...entry.queue }] }
|
||||
});
|
||||
|
||||
assert.equal(accepted, false, entry.name);
|
||||
assert.equal(await completion, false, entry.name);
|
||||
assert.deepEqual(savedQueues, [], entry.name);
|
||||
}
|
||||
});
|
||||
|
||||
test('history failure acknowledgement accepts complete terminal failure evidence', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
let deliveryId = null;
|
||||
const savedQueues = [];
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => 'complete-error-finalization',
|
||||
createDeliveryId: () => 'complete-error-delivery'
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [{
|
||||
jobId: 'error-job',
|
||||
status: 'error',
|
||||
error: 'upload rejected',
|
||||
failureDetails: { code: 429, reason: 'rate-limit' },
|
||||
remoteCommitUncertain: true
|
||||
}] }]
|
||||
}, false);
|
||||
const pendingQueue = { queueJobs: [{
|
||||
id: 'error-job',
|
||||
status: 'error',
|
||||
error: 'upload rejected',
|
||||
failureDetails: { reason: 'rate-limit', code: 429 },
|
||||
remoteCommitUncertain: true
|
||||
}] };
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'complete-error-finalization',
|
||||
deliveryId,
|
||||
pendingQueue
|
||||
});
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(await completion, true);
|
||||
assert.deepEqual(savedQueues, [pendingQueue]);
|
||||
});
|
||||
|
||||
test('history failure acknowledgement compares complete done results independent of object key order', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
let deliveryId = null;
|
||||
const savedQueues = [];
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => 'done-result-finalization',
|
||||
createDeliveryId: () => 'done-result-delivery'
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [{
|
||||
jobId: 'done-job',
|
||||
status: 'done',
|
||||
download_url: 'https://doodstream.com/d/abc123',
|
||||
embed_url: 'https://doodstream.com/e/abc123',
|
||||
file_code: 'abc123'
|
||||
}] }]
|
||||
}, false);
|
||||
const pendingQueue = { queueJobs: [{
|
||||
id: 'done-job',
|
||||
status: 'done',
|
||||
result: {
|
||||
file_code: 'abc123',
|
||||
embed_url: 'https://doodstream.com/e/abc123',
|
||||
download_url: 'https://doodstream.com/d/abc123'
|
||||
}
|
||||
}] };
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'done-result-finalization',
|
||||
deliveryId,
|
||||
pendingQueue
|
||||
});
|
||||
|
||||
assert.equal(accepted, true);
|
||||
assert.equal(await completion, true);
|
||||
assert.deepEqual(savedQueues, [pendingQueue]);
|
||||
});
|
||||
|
||||
test('history failure acknowledgement rejects incomplete done results', async () => {
|
||||
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||
let deliveryId = null;
|
||||
const savedQueues = [];
|
||||
const coordinator = createCoordinator({
|
||||
send: payload => {
|
||||
deliveryId = payload.deliveryId;
|
||||
return true;
|
||||
},
|
||||
saveQueue: async pendingQueue => savedQueues.push(pendingQueue),
|
||||
schedule: () => null,
|
||||
cancelSchedule: () => {},
|
||||
createFinalizationId: () => 'incomplete-done-finalization',
|
||||
createDeliveryId: () => 'incomplete-done-delivery'
|
||||
});
|
||||
coordinator.rendererReady();
|
||||
const completion = coordinator.request({
|
||||
files: [{ results: [{
|
||||
jobId: 'done-job',
|
||||
status: 'done',
|
||||
download_url: 'https://doodstream.com/d/abc123',
|
||||
embed_url: 'https://doodstream.com/e/abc123',
|
||||
file_code: 'abc123'
|
||||
}] }]
|
||||
}, false);
|
||||
|
||||
const accepted = await coordinator.complete({
|
||||
finalizationId: 'incomplete-done-finalization',
|
||||
deliveryId,
|
||||
pendingQueue: { queueJobs: [{
|
||||
id: 'done-job',
|
||||
status: 'done',
|
||||
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
|
||||
}] }
|
||||
});
|
||||
|
||||
assert.equal(accepted, false);
|
||||
assert.equal(await completion, false);
|
||||
assert.deepEqual(savedQueues, []);
|
||||
});
|
||||
|
||||
test('history failure remains visible to the durable terminal finalization barrier', async () => {
|
||||
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||
const recoveries = [];
|
||||
const finalizationCalls = [];
|
||||
const errors = [];
|
||||
const barrier = createBarrier({
|
||||
appendHistory: async () => { throw new Error('history unavailable'); },
|
||||
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
|
||||
requestFinalization: async (summary, historyPersisted) => {
|
||||
finalizationCalls.push({ summary: JSON.parse(JSON.stringify(summary)), historyPersisted });
|
||||
return false;
|
||||
},
|
||||
buildTerminalSnapshots: summary => summary.files[0].results.map(result => ({ jobId: result.jobId, status: result.status })),
|
||||
now: () => '2026-08-13T12:00:00.000Z',
|
||||
onError: (phase, error) => errors.push([phase, error.message])
|
||||
});
|
||||
const summary = {
|
||||
id: 'skipped-batch',
|
||||
files: [{ name: 'missing-account.bin', results: [{ jobId: 'skip-1', status: 'skipped' }] }]
|
||||
};
|
||||
|
||||
const result = await barrier.finalize(summary, {
|
||||
id: 'recovery-skipped',
|
||||
startedAt: '2026-08-13T11:59:59.000Z',
|
||||
jobIds: ['skip-1']
|
||||
});
|
||||
|
||||
assert.equal(result.historyPersisted, false);
|
||||
assert.equal(result.queuePersisted, false);
|
||||
assert.equal(result.terminalRecoveryPersisted, true);
|
||||
assert.deepEqual(finalizationCalls, [{ summary, historyPersisted: false }]);
|
||||
assert.deepEqual(recoveries, [{
|
||||
id: 'recovery-skipped',
|
||||
startedAt: '2026-08-13T11:59:59.000Z',
|
||||
jobIds: ['skip-1'],
|
||||
settledAt: '2026-08-13T12:00:00.000Z',
|
||||
historyPending: true,
|
||||
terminalJobs: [{ jobId: 'skip-1', status: 'skipped' }]
|
||||
}]);
|
||||
assert.deepEqual(errors, [['history', 'history unavailable']]);
|
||||
});
|
||||
|
||||
test('terminal recovery clears after history and queue are durable', async () => {
|
||||
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||
const recoveries = [];
|
||||
const barrier = createBarrier({
|
||||
appendHistory: async () => true,
|
||||
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
|
||||
requestFinalization: async (_summary, historyPersisted) => historyPersisted,
|
||||
buildTerminalSnapshots: () => [{ jobId: 'done-1', status: 'done' }],
|
||||
now: () => '2026-08-13T12:01:00.000Z',
|
||||
onError: () => {}
|
||||
});
|
||||
|
||||
const result = await barrier.finalize({ id: 'done-batch', files: [] }, {
|
||||
id: 'recovery-done',
|
||||
startedAt: '2026-08-13T12:00:00.000Z',
|
||||
jobIds: ['done-1']
|
||||
});
|
||||
|
||||
assert.equal(result.historyPersisted, true);
|
||||
assert.equal(result.queuePersisted, true);
|
||||
assert.equal(result.terminalRecoveryPersisted, true);
|
||||
assert.equal(result.recoveryCleared, true);
|
||||
assert.equal(recoveries.length, 2);
|
||||
assert.equal(recoveries[1], null);
|
||||
});
|
||||
|
||||
test('terminal recovery remains when queue is durable but history is pending', async () => {
|
||||
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||
const recoveries = [];
|
||||
const barrier = createBarrier({
|
||||
appendHistory: async () => { throw new Error('history unavailable'); },
|
||||
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
|
||||
requestFinalization: async () => true,
|
||||
buildTerminalSnapshots: () => [{ jobId: 'done-1', status: 'done' }],
|
||||
now: () => '2026-08-13T12:02:00.000Z',
|
||||
onError: () => {}
|
||||
});
|
||||
|
||||
const result = await barrier.finalize({ id: 'history-pending-batch', files: [] }, {
|
||||
id: 'recovery-history-pending',
|
||||
startedAt: '2026-08-13T12:01:00.000Z',
|
||||
jobIds: ['done-1']
|
||||
});
|
||||
|
||||
assert.equal(result.historyPersisted, false);
|
||||
assert.equal(result.queuePersisted, true);
|
||||
assert.equal(result.terminalRecoveryPersisted, true);
|
||||
assert.equal(result.recoveryCleared, false);
|
||||
assert.deepEqual(recoveries, [{
|
||||
id: 'recovery-history-pending',
|
||||
startedAt: '2026-08-13T12:01:00.000Z',
|
||||
jobIds: ['done-1'],
|
||||
settledAt: '2026-08-13T12:02:00.000Z',
|
||||
historyPending: true,
|
||||
terminalJobs: [{ jobId: 'done-1', status: 'done' }]
|
||||
}]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -70,119 +70,6 @@ describe('UploadManager', () => {
|
||||
assert.ok(events.length > 0, 'should emit at least one progress event');
|
||||
});
|
||||
|
||||
it('waits outside the upload schedule and starts on a live settings update without consuming an attempt', async () => {
|
||||
const mgr = new UploadManager({}, {
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
|
||||
});
|
||||
const events = [];
|
||||
mgr.on('progress', event => events.push(event));
|
||||
const done = new Promise(resolve => mgr.once('batch-done', resolve));
|
||||
|
||||
const batch = mgr.startBatch([{
|
||||
file: '/test/scheduled.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
jobId: 'scheduled-job'
|
||||
}]);
|
||||
await new Promise(setImmediate);
|
||||
await new Promise(setImmediate);
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(events.some(event => event.status === 'getting-server' || event.status === 'uploading'), false);
|
||||
|
||||
mgr.updateSettings(null, { uploadSchedule: { enabled: false } });
|
||||
await batch;
|
||||
const summary = await done;
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||
assert.equal(result.status, 'done');
|
||||
assert.equal(result.attempt, 1);
|
||||
});
|
||||
|
||||
it('cancels a schedule waiter without starting transport or recording a failure', async () => {
|
||||
const mgr = new UploadManager({}, {
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
|
||||
});
|
||||
const done = new Promise(resolve => mgr.once('batch-done', resolve));
|
||||
const batch = mgr.startBatch([{
|
||||
file: '/test/scheduled-cancel.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
jobId: 'scheduled-cancel-job'
|
||||
}]);
|
||||
await new Promise(setImmediate);
|
||||
mgr.cancel();
|
||||
await batch;
|
||||
const summary = await done;
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(result.status, 'aborted');
|
||||
assert.equal(result.attempt, 0);
|
||||
});
|
||||
|
||||
it('finishAfterActive wakes a closed schedule waiter without starting transport', async () => {
|
||||
const mgr = new UploadManager({}, {
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
|
||||
});
|
||||
const done = new Promise(resolve => mgr.once('batch-done', resolve));
|
||||
const batch = mgr.startBatch([{
|
||||
jobId: 'scheduled-stop',
|
||||
file: '/test/scheduled-stop.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
mgr.finishAfterActive();
|
||||
await batch;
|
||||
const summary = await done;
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(result.status, 'aborted');
|
||||
assert.equal(result.attempt, 0);
|
||||
});
|
||||
|
||||
it('releases an acquired slot when the schedule closes and re-admits after reopening', async () => {
|
||||
let releaseFirst;
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
|
||||
if (filePath === '/test/schedule-first.mp4') {
|
||||
return new Promise(resolve => { releaseFirst = () => resolve({ download_url: `https://${hoster}/first`, file_code: 'first' }); });
|
||||
}
|
||||
return { download_url: `https://${hoster}/second`, file_code: 'second' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1 }
|
||||
}, {
|
||||
uploadSchedule: { enabled: false }
|
||||
});
|
||||
const done = new Promise(resolve => mgr.once('batch-done', resolve));
|
||||
const batch = mgr.startBatch([
|
||||
{ file: '/test/schedule-first.mp4', hoster: 'doodstream.com', apiKey: 'key1', jobId: 'schedule-first' },
|
||||
{ file: '/test/schedule-second.mp4', hoster: 'doodstream.com', apiKey: 'key1', jobId: 'schedule-second' }
|
||||
]);
|
||||
for (let index = 0; index < 50 && typeof releaseFirst !== 'function'; index++) await new Promise(setImmediate);
|
||||
assert.equal(typeof releaseFirst, 'function');
|
||||
|
||||
mgr.updateSettings(null, {
|
||||
uploadSchedule: { enabled: true, weekdays: [], start: '08:00', end: '09:00' }
|
||||
});
|
||||
releaseFirst();
|
||||
for (let index = 0; index < 10; index++) await new Promise(setImmediate);
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||
assert.equal(mgr._getSemaphore('doodstream.com').active, 0);
|
||||
|
||||
mgr.updateSettings(null, { uploadSchedule: { enabled: false } });
|
||||
await batch;
|
||||
const summary = await done;
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 2);
|
||||
assert.equal(summary.succeeded, 2);
|
||||
});
|
||||
|
||||
it('emits job-settled after releasing job resources', async () => {
|
||||
const mgr = new UploadManager({});
|
||||
let settled;
|
||||
@@ -264,8 +151,8 @@ describe('UploadManager', () => {
|
||||
mgr.on('batch-done', (s) => { summary = s; });
|
||||
|
||||
await mgr.startBatch([
|
||||
{ file: '/test/video1.mp4', fileKey: 'video-one', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ file: '/test/video2.mp4', fileKey: 'video-two', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
{ file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ file: '/test/video2.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
assert.ok(summary);
|
||||
@@ -273,8 +160,6 @@ describe('UploadManager', () => {
|
||||
assert.equal(summary.succeeded, 2);
|
||||
assert.equal(summary.failed, 0);
|
||||
assert.equal(summary.files.length, 2);
|
||||
assert.deepEqual(summary.files.map(file => file.fileKey), ['video-one', 'video-two']);
|
||||
assert.ok(summary.files.flatMap(file => file.results).every(result => typeof result.jobId === 'string' && result.jobId.length > 0));
|
||||
});
|
||||
|
||||
it('emits a final idle stats snapshot after a normal batch', async () => {
|
||||
@@ -417,50 +302,6 @@ describe('UploadManager', () => {
|
||||
assert.equal(statuses.filter((status) => status === 'aborted').length, 0);
|
||||
});
|
||||
|
||||
it('cancels one selected job followed by all 100 active uploads without retaining resources', async () => {
|
||||
let active = 0;
|
||||
let started = 0;
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
||||
started++;
|
||||
active++;
|
||||
await new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
active--;
|
||||
reject(new Error('Aborted'));
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 100, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
}, { parallelUploadCount: 100 });
|
||||
const tasks = Array.from({ length: 100 }, (_, index) => ({
|
||||
jobId: `active-cancel-${index}`,
|
||||
file: `/test/active-cancel-${index}.mp4`,
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}));
|
||||
const batchPromise = mgr.startBatch(tasks);
|
||||
|
||||
for (let attempt = 0; attempt < 200 && started < 100; attempt++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(started, 100);
|
||||
assert.equal(active, 100);
|
||||
|
||||
const cancelStartedAt = performance.now();
|
||||
mgr.cancelJobs(['active-cancel-0']);
|
||||
mgr.cancel();
|
||||
await batchPromise;
|
||||
const cancelDuration = performance.now() - cancelStartedAt;
|
||||
|
||||
assert.ok(cancelDuration < 1000, `cancel took ${cancelDuration.toFixed(1)}ms`);
|
||||
assert.equal(active, 0);
|
||||
assert.equal(mgr.running, false);
|
||||
assert.equal(mgr.jobAbortControllers.size, 0);
|
||||
assert.equal(mgr.statsInterval, null);
|
||||
});
|
||||
|
||||
it('maxSizeMb filter skips oversized files', async () => {
|
||||
fakeFileSize = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
@@ -652,269 +493,7 @@ describe('UploadManager', () => {
|
||||
|
||||
assert.equal(settled.at(-1).status, 'aborted');
|
||||
assert.equal(progress.some(event => event.status === 'done'), false);
|
||||
assert.equal(progress.at(-1).remoteCommitUncertain, true);
|
||||
assert.equal(summary.succeeded, 0);
|
||||
assert.equal(summary.files[0].results[0].remoteCommitUncertain, true);
|
||||
});
|
||||
|
||||
it('keeps a confirmed result when cancellation arrives after confirmation', async () => {
|
||||
const mgr = new UploadManager({});
|
||||
const originalExecuteUpload = mgr._executeUpload.bind(mgr);
|
||||
mgr._executeUpload = async (...args) => {
|
||||
const result = await originalExecuteUpload(...args);
|
||||
queueMicrotask(() => mgr.cancelJobs(['confirmed-before-cancel']));
|
||||
return result;
|
||||
};
|
||||
const settled = [];
|
||||
let summary = null;
|
||||
mgr.on('job-settled', event => settled.push(event));
|
||||
mgr.on('batch-done', value => { summary = value; });
|
||||
|
||||
await mgr.startBatch([{
|
||||
jobId: 'confirmed-before-cancel',
|
||||
file: '/test/confirmed-before-cancel.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
assert.equal(settled.at(-1).status, 'done');
|
||||
assert.equal(summary.succeeded, 1);
|
||||
});
|
||||
|
||||
it('finishAfterActive prevents a semaphore waiter from starting', async () => {
|
||||
let releaseFirst;
|
||||
let markFirstStarted;
|
||||
const firstGate = new Promise(resolve => { releaseFirst = resolve; });
|
||||
const firstStarted = new Promise(resolve => { markFirstStarted = resolve; });
|
||||
const uploads = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
|
||||
uploads.push(filePath);
|
||||
if (filePath === '/test/active-before-stop.mp4') {
|
||||
markFirstStarted();
|
||||
await firstGate;
|
||||
}
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const settled = new Map();
|
||||
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
|
||||
const batchPromise = mgr.startBatch([
|
||||
{ jobId: 'active-before-stop', file: '/test/active-before-stop.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'semaphore-waiter', file: '/test/semaphore-waiter.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
await firstStarted;
|
||||
for (let index = 0; index < 50 && mgr._getSemaphore('doodstream.com').pending === 0; index++) {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(mgr._getSemaphore('doodstream.com').pending, 1);
|
||||
mgr.finishAfterActive();
|
||||
releaseFirst();
|
||||
await batchPromise;
|
||||
|
||||
assert.deepEqual(uploads, ['/test/active-before-stop.mp4']);
|
||||
assert.equal(settled.get('active-before-stop'), 'done');
|
||||
assert.equal(settled.get('semaphore-waiter'), 'aborted');
|
||||
});
|
||||
|
||||
it('finishAfterActive prevents an interval waiter from starting', async () => {
|
||||
let releaseInterval;
|
||||
let markIntervalEntered;
|
||||
const intervalGate = new Promise(resolve => { releaseInterval = resolve; });
|
||||
const intervalEntered = new Promise(resolve => { markIntervalEntered = resolve; });
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 1, maxSizeMb: 0 }
|
||||
});
|
||||
mgr._waitForInterval = async (hoster, intervalMs, signal, acquireSlots) => {
|
||||
markIntervalEntered();
|
||||
await intervalGate;
|
||||
await acquireSlots();
|
||||
};
|
||||
let settled;
|
||||
mgr.on('job-settled', event => { settled = event; });
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'interval-waiter-stop',
|
||||
file: '/test/interval-waiter-stop.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
await intervalEntered;
|
||||
mgr.finishAfterActive();
|
||||
releaseInterval();
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(settled.status, 'aborted');
|
||||
});
|
||||
|
||||
it('finishAfterActive prevents a suspect-resolution waiter from starting', async () => {
|
||||
let releaseSuspect;
|
||||
let markSuspectEntered;
|
||||
const suspectGate = new Promise(resolve => { releaseSuspect = resolve; });
|
||||
const suspectEntered = new Promise(resolve => { markSuspectEntered = resolve; });
|
||||
const mgr = new UploadManager({});
|
||||
mgr._waitForSuspectResolution = async () => {
|
||||
markSuspectEntered();
|
||||
await suspectGate;
|
||||
};
|
||||
let settled;
|
||||
mgr.on('job-settled', event => { settled = event; });
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'suspect-waiter-stop',
|
||||
file: '/test/suspect-waiter-stop.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
await suspectEntered;
|
||||
mgr.finishAfterActive();
|
||||
releaseSuspect();
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
assert.equal(settled.status, 'aborted');
|
||||
});
|
||||
|
||||
it('does not start a suspect alternate that became a failed account while waiting', async () => {
|
||||
const mgr = new UploadManager(
|
||||
{ 'byse.sx': { retries: 0, parallelCount: 2, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } },
|
||||
{},
|
||||
{ 'byse.sx': [{ id: 'alternate', apiKey: 'alternate-key' }] }
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const task = {
|
||||
file: '/test/suspect-waiter.mkv',
|
||||
hoster: 'byse.sx',
|
||||
accountId: 'alternate',
|
||||
apiKey: 'alternate-key',
|
||||
jobId: 'suspect-waiter'
|
||||
};
|
||||
mgr._beginSuspectResolution('byse.sx', 'suspect-owner');
|
||||
|
||||
const attempt = mgr._executeUploadWithAdmission(
|
||||
task,
|
||||
() => {},
|
||||
controller.signal,
|
||||
null,
|
||||
{ ok: true, isVideoLike: true },
|
||||
fakeFileSize,
|
||||
false,
|
||||
task.jobId
|
||||
);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
mgr._failedAccounts.set('byse.sx:alternate', true);
|
||||
mgr._endSuspectResolution('byse.sx', 'suspect-owner');
|
||||
|
||||
await assert.rejects(attempt, error => error.accountUnavailable === true);
|
||||
assert.equal(mockUploadFile.mock.calls.length, 0);
|
||||
});
|
||||
|
||||
it('finishAfterActive prevents a recovery-admission waiter from starting', async () => {
|
||||
let releaseFirst;
|
||||
let markFirstStarted;
|
||||
const firstGate = new Promise(resolve => { releaseFirst = resolve; });
|
||||
const firstStarted = new Promise(resolve => { markFirstStarted = resolve; });
|
||||
const uploads = [];
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath) => {
|
||||
uploads.push(filePath);
|
||||
if (filePath === '/test/first/shared-title.mp4') {
|
||||
markFirstStarted();
|
||||
await firstGate;
|
||||
}
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: `ok-${uploads.length}` };
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 2, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const settled = new Map();
|
||||
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
|
||||
const batchPromise = mgr.startBatch([
|
||||
{ jobId: 'recovery-active', file: '/test/first/shared-title.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'recovery-waiter', file: '/test/second/shared-title.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
await firstStarted;
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
mgr.finishAfterActive();
|
||||
releaseFirst();
|
||||
await batchPromise;
|
||||
|
||||
assert.deepEqual(uploads, ['/test/first/shared-title.mp4']);
|
||||
assert.equal(settled.get('recovery-active'), 'done');
|
||||
assert.equal(settled.get('recovery-waiter'), 'aborted');
|
||||
});
|
||||
|
||||
it('finishAfterActive remains enforced after account-failure coordination', async () => {
|
||||
let releaseFailureGate;
|
||||
let markFailureGateEntered;
|
||||
const failureGate = new Promise(resolve => { releaseFailureGate = resolve; });
|
||||
const failureGateEntered = new Promise(resolve => { markFailureGateEntered = resolve; });
|
||||
const error = new Error('Account rejected upload');
|
||||
error.accountError = true;
|
||||
mockUploadFile.mock.mockImplementation(async () => { throw error; });
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 2, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
mgr._coordinateAccountFailure = async () => {
|
||||
markFailureGateEntered();
|
||||
await failureGate;
|
||||
};
|
||||
let settled;
|
||||
mgr.on('job-settled', event => { settled = event; });
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'failure-gate-stop',
|
||||
file: '/test/failure-gate-stop.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
accountId: 'ACCOUNT_A',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
|
||||
await failureGateEntered;
|
||||
mgr.finishAfterActive();
|
||||
releaseFailureGate();
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||
assert.equal(settled.status, 'aborted');
|
||||
});
|
||||
|
||||
it('remote commit uncertainty remains terminal when finishAfterActive arrives simultaneously', async () => {
|
||||
let releaseUpload;
|
||||
let markUploadStarted;
|
||||
const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
|
||||
const uploadStarted = new Promise(resolve => { markUploadStarted = resolve; });
|
||||
mockUploadFile.mock.mockImplementation(async () => {
|
||||
markUploadStarted();
|
||||
await uploadGate;
|
||||
const error = new Error('Remote commit could not be confirmed');
|
||||
error.remoteCommitUncertain = true;
|
||||
throw error;
|
||||
});
|
||||
const mgr = new UploadManager({
|
||||
'example.test': { retries: 2, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
let summary;
|
||||
const batchPromise = mgr.startBatch([{
|
||||
jobId: 'uncertain-during-stop',
|
||||
file: '/test/uncertain-during-stop.mp4',
|
||||
hoster: 'example.test',
|
||||
accountId: 'ACCOUNT_A',
|
||||
apiKey: 'key1'
|
||||
}]);
|
||||
mgr.on('batch-done', value => { summary = value; });
|
||||
|
||||
await uploadStarted;
|
||||
mgr.finishAfterActive();
|
||||
releaseUpload();
|
||||
await batchPromise;
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.equal(mockUploadFile.mock.calls.length, 1);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.remoteCommitUncertain, true);
|
||||
});
|
||||
|
||||
it('late success after cancellation stays blocked by the real source cleanup gate', async (t) => {
|
||||
@@ -1024,70 +603,6 @@ describe('UploadManager', () => {
|
||||
assert.ok(statuses.some((entry) => entry.jobId === 'job-third' && entry.status === 'done'));
|
||||
});
|
||||
|
||||
it('accepts each job ID only once across concurrent addJobs calls', async () => {
|
||||
const anchorPath = '/race/anchor.mp4';
|
||||
const racePath = '/race/concurrent.mp4';
|
||||
const originalStatSync = fs.statSync;
|
||||
const originalStat = fs.promises.stat;
|
||||
const pendingStats = [];
|
||||
let concurrentStarts = 0;
|
||||
let batchPromise;
|
||||
|
||||
fs.statSync = function(p) {
|
||||
if (p === anchorPath || p === racePath) return { size: 0 };
|
||||
return originalStatSync.call(this, p);
|
||||
};
|
||||
fs.promises.stat = function(p) {
|
||||
if (p === anchorPath || p === racePath) {
|
||||
return new Promise((resolve) => pendingStats.push({ path: p, resolve }));
|
||||
}
|
||||
return originalStat.call(this, p);
|
||||
};
|
||||
|
||||
try {
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
if (filePath === racePath) concurrentStarts++;
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({
|
||||
'doodstream.com': { retries: 0, parallelCount: 3, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||
});
|
||||
const controllerRegistrations = [];
|
||||
const registerController = mgr.jobAbortControllers.set;
|
||||
mgr.jobAbortControllers.set = function(jobId, controller) {
|
||||
if (jobId === 'job-concurrent') controllerRegistrations.push(controller);
|
||||
return registerController.call(this, jobId, controller);
|
||||
};
|
||||
|
||||
batchPromise = mgr.startBatch([
|
||||
{ jobId: 'job-anchor', file: anchorPath, hoster: 'doodstream.com', apiKey: 'k' }
|
||||
]);
|
||||
assert.equal(mgr.running, true);
|
||||
|
||||
const task = { jobId: 'job-concurrent', file: racePath, hoster: 'doodstream.com', apiKey: 'k' };
|
||||
const addResults = await Promise.all([
|
||||
Promise.resolve().then(() => mgr.addJobs([task])),
|
||||
Promise.resolve().then(() => mgr.addJobs([{ ...task }]))
|
||||
]);
|
||||
const concurrentStats = pendingStats.filter((entry) => entry.path === racePath);
|
||||
for (const entry of pendingStats) entry.resolve({ size: fakeFileSize });
|
||||
await batchPromise;
|
||||
|
||||
assert.equal(addResults.reduce((total, result) => total + result.added, 0), 1);
|
||||
assert.deepEqual(addResults.flatMap((result) => result.alreadyInBatchJobIds), ['job-concurrent']);
|
||||
assert.equal(concurrentStats.length, 1);
|
||||
assert.equal(controllerRegistrations.length, 1);
|
||||
assert.equal(concurrentStarts, 1);
|
||||
} finally {
|
||||
for (const entry of pendingStats) entry.resolve({ size: fakeFileSize });
|
||||
if (batchPromise) await batchPromise.catch(() => {});
|
||||
fs.statSync = originalStatSync;
|
||||
fs.promises.stat = originalStat;
|
||||
}
|
||||
});
|
||||
|
||||
it('_combineSignals propagates abort from either source', () => {
|
||||
const mgr = new UploadManager({});
|
||||
const ac1 = new AbortController();
|
||||
@@ -1211,7 +726,7 @@ describe('UploadManager', () => {
|
||||
assert.ok(maxConcurrent <= 2, `scaleParallelUploads should cap at 2, was ${maxConcurrent}`);
|
||||
});
|
||||
|
||||
it('addJobs includes newly injected tasks in the batch summary', async () => {
|
||||
it('addJobs injects new tasks into running batch', async () => {
|
||||
let started = 0;
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
started++;
|
||||
@@ -1242,9 +757,6 @@ describe('UploadManager', () => {
|
||||
await batchPromise;
|
||||
assert.ok(summary);
|
||||
assert.equal(started, 4, 'all 4 jobs should have run');
|
||||
assert.equal(summary.total, 4);
|
||||
assert.equal(summary.succeeded, 4);
|
||||
assert.equal(summary.failed, 0);
|
||||
});
|
||||
|
||||
it('addJobs rejects duplicates already in running batch', async () => {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
test('catastrophic batch starts retain exact terminal outcomes for every job', () => {
|
||||
const { buildFailedUploadSummary, buildTerminalJobSnapshots } = require('../lib/upload-recovery');
|
||||
const summary = buildFailedUploadSummary([
|
||||
{ jobId: 'job-a', file: 'C:\\one\\same.mkv', hoster: 'doodstream.com' },
|
||||
{ jobId: 'job-b', file: 'D:\\two\\same.mkv', hoster: 'voe.sx' }
|
||||
], 'Upload konnte nicht gestartet werden', Date.UTC(2026, 7, 13));
|
||||
assert.equal(summary.total, 2);
|
||||
assert.equal(summary.failed, 2);
|
||||
assert.deepEqual(buildTerminalJobSnapshots(summary).map(entry => [entry.jobId, entry.status]), [
|
||||
['job-a', 'error'],
|
||||
['job-b', 'error']
|
||||
]);
|
||||
});
|
||||
|
||||
test('catastrophic batch summaries preserve safe file keys for later skipped-job merging', () => {
|
||||
const { buildFailedUploadSummary } = require('../lib/upload-recovery');
|
||||
const summary = buildFailedUploadSummary([
|
||||
{ jobId: 'job-a', file: 'C:\\one\\same.mkv', fileKey: 'safe-file-key', hoster: 'doodstream.com' }
|
||||
], 'Upload konnte nicht gestartet werden');
|
||||
|
||||
assert.equal(summary.files[0].fileKey, 'safe-file-key');
|
||||
});
|
||||
|
||||
test('terminal recovery snapshots retain exact job outcomes and canonical links', () => {
|
||||
const { buildTerminalJobSnapshots } = require('../lib/upload-recovery');
|
||||
const snapshots = buildTerminalJobSnapshots({
|
||||
files: [{
|
||||
name: 'episode.mkv',
|
||||
results: [
|
||||
{ jobId: 'done-job', hoster: 'doodstream.com', status: 'done', download_url: 'https://doodstream.com/d/abc123', file_code: 'abc123' },
|
||||
{ jobId: 'error-job', hoster: 'voe.sx', status: 'error', error: 'rejected', failureDetails: { kind: 'hoster' }, remoteCommitUncertain: true },
|
||||
{ hoster: 'byse.sx', status: 'done', download_url: 'https://byse.sx/d/no-id' }
|
||||
]
|
||||
}]
|
||||
});
|
||||
|
||||
assert.deepEqual(snapshots, [
|
||||
{
|
||||
jobId: 'done-job',
|
||||
status: 'done',
|
||||
error: null,
|
||||
failureDetails: null,
|
||||
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
|
||||
},
|
||||
{
|
||||
jobId: 'error-job',
|
||||
status: 'error',
|
||||
error: 'rejected',
|
||||
failureDetails: { kind: 'hoster' },
|
||||
remoteCommitUncertain: true,
|
||||
result: null
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test('recovery markers affect only their exact job IDs and never restart terminal outcomes', () => {
|
||||
const { getRecoveryOutcome } = require('../lib/upload-recovery');
|
||||
const recovery = {
|
||||
jobIds: ['done-job', 'active-job'],
|
||||
historyPending: true,
|
||||
terminalJobs: [{
|
||||
jobId: 'done-job',
|
||||
status: 'done',
|
||||
error: null,
|
||||
failureDetails: null,
|
||||
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }
|
||||
}, {
|
||||
jobId: 'uncertain-job',
|
||||
status: 'error',
|
||||
error: 'remote result unknown',
|
||||
failureDetails: null,
|
||||
remoteCommitUncertain: true,
|
||||
result: null
|
||||
}]
|
||||
};
|
||||
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'done-job', status: 'preview' }, recovery), {
|
||||
status: 'done',
|
||||
error: null,
|
||||
failureDetails: null,
|
||||
result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' },
|
||||
historyPending: true,
|
||||
interrupted: false
|
||||
});
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'active-job', status: 'queued' }, recovery), { status: 'queued', interrupted: true });
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'uncertain-job', status: 'preview' }, recovery), {
|
||||
status: 'error',
|
||||
error: 'remote result unknown',
|
||||
failureDetails: null,
|
||||
remoteCommitUncertain: true,
|
||||
result: null,
|
||||
historyPending: true,
|
||||
interrupted: false
|
||||
});
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'foreign-job', status: 'queued' }, recovery), { status: 'queued', interrupted: false });
|
||||
assert.deepEqual(getRecoveryOutcome({ id: 'already-done', status: 'done' }, recovery), { status: 'done', interrupted: false });
|
||||
});
|
||||
|
||||
test('remote commit uncertainty survives acknowledgements and failed retries until confirmed success', () => {
|
||||
const { resolveRemoteCommitUncertainty } = require('../lib/upload-recovery');
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'queued' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'getting-server' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'uploading' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'error' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'aborted' }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(true, { status: 'done' }), false);
|
||||
assert.equal(resolveRemoteCommitUncertainty(false, { status: 'error', remoteCommitUncertain: true }), true);
|
||||
assert.equal(resolveRemoteCommitUncertainty(false, { status: 'error' }), false);
|
||||
});
|
||||
|
||||
test('main and renderer keep recovery evidence until final queue persistence succeeds', () => {
|
||||
const root = path.join(__dirname, '..');
|
||||
const mainSource = fs.readFileSync(path.join(root, 'main.js'), 'utf8');
|
||||
const rendererSource = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8');
|
||||
const indexSource = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
|
||||
const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
|
||||
const barrier = mainSource.slice(mainSource.indexOf('function createUploadFinalizationBarrier'), mainSource.indexOf('function requestUploadFinalization'));
|
||||
|
||||
assert.match(mainSource, /buildTerminalSnapshots: buildTerminalJobSnapshots/);
|
||||
assert.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)'));
|
||||
assert.ok(barrier.indexOf('saveRecovery(terminalRecovery)') < barrier.indexOf('requestFinalization(summary, historyPersisted)'));
|
||||
assert.match(barrier, /if \(historyPersisted && queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveRecovery\(null\)/);
|
||||
assert.match(batchDone, /uploadFinalizationBarrier\.finalize\(summary, recovery\)/);
|
||||
const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks'));
|
||||
assert.match(startFailure, /buildFailedUploadSummary\(tasks/);
|
||||
assert.match(startFailure, /uploadFinalizationBarrier\.finalize\(errorSummary, recovery\)/);
|
||||
const startHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('start-upload'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
|
||||
assert.match(startHandler, /await configStore\.saveUploadRecovery\(recovery\)/);
|
||||
assert.match(startHandler, /uploadFinalizationBarrier\.finalize\(skippedSummary/);
|
||||
assert.match(startHandler, /finalized: true/);
|
||||
assert.ok(startHandler.indexOf('sourceCleanup.registerGroups(sourceCleanupGroups)') < startHandler.indexOf('saveUploadRecovery(recovery)'));
|
||||
assert.match(startHandler, /catch \(error\)[\s\S]*return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }/);
|
||||
const addHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"), mainSource.indexOf("ipcMain.handle('finish-after-active'"));
|
||||
assert.match(addHandler, /await configStore\.saveUploadRecovery\(nextRecovery\)/);
|
||||
assert.ok(addHandler.indexOf('saveUploadRecovery(nextRecovery)') < addHandler.indexOf('batchManager.addJobs(tasks)'));
|
||||
assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
|
||||
assert.match(rendererSource, /data\.historyPersisted !== true/);
|
||||
assert.match(rendererSource, /deliveryId: data\.deliveryId/);
|
||||
assert.match(rendererSource, /remoteCommitUncertain: Object\.hasOwn\(recoveryOutcome, 'remoteCommitUncertain'\)/);
|
||||
assert.match(rendererSource, /remoteCommitUncertain: job\.remoteCommitUncertain === true/);
|
||||
assert.match(rendererSource, /j\.remoteCommitUncertain !== true/);
|
||||
assert.equal((rendererSource.match(/window\.UploadRecovery\.resolveRemoteCommitUncertainty/g) || []).length >= 2, true);
|
||||
assert.doesNotMatch(rendererSource, /acknowledgeRemoteCommitRetry/);
|
||||
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
normalizeUploadSchedule,
|
||||
evaluateUploadSchedule,
|
||||
createUploadScheduleGate
|
||||
} = require('../lib/upload-schedule');
|
||||
|
||||
function at(iso) {
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
test('normalizes weekdays and valid local times into a stable Monday-first shape', () => {
|
||||
assert.deepEqual(normalizeUploadSchedule({
|
||||
enabled: true,
|
||||
weekdays: [0, 1, 1, 8, '2'],
|
||||
start: ' 08:15 ',
|
||||
end: '17:45'
|
||||
}), {
|
||||
enabled: true,
|
||||
weekdays: [1, 2, 0],
|
||||
start: '08:15',
|
||||
end: '17:45'
|
||||
});
|
||||
});
|
||||
|
||||
test('allows a selected daytime window with an inclusive start and exclusive end', () => {
|
||||
const schedule = { enabled: true, weekdays: [1], start: '08:00', end: '10:00' };
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T08:00:00')).allowed, true);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T09:59:59')).allowed, true);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T10:00:00')).allowed, false);
|
||||
});
|
||||
|
||||
test('attributes the after-midnight half of an overnight window to the originating weekday', () => {
|
||||
const schedule = { enabled: true, weekdays: [1], start: '22:00', end: '06:00' };
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-17T22:00:00')).allowed, true);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-18T05:59:59')).allowed, true);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-18T06:00:00')).allowed, false);
|
||||
assert.equal(evaluateUploadSchedule(schedule, at('2026-08-19T05:00:00')).allowed, false);
|
||||
});
|
||||
|
||||
test('finds the next selected start across the week boundary', () => {
|
||||
const result = evaluateUploadSchedule(
|
||||
{ enabled: true, weekdays: [1], start: '08:30', end: '09:30' },
|
||||
at('2026-08-23T12:00:00')
|
||||
);
|
||||
assert.equal(result.allowed, false);
|
||||
assert.equal(result.nextStart.getDay(), 1);
|
||||
assert.equal(result.nextStart.getHours(), 8);
|
||||
assert.equal(result.nextStart.getMinutes(), 30);
|
||||
assert.equal(result.nextStart.getDate(), 24);
|
||||
});
|
||||
|
||||
test('reports enabled schedules with equal times, missing times, or no weekdays as invalid', () => {
|
||||
assert.deepEqual(
|
||||
evaluateUploadSchedule({ enabled: true, weekdays: [1], start: '08:00', end: '08:00' }, at('2026-08-17T08:00:00')).reason,
|
||||
'equal-times'
|
||||
);
|
||||
assert.equal(evaluateUploadSchedule({ enabled: true, weekdays: [], start: '08:00', end: '09:00' }, at('2026-08-17T08:00:00')).reason, 'weekdays');
|
||||
assert.equal(evaluateUploadSchedule({ enabled: true, weekdays: [1], start: 'bad', end: '09:00' }, at('2026-08-17T08:00:00')).reason, 'time');
|
||||
});
|
||||
|
||||
test('disabled schedules always allow uploads', () => {
|
||||
const result = evaluateUploadSchedule({ enabled: false, weekdays: [], start: '', end: '' }, at('2026-08-17T08:00:00'));
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.allowed, true);
|
||||
assert.equal(result.nextStart, null);
|
||||
});
|
||||
|
||||
test('gate wakes all waiting jobs when settings are updated', async () => {
|
||||
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
|
||||
const first = gate.wait();
|
||||
const second = gate.wait();
|
||||
gate.update({ enabled: false });
|
||||
const results = await Promise.all([first, second]);
|
||||
assert.equal(results.every(result => result.allowed), true);
|
||||
});
|
||||
|
||||
test('gate rejects a waiting job immediately when its signal is aborted', async () => {
|
||||
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
|
||||
const controller = new AbortController();
|
||||
const waiting = gate.wait(controller.signal);
|
||||
controller.abort();
|
||||
await assert.rejects(waiting, error => error?.name === 'AbortError');
|
||||
});
|
||||
|
||||
test('gate rechecks an external stop condition when explicitly woken', async () => {
|
||||
const gate = createUploadScheduleGate({ enabled: true, weekdays: [], start: '08:00', end: '09:00' });
|
||||
let stopped = false;
|
||||
const waiting = gate.wait(undefined, () => {
|
||||
if (!stopped) return;
|
||||
const error = new Error('Stopped');
|
||||
error.stopAfterActive = true;
|
||||
throw error;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
stopped = true;
|
||||
gate.wake();
|
||||
|
||||
await assert.rejects(waiting, error => error.stopAfterActive === true);
|
||||
});
|
||||
|
||||
test('gate schedules a wake for the exact next opening', async () => {
|
||||
let current = at('2026-08-17T07:30:00');
|
||||
let timerDelay = null;
|
||||
let timerCallback = null;
|
||||
const gate = createUploadScheduleGate(
|
||||
{ enabled: true, weekdays: [1], start: '08:00', end: '09:00' },
|
||||
{
|
||||
now: () => current,
|
||||
setTimeout(callback, delay) {
|
||||
timerCallback = callback;
|
||||
timerDelay = delay;
|
||||
return 1;
|
||||
},
|
||||
clearTimeout() {}
|
||||
}
|
||||
);
|
||||
const waiting = gate.wait();
|
||||
assert.equal(timerDelay, 30 * 60 * 1000);
|
||||
current = at('2026-08-17T08:00:00');
|
||||
timerCallback();
|
||||
assert.equal((await waiting).allowed, true);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createUploadStartReservation } = require('../lib/upload-start-reservation');
|
||||
|
||||
test('only one upload start can hold the reservation across asynchronous work', () => {
|
||||
const reservation = createUploadStartReservation();
|
||||
const first = reservation.acquire();
|
||||
|
||||
assert.ok(first);
|
||||
assert.equal(reservation.isActive(), true);
|
||||
assert.equal(reservation.acquire(), null);
|
||||
|
||||
first.release();
|
||||
const second = reservation.acquire();
|
||||
assert.ok(second);
|
||||
assert.notStrictEqual(second, first);
|
||||
});
|
||||
|
||||
test('cancelling a reserved start is visible until its owner releases it', () => {
|
||||
const reservation = createUploadStartReservation();
|
||||
const lease = reservation.acquire();
|
||||
|
||||
assert.equal(reservation.cancel(), true);
|
||||
assert.equal(lease.isCancelled(), true);
|
||||
assert.equal(reservation.acquire(), null);
|
||||
lease.release();
|
||||
assert.equal(reservation.isActive(), false);
|
||||
assert.equal(reservation.cancel(), false);
|
||||
});
|
||||
|
||||
test('stale and repeated releases cannot clear a newer reservation', () => {
|
||||
const reservation = createUploadStartReservation();
|
||||
const first = reservation.acquire();
|
||||
first.release();
|
||||
const second = reservation.acquire();
|
||||
|
||||
first.release();
|
||||
assert.equal(reservation.isActive(), true);
|
||||
assert.equal(reservation.acquire(), null);
|
||||
second.release();
|
||||
assert.equal(reservation.isActive(), false);
|
||||
});
|
||||
|
||||
test('main process reserves starts before audit and exposes cancellation during the wait', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
const start = source.slice(
|
||||
source.indexOf("ipcMain.handle('start-upload'"),
|
||||
source.indexOf("ipcMain.handle('cancel-selected-jobs'")
|
||||
);
|
||||
|
||||
assert.ok(start.indexOf('uploadStartReservation.acquire()') < start.indexOf('appendUploadPlanAudit(batchPlan'));
|
||||
assert.match(start, /executeReservedUploadStart\(payload, startLease\)\.finally\(\(\) => startLease\.release\(\)\)/);
|
||||
assert.match(start, /uploadStartReservation\.cancel\(\)/);
|
||||
assert.match(source, /createSettingsImportGate\(\(\) => !!uploadManager \|\| uploadStartReservation\.isActive\(\)\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user