Preserve uncertain remote commits and destructive source-cleanup requirements across cancellation, persistence failures, retries, and restart recovery. Revalidate account availability after admission waits and prevent late upload results from bypassing cancellation state. Strengthen release argument, CI tag, source allowlist, and secret scanning gates. Run the Electron smoke suite in a fully hidden offscreen harness that cannot reveal, focus, or elevate native windows.
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
});
|
||||
@@ -41,6 +41,26 @@ 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 }));
|
||||
@@ -81,6 +101,87 @@ test('public source verification runs against the actual checkout at its package
|
||||
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 }));
|
||||
|
||||
@@ -151,7 +151,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, () => 'cleanup-1', 'win32');
|
||||
policy.prepareGroups(queueJobs, queueJobs.slice(0, 3), () => 'cleanup-1', 'win32');
|
||||
|
||||
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[3], 'win32');
|
||||
|
||||
@@ -178,6 +178,66 @@ test('markCompleted keeps successful hosters provisional for the current round',
|
||||
}
|
||||
});
|
||||
|
||||
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' },
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 };
|
||||
+196
-10
@@ -26,13 +26,15 @@ if (visualScreenshotDir) fs.mkdirSync(visualScreenshotDir, { recursive: true });
|
||||
// Create a temp script that the real Electron app will execute via --eval
|
||||
const testScript = `
|
||||
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
|
||||
const path = require('path');
|
||||
const { installHiddenElectronWindowHarness } = require(path.join(process.cwd(), 'tests', 'support', 'hidden-electron-window'));
|
||||
const hiddenWindowHarness = installHiddenElectronWindowHarness({ BrowserWindow });
|
||||
const isolatedUserDataPath = app.getPath('userData');
|
||||
const setAppPath = app.setPath.bind(app);
|
||||
app.setPath = (name, value) => setAppPath(name, name === 'userData' ? isolatedUserDataPath : value);
|
||||
app.setVersion(${JSON.stringify(productVersion)});
|
||||
const fs = require('fs');
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
const ConfigStore = require(path.join(process.cwd(), 'lib', 'config-store'));
|
||||
const RemoteServer = require(path.join(process.cwd(), 'lib', 'remote-server'));
|
||||
const { listenOnLoopback, installLoopbackRemoteServerGuard } = require(path.join(process.cwd(), 'tests', 'support', 'ui-network-safety'));
|
||||
@@ -81,10 +83,10 @@ ipcMain.handle = (channel, listener) => {
|
||||
if (!initialConfigReadDelayed) {
|
||||
initialConfigReadDelayed = true;
|
||||
const deadline = Date.now() + 1500;
|
||||
let window = BrowserWindow.getAllWindows()[0];
|
||||
let window = hiddenWindowHarness.getWindows()[0];
|
||||
while (window && !window.isVisible() && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 25));
|
||||
window = BrowserWindow.getAllWindows()[0];
|
||||
window = hiddenWindowHarness.getWindows()[0];
|
||||
}
|
||||
startupLanguagePendingSnapshot = window
|
||||
? {
|
||||
@@ -157,12 +159,14 @@ async function waitUntil(read, timeoutMs = 3000) {
|
||||
|
||||
// Wait for app to be ready, then wait for the real window to load
|
||||
setTimeout(async () => {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
const windows = hiddenWindowHarness.getWindows();
|
||||
if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); }
|
||||
const win = windows[0];
|
||||
win.setIgnoreMouseEvents(true);
|
||||
if (win.isFullScreen()) win.setFullScreen(false);
|
||||
if (win.isMaximized()) win.unmaximize();
|
||||
const wc = win.webContents;
|
||||
if (typeof wc.setFrameRate === 'function') wc.setFrameRate(60);
|
||||
if (!wc.debugger.isAttached()) wc.debugger.attach('1.3');
|
||||
await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] });
|
||||
const setWindowBounds = async bounds => {
|
||||
@@ -213,6 +217,7 @@ setTimeout(async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
check('Every UI smoke window stays offscreen and never takes focus', hiddenWindowHarness.areNativeSurfacesSuppressed(hiddenWindowHarness.getWindows()));
|
||||
const startupUpdateState = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [_knownUpdateInfo?.remoteVersion, button?.hidden, getComputedStyle(button).display, document.getElementById("updateBanner")?.style.display].join("|"); })()');
|
||||
check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex');
|
||||
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
|
||||
@@ -803,7 +808,7 @@ setTimeout(async () => {
|
||||
const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()');
|
||||
check('Remove all is visually marked as a destructive queue action', removeAllDanger === true);
|
||||
|
||||
await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); queueJobs = [{ id: "ui-cleanup-survivor", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-cleanup-remove-preview", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-cleanup-remove-preview"); rebuildJobIndex(); renderQueueTable(); window.__uiCleanupPreviewRemoval = handleContextAction("delete-selected"); return true; })()');
|
||||
await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); queueJobs = [{ id: "ui-cleanup-survivor", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-cleanup-remove-preview", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-cleanup-remove-preview"); rebuildJobIndex(); renderQueueTable(); window.__uiCleanupPreviewRemoval = handleContextAction("delete-selected"); return true; })()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||
const previewCleanupRemoval = await wc.executeJavaScript('window.__uiCleanupPreviewRemoval.then(() => { delete window.__uiCleanupPreviewRemoval; queuePersistThrottle.cancel(); const survivor = queueJobs[0]; const result = { length: queueJobs.length, id: survivor?.id, required: survivor?.sourceCleanupRequiredHosters || [], confirmed: survivor?.sourceCleanupConfirmedHosters || [], finalizing: sourceCleanupFinalizationPending }; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); return result; })');
|
||||
@@ -871,13 +876,13 @@ setTimeout(async () => {
|
||||
restoreInitialIpcHandler('cancel-upload');
|
||||
restoreInitialIpcHandler('cancel-selected-jobs');
|
||||
|
||||
await wc.executeJavaScript('(() => { uploading = false; queuePersistThrottle.cancel(); const required = ["doodstream.com", "voe.sx", "byse.sx"]; window.__uiDeleteAllCleanupJobs = [{ id: "ui-delete-all-cleanup-done", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-voe", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-byse", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; queueJobs = window.__uiDeleteAllCleanupJobs; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllCleanupPromise = handleContextAction("delete-all"); return true; })()');
|
||||
await wc.executeJavaScript('(() => { uploading = false; queuePersistThrottle.cancel(); const required = ["doodstream.com", "voe.sx", "byse.sx"]; window.__uiDeleteAllCleanupJobs = [{ id: "ui-delete-all-cleanup-done", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-voe", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-byse", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }]; queueJobs = window.__uiDeleteAllCleanupJobs; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllCleanupPromise = handleContextAction("delete-all"); return true; })()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||
const deleteAllCleanupState = await wc.executeJavaScript('window.__uiDeleteAllCleanupPromise.then(() => { delete window.__uiDeleteAllCleanupPromise; queuePersistThrottle.cancel(); const result = { queueLength: queueJobs.length, required: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; delete window.__uiDeleteAllCleanupJobs; return result; })');
|
||||
check('Remove all drops only never-started cleanup destinations without authorizing deletion', deleteAllCleanupState.queueLength === 0 && deleteAllCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com') && deleteAllCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com'));
|
||||
|
||||
await wc.executeJavaScript('(() => { const required = ["doodstream.com", "voe.sx", "byse.sx"]; queueJobs = [{ id: "ui-delete-hoster-cleanup-done", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-voe", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-byse", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteHosterCleanupPromise = handleContextAction("delete-hoster:voe.sx"); return true; })()');
|
||||
await wc.executeJavaScript('(() => { const required = ["doodstream.com", "voe.sx", "byse.sx"]; queueJobs = [{ id: "ui-delete-hoster-cleanup-done", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-voe", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-byse", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"], sourceCleanupStartedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteHosterCleanupPromise = handleContextAction("delete-hoster:voe.sx"); return true; })()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||
const deleteHosterCleanupState = await wc.executeJavaScript('window.__uiDeleteHosterCleanupPromise.then(() => { delete window.__uiDeleteHosterCleanupPromise; queuePersistThrottle.cancel(); const result = { ids: queueJobs.map(job => job.id), required: queueJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: queueJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; queueJobs = []; rebuildJobIndex(); return result; })');
|
||||
@@ -1499,6 +1504,7 @@ setTimeout(async () => {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
check('Save shows Gespeichert!', feedback === 'Gespeichert!');
|
||||
check('Always-on-top settings never affect the native UI smoke window', hiddenWindowHarness.isAlwaysOnTopRequested(win) === true && hiddenWindowHarness.isNativeSurfaceSuppressed(win));
|
||||
|
||||
const originalShowSaveDialog = dialog.showSaveDialog;
|
||||
const originalShowOpenDialog = dialog.showOpenDialog;
|
||||
@@ -2045,11 +2051,186 @@ setTimeout(async () => {
|
||||
const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || [];
|
||||
const sourceCleanupAfterJobs = sourceCleanupRollback.after?.queueJobs || [];
|
||||
const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || [];
|
||||
const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupFinalizationPayload?.deliveryId === 'ui-cleanup-delivery' && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0);
|
||||
const sourceCleanupRollbackOk = sourceCleanupRollback.available === true
|
||||
&& sourceCleanupRollback.result === false
|
||||
&& sourceCleanupFinalizationPayload?.deliveryId === 'ui-cleanup-delivery'
|
||||
&& sourceCleanupBeforeJobs.length === 1
|
||||
&& sourceCleanupBeforeJobs[0]?.id === 'ui-cleanup-byse'
|
||||
&& !Object.prototype.hasOwnProperty.call(sourceCleanupBeforeJobs[0], 'sourceCleanupProvisionalHosters')
|
||||
&& !Object.prototype.hasOwnProperty.call(sourceCleanupBeforeJobs[0], 'sourceCleanupCompletedHosters')
|
||||
&& (sourceCleanupBeforeJobs[0]?.sourceCleanupRequiredHosters || []).join('|') === 'voe.sx|byse.sx'
|
||||
&& (sourceCleanupBeforeJobs[0]?.sourceCleanupStartedHosters || []).join('|') === 'voe.sx|byse.sx'
|
||||
&& (sourceCleanupBeforeJobs[0]?.sourceCleanupConfirmedHosters || []).length === 0
|
||||
&& sourceCleanupPromotedJobs.length === 1
|
||||
&& sourceCleanupPromotedJobs[0]?.id === 'ui-cleanup-byse'
|
||||
&& (sourceCleanupPromotedJobs[0]?.sourceCleanupRequiredHosters || []).join('|') === 'voe.sx|byse.sx'
|
||||
&& (sourceCleanupPromotedJobs[0]?.sourceCleanupStartedHosters || []).join('|') === 'voe.sx|byse.sx'
|
||||
&& (sourceCleanupPromotedJobs[0]?.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx'
|
||||
&& sourceCleanupAfterJobs.length === 1
|
||||
&& sourceCleanupAfterJobs[0]?.id === 'ui-cleanup-byse'
|
||||
&& (sourceCleanupAfterJobs[0]?.sourceCleanupRequiredHosters || []).join('|') === 'voe.sx|byse.sx'
|
||||
&& (sourceCleanupAfterJobs[0]?.sourceCleanupStartedHosters || []).join('|') === 'voe.sx|byse.sx'
|
||||
&& (sourceCleanupAfterJobs[0]?.sourceCleanupConfirmedHosters || []).length === 0;
|
||||
check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk);
|
||||
const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()');
|
||||
const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || [];
|
||||
check('History failure keeps terminal queue results and links restart-recoverable', terminalRecoveryState.normal === null && terminalRecoveryState.recovery !== null && terminalRecoveryState.recovery.selectedFiles.length === 2 && terminalRecoveryJobs.length === 2 && terminalRecoveryJobs[0].id === 'ui-terminal-done' && terminalRecoveryJobs[0].status === 'done' && terminalRecoveryJobs[0].result?.download_url === 'https://example.invalid/terminal-done' && terminalRecoveryJobs[0].result?.embed_url === 'https://example.invalid/embed-terminal-done' && terminalRecoveryJobs[0].result?.file_code === 'terminal-code' && terminalRecoveryJobs[1].status === 'skipped' && terminalRecoveryJobs[1].error === 'Size limit');
|
||||
const legacyCleanupRoundTrip = await wc.executeJavaScript(\`(() => {
|
||||
const originalGlobalSettings = structuredClone(config.globalSettings || {});
|
||||
selectedFiles = [];
|
||||
queueJobs = [{ id: 'ui-legacy-cleanup-done', file: 'C:/ui/legacy-cleanup.bin', fileName: 'legacy-cleanup.bin', hoster: 'doodstream.com', status: 'done', bytesTotal: 20, sourceCleanupMetadataVersion: 2, sourceCleanupToken: 'ui-legacy-cleanup-token', sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'], sourceCleanupConfirmedHosters: ['doodstream.com'] }, { id: 'ui-legacy-cleanup-preview', file: 'C:/ui/legacy-cleanup.bin', fileName: 'legacy-cleanup.bin', hoster: 'voe.sx', status: 'preview', bytesTotal: 20, sourceCleanupMetadataVersion: 2, sourceCleanupToken: 'ui-legacy-cleanup-token', sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'], sourceCleanupConfirmedHosters: ['doodstream.com'] }];
|
||||
rebuildJobIndex();
|
||||
const snapshot = JSON.parse(JSON.stringify(buildPersistedQueueState()));
|
||||
const persistedMissing = snapshot.queueJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupStartedHosters'));
|
||||
config.globalSettings = { ...originalGlobalSettings, resumeQueueOnLaunch: true, pendingQueue: snapshot, uploadRecovery: null };
|
||||
queueJobs = [];
|
||||
rebuildJobIndex();
|
||||
restoreQueueStateFromConfig();
|
||||
const restoredMissing = queueJobs.every(job => !Array.isArray(job.sourceCleanupStartedHosters));
|
||||
const touched = window.SourceCleanupPolicy.removeRequirement(queueJobs, queueJobs.find(job => job.id === 'ui-legacy-cleanup-preview'), 'win32');
|
||||
const requiredPreserved = queueJobs.every(job => (job.sourceCleanupRequiredHosters || []).join('|') === 'doodstream.com|voe.sx');
|
||||
config.globalSettings = originalGlobalSettings;
|
||||
selectedFiles = [];
|
||||
queueJobs = [];
|
||||
rebuildJobIndex();
|
||||
return { persistedMissing, restoredMissing, touched: touched.length, requiredPreserved };
|
||||
})()\`);
|
||||
check('Legacy cleanup metadata stays fail-closed through queue persistence and restart', legacyCleanupRoundTrip.persistedMissing === true && legacyCleanupRoundTrip.restoredMissing === true && legacyCleanupRoundTrip.touched === 0 && legacyCleanupRoundTrip.requiredPreserved === true);
|
||||
const uncertainRetryState = await wc.executeJavaScript(\`(async () => {
|
||||
const originalGlobalSettings = structuredClone(config.globalSettings || {});
|
||||
const originalLanguage = document.documentElement.lang;
|
||||
config.globalSettings = { ...originalGlobalSettings, uploadRecovery: null, resumeQueueOnLaunch: true };
|
||||
setUiLanguage('en');
|
||||
selectedFiles = [];
|
||||
queueJobs = [{ id: 'ui-uncertain-retry', file: 'C:/ui/uncertain.bin', fileName: 'uncertain.bin', hoster: 'voe.sx', status: 'error', error: 'Connection lost', remoteCommitUncertain: true, bytesTotal: 77 }];
|
||||
rebuildJobIndex();
|
||||
const automaticCount = _collectAutoRetryableJobs().length;
|
||||
const snapshot = buildPersistedQueueState();
|
||||
config.globalSettings.pendingQueue = structuredClone(snapshot);
|
||||
queueJobs = [];
|
||||
rebuildJobIndex();
|
||||
restoreQueueStateFromConfig();
|
||||
const restored = queueJobs[0]?.remoteCommitUncertain === true;
|
||||
uploadSidebarFilter = 'all';
|
||||
queueSearchQuery = '';
|
||||
queueHosterFilter = '';
|
||||
queueStatusFilter = '';
|
||||
renderQueueTable();
|
||||
selectedJobIds.clear();
|
||||
selectedJobIds.add('ui-uncertain-retry');
|
||||
const retryPromise = retrySelectedJobs();
|
||||
await Promise.resolve();
|
||||
const dialogTitle = document.getElementById('appAlertTitle')?.textContent.trim();
|
||||
const dialogDanger = document.getElementById('appAlertConfirmBtn')?.classList.contains('btn-danger') === true;
|
||||
document.getElementById('appAlertCancelBtn')?.click();
|
||||
await retryPromise;
|
||||
const statusAfterCancel = queueJobs[0]?.status;
|
||||
const uncertainAfterCancel = queueJobs[0]?.remoteCommitUncertain === true;
|
||||
config.globalSettings = originalGlobalSettings;
|
||||
setUiLanguage(originalLanguage);
|
||||
selectedJobIds.clear();
|
||||
selectedFiles = [];
|
||||
queueJobs = [];
|
||||
rebuildJobIndex();
|
||||
return { automaticCount, restored, dialogTitle, dialogDanger, statusAfterCancel, uncertainAfterCancel };
|
||||
})()\`);
|
||||
check('Uncertain remote commits survive restart, never auto-retry, and require explicit dangerous confirmation', uncertainRetryState.automaticCount === 0 && uncertainRetryState.restored === true && uncertainRetryState.dialogTitle === 'The upload status could not be confirmed' && uncertainRetryState.dialogDanger === true && uncertainRetryState.statusAfterCancel === 'error' && uncertainRetryState.uncertainAfterCancel === true);
|
||||
const lateTerminalProgress = await wc.executeJavaScript(\`(() => {
|
||||
queueJobs = [{ id: 'ui-late-terminal', uploadId: 'ui-late-terminal-upload', file: 'C:/ui/late-terminal.bin', fileName: 'late-terminal.bin', hoster: 'voe.sx', status: 'error', error: 'Remote result uncertain', failureDetails: { phase: 'response' }, remoteCommitUncertain: true, bytesUploaded: 50, bytesTotal: 100, progress: .5 }];
|
||||
rebuildJobIndex();
|
||||
_handleProgressImpl({ jobId: 'ui-late-terminal', uploadId: 'ui-late-terminal-upload', fileName: 'late-terminal.bin', hoster: 'voe.sx', status: 'uploading', error: null, failureDetails: null, remoteCommitUncertain: false, bytesUploaded: 90, bytesTotal: 100, progress: .9 });
|
||||
const job = queueJobs[0];
|
||||
return { status: job.status, error: job.error, phase: job.failureDetails?.phase, uncertain: job.remoteCommitUncertain, bytesUploaded: job.bytesUploaded, progress: job.progress };
|
||||
})()\`);
|
||||
check('Late progress cannot overwrite a terminal uncertain upload result', lateTerminalProgress.status === 'error' && lateTerminalProgress.error === 'Remote result uncertain' && lateTerminalProgress.phase === 'response' && lateTerminalProgress.uncertain === true && lateTerminalProgress.bytesUploaded === 50 && lateTerminalProgress.progress === .5);
|
||||
let uncertainStartCalls = 0;
|
||||
ipcMain.removeHandler('start-upload');
|
||||
ipcMain.handle('start-upload', () => {
|
||||
uncertainStartCalls++;
|
||||
return uncertainStartCalls === 1
|
||||
? { error: 'Injected start rejection' }
|
||||
: { started: true, taskCount: 1, skippedJobs: [] };
|
||||
});
|
||||
const rejectedUncertainStartPromise = wc.executeJavaScript(\`(() => {
|
||||
uploading = false;
|
||||
selectedUploadHosters = ['voe.sx'];
|
||||
selectedFiles = [];
|
||||
queueJobs = [{ id: 'ui-normal-uncertain-start', file: 'C:/ui/normal-uncertain.bin', fileName: 'normal-uncertain.bin', hoster: 'voe.sx', status: 'error', error: 'Remote result uncertain', remoteCommitUncertain: true, bytesTotal: 12 }];
|
||||
rebuildJobIndex();
|
||||
return startUpload().then(() => ({ status: queueJobs[0].status, uncertain: queueJobs[0].remoteCommitUncertain === true }));
|
||||
})()\`);
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertCancelBtn")?.hidden === false'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertCancelBtn")?.hidden === true && document.getElementById("appAlertModal")?.style.display === "flex"'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()');
|
||||
const rejectedUncertainStart = await rejectedUncertainStartPromise;
|
||||
const acceptedUncertainStartPromise = wc.executeJavaScript('startUpload().then(() => { uploading = false; return { status: queueJobs[0].status, uncertain: queueJobs[0].remoteCommitUncertain === true }; })');
|
||||
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertCancelBtn")?.hidden === false'));
|
||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()');
|
||||
const acceptedUncertainStart = await acceptedUncertainStartPromise;
|
||||
const confirmedUncertainRetry = await wc.executeJavaScript('(() => { _handleProgressImpl({ jobId: "ui-normal-uncertain-start", uploadId: "ui-normal-uncertain-start-upload", fileName: "normal-uncertain.bin", hoster: "voe.sx", status: "done", result: { download_url: "https://voe.sx/e/confirmed", file_code: "confirmed" }, bytesUploaded: 12, bytesTotal: 12, progress: 1 }); return queueJobs[0]?.remoteCommitUncertain === true; })()');
|
||||
check('Normal start preserves the duplicate warning until a confirmed terminal retry result', uncertainStartCalls === 2 && rejectedUncertainStart.uncertain === true && acceptedUncertainStart.uncertain === true && confirmedUncertainRetry === false);
|
||||
let bucketRetryPayload = null;
|
||||
ipcMain.removeHandler('start-upload');
|
||||
ipcMain.handle('start-upload', (_event, payload) => {
|
||||
bucketRetryPayload = payload;
|
||||
return { started: true, taskCount: payload.jobs.length, skippedJobs: [] };
|
||||
});
|
||||
const bucketRetryState = await wc.executeJavaScript(\`(async () => {
|
||||
uploading = false;
|
||||
queueJobs = [{ id: 'ui-bucket-first', file: 'C:/ui/a/shared.bin', fileName: 'shared.bin', hoster: 'voe.sx', status: 'error', error: 'Network failed', bytesTotal: 13 }, { id: 'ui-bucket-second', file: 'C:/ui/b/shared.bin', fileName: 'shared.bin', hoster: 'voe.sx', status: 'error', error: 'Network failed', bytesTotal: 14 }, { id: 'ui-bucket-unrelated', file: 'C:/ui/c/other.bin', fileName: 'other.bin', hoster: 'voe.sx', status: 'error', error: 'Network failed', bytesTotal: 15 }];
|
||||
rebuildJobIndex();
|
||||
const started = await _retryFailedFromBuckets({ network: [{ jobId: 'ui-bucket-first', fileName: 'shared.bin', hoster: 'voe.sx' }, { jobId: 'ui-bucket-second', fileName: 'shared.bin', hoster: 'voe.sx' }] }, true);
|
||||
uploading = false;
|
||||
return { started, statuses: queueJobs.map(job => [job.id, job.status]) };
|
||||
})()\`);
|
||||
const bucketRetryIds = bucketRetryPayload?.jobs?.map(job => job.id).sort() || [];
|
||||
check('Batch retry uses exact job IDs and starts only the requested duplicate-name jobs', bucketRetryState.started === true && bucketRetryIds.join('|') === 'ui-bucket-first|ui-bucket-second' && bucketRetryState.statuses.find(entry => entry[0] === 'ui-bucket-unrelated')?.[1] === 'error');
|
||||
restoreInitialIpcHandler('start-upload');
|
||||
const failedHistoryRetention = await wc.executeJavaScript(\`(() => {
|
||||
const originalGlobalSettings = structuredClone(config.globalSettings || {});
|
||||
const summaryFor = jobs => ({ files: jobs.map(job => ({ name: job.fileName, size: job.bytesTotal, results: [{ jobId: job.id, hoster: job.hoster, status: 'done', download_url: 'https://example.invalid/' + job.id }] })) });
|
||||
config.globalSettings = { ...originalGlobalSettings, removeFromQueueOnDone: true };
|
||||
queueJobs = [{ id: 'ui-history-auto-remove', file: 'C:/ui/history-auto-remove.bin', fileName: 'history-auto-remove.bin', hoster: 'voe.sx', status: 'preview', bytesTotal: 91 }];
|
||||
selectedFiles = [];
|
||||
rebuildJobIndex();
|
||||
handleBatchDone(summaryFor(queueJobs), { deferPersistence: true, historyPersisted: false });
|
||||
const autoRemoveSnapshot = buildPersistedQueueState();
|
||||
const autoRemoveProtected = queueJobs.length === 1 && queueJobs[0].historyPending === true && autoRemoveSnapshot?.queueJobs?.[0]?.historyPending === true && autoRemoveSnapshot.queueJobs[0].result?.download_url === 'https://example.invalid/ui-history-auto-remove';
|
||||
config.globalSettings.pendingQueue = structuredClone(autoRemoveSnapshot);
|
||||
queueJobs = [];
|
||||
rebuildJobIndex();
|
||||
restoreQueueStateFromConfig();
|
||||
const restartProtected = queueJobs.length === 1 && queueJobs[0].historyPending === true && queueJobs[0].result?.download_url === 'https://example.invalid/ui-history-auto-remove';
|
||||
config.globalSettings = { ...originalGlobalSettings, removeFromQueueOnDone: false };
|
||||
queueJobs = Array.from({ length: 501 }, (_, index) => ({ id: 'ui-history-large-' + index, file: 'C:/ui/history-large-' + index + '.bin', fileName: 'history-large-' + index + '.bin', hoster: 'byse.sx', status: 'preview', bytesTotal: index + 1 }));
|
||||
selectedFiles = [];
|
||||
rebuildJobIndex();
|
||||
handleBatchDone(summaryFor(queueJobs), { deferPersistence: true, historyPersisted: false });
|
||||
const largeSnapshot = buildPersistedQueueState();
|
||||
const largeProtected = queueJobs.length === 501 && queueJobs.every(job => job.historyPending === true) && largeSnapshot?.queueJobs?.length === 501 && largeSnapshot.queueJobs[0].id === 'ui-history-large-0';
|
||||
const protectedJob = queueJobs[0];
|
||||
const originalPersistSoon = persistQueueStateSoon;
|
||||
const originalClearSoon = clearPersistedQueueStateSoon;
|
||||
let persistCalls = 0;
|
||||
let clearCalls = 0;
|
||||
persistQueueStateSoon = () => { persistCalls++; };
|
||||
clearPersistedQueueStateSoon = () => { clearCalls++; };
|
||||
const laterJob = { id: 'ui-history-later-batch', file: 'C:/ui/history-later.bin', fileName: 'history-later.bin', hoster: 'voe.sx', status: 'preview', bytesTotal: 92 };
|
||||
queueJobs.push(laterJob);
|
||||
rebuildJobIndex();
|
||||
handleBatchDone(summaryFor([laterJob]), { historyPersisted: true });
|
||||
persistQueueStateSoon = originalPersistSoon;
|
||||
clearPersistedQueueStateSoon = originalClearSoon;
|
||||
const laterBatchProtected = queueJobs.includes(protectedJob) && protectedJob.historyPending === true && persistCalls === 1 && clearCalls === 0;
|
||||
config.globalSettings = originalGlobalSettings;
|
||||
selectedFiles = [];
|
||||
queueJobs = [];
|
||||
rebuildJobIndex();
|
||||
renderQueueTable();
|
||||
return { autoRemoveProtected, restartProtected, largeProtected, laterBatchProtected };
|
||||
})()\`);
|
||||
check('Failed history persistence protects terminal results from auto-remove, restart loss, later batches, and 500-row pruning', failedHistoryRetention.autoRemoveProtected === true && failedHistoryRetention.restartProtected === true && failedHistoryRetention.largeProtected === true && failedHistoryRetention.laterBatchProtected === true);
|
||||
const finalSummaryCorrelation = await wc.executeJavaScript('(() => { queueJobs = [{ id: "summary-exact-a", file: "C:/ui/shared-a.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "summary-exact-b", file: "C:/ui/shared-b.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 11 }, { id: "summary-ambiguous", file: "C:/ui/shared-c.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 12 }, { id: "summary-legacy-unique", file: "C:/ui/unique.bin", fileName: "unique.bin", hoster: "byse.sx", status: "preview", bytesTotal: 13 }]; rebuildJobIndex(); applySummaryResults({ files: [{ name: "shared.bin", size: 10, results: [{ jobId: "summary-exact-a", hoster: "voe.sx", status: "done", download_url: "https://example.invalid/exact-a" }, { jobId: "missing-summary-id", hoster: "voe.sx", status: "error", error: "Must not use legacy fallback" }, { hoster: "voe.sx", status: "error", error: "Ambiguous legacy result" }] }, { name: "different-name.bin", size: 11, results: [{ jobId: "summary-exact-b", hoster: "different.invalid", status: "done", download_url: "https://example.invalid/exact-b" }] }, { name: "unique.bin", size: 13, results: [{ hoster: "byse.sx", status: "done", download_url: "https://example.invalid/legacy-unique" }] }] }); const result = queueJobs.map(job => ({ id: job.id, status: job.status, error: job.error || null, link: job.result?.download_url || null })); queueJobs = []; selectedFiles = []; rebuildJobIndex(); renderQueueTable(); return result; })()');
|
||||
check('Final summary correlates by exact jobId and uses legacy identity only for one unique candidate', finalSummaryCorrelation[0].status === 'done' && finalSummaryCorrelation[0].link === 'https://example.invalid/exact-a' && finalSummaryCorrelation[1].status === 'done' && finalSummaryCorrelation[1].link === 'https://example.invalid/exact-b' && finalSummaryCorrelation[2].status === 'preview' && finalSummaryCorrelation[2].error === null && finalSummaryCorrelation[3].status === 'done' && finalSummaryCorrelation[3].link === 'https://example.invalid/legacy-unique');
|
||||
const completionIdentityRaces = await wc.executeJavaScript(\`(() => {
|
||||
@@ -3012,6 +3193,9 @@ setTimeout(async () => {
|
||||
return {
|
||||
headerVisible: Boolean(headerRect && headerRect.width > 0 && headerRect.left >= containerRect.left - 1 && headerRect.right <= containerRect.right + 1),
|
||||
cellVisible: Boolean(cellRect && cellRect.width > 0 && cellRect.left >= containerRect.left - 1 && cellRect.right <= containerRect.right + 1),
|
||||
containerRect: { left: containerRect.left, right: containerRect.right, width: containerRect.width },
|
||||
headerRect: headerRect ? { left: headerRect.left, right: headerRect.right, width: headerRect.width } : null,
|
||||
cellRect: cellRect ? { left: cellRect.left, right: cellRect.right, width: cellRect.width } : null,
|
||||
headerHeight: headerRect?.height,
|
||||
rowHeight: row?.getBoundingClientRect().height,
|
||||
sidebarIndicatorAligned: Boolean(sidebarIndicatorRect && activeSidebarRect && Math.abs(sidebarIndicatorRect.top - activeSidebarRect.top) <= 1 && Math.abs(sidebarIndicatorRect.width - activeSidebarRect.width) <= 1 && Math.abs(sidebarIndicatorRect.height - activeSidebarRect.height) <= 1)
|
||||
@@ -3054,6 +3238,7 @@ setTimeout(async () => {
|
||||
})()\`);
|
||||
await setWindowBounds(originalBounds);
|
||||
await wc.executeJavaScript('queueJobs = []; rebuildJobIndex(); updateUploadView(); renderQueueTable(); updateStatusBar();');
|
||||
if (!(queueProgressVisibility.minimum.headerVisible && queueProgressVisibility.minimum.cellVisible)) console.log('Queue progress visibility: ' + JSON.stringify(queueProgressVisibility));
|
||||
check('Upload progress stays visible at the standard window size', queueProgressVisibility.standard.headerVisible && queueProgressVisibility.standard.cellVisible);
|
||||
check('Upload progress stays visible at the minimum window size', queueProgressVisibility.minimum.headerVisible && queueProgressVisibility.minimum.cellVisible);
|
||||
check('Responsive queue keeps a compact table header', queueProgressVisibility.standard.headerHeight <= 34 && queueProgressVisibility.minimum.headerHeight <= 34);
|
||||
@@ -3232,7 +3417,7 @@ setTimeout(async () => {
|
||||
|
||||
restoreInitialIpcHandler('save-global-settings');
|
||||
restoreInitialIpcHandler('save-pending-queue');
|
||||
const keepaliveWindow = new BrowserWindow({ show: false });
|
||||
const keepaliveWindow = new globalThis.__mhuBrowserWindowConstructor({ show: false });
|
||||
realAppQuit = app.quit.bind(app);
|
||||
app.relaunch = () => { relaunchCalls++; };
|
||||
app.quit = () => {
|
||||
@@ -3330,6 +3515,7 @@ setTimeout(async () => {
|
||||
failed++;
|
||||
}
|
||||
|
||||
check('Every UI smoke window remains offscreen after every interaction', hiddenWindowHarness.areNativeSurfacesSuppressed(hiddenWindowHarness.getWindows()));
|
||||
const printResults = () => {
|
||||
console.log('\\n=== Results ===');
|
||||
results.forEach(r => console.log(r));
|
||||
@@ -3360,7 +3546,7 @@ try {
|
||||
const result = execFileSync(
|
||||
electronPath,
|
||||
[`--user-data-dir=${userDataPath}`, '--require', injectPath, mainPath],
|
||||
{ cwd: path.join(__dirname, '..'), timeout: 120000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
{ cwd: path.join(__dirname, '..'), timeout: 180000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
console.log(result);
|
||||
const isolatedConfigPath = path.join(userDataPath, 'electron-config.json');
|
||||
|
||||
@@ -23,6 +23,75 @@ 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) => {
|
||||
@@ -252,3 +321,18 @@ test('incompatible existing release title fails closed', async () => {
|
||||
/Refusing recovery for v3\.3\.109: existing release title "Multi-Hoster-Upload v3\.3\.109" does not match "Multi Hoster Uploader 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"/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -85,6 +85,222 @@ test('terminal finalization is delivered once per ready renderer generation and
|
||||
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 = [];
|
||||
@@ -121,12 +337,13 @@ test('history failure remains visible to the durable terminal finalization barri
|
||||
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 only after history-independent queue acknowledgement', async () => {
|
||||
test('terminal recovery clears after history and queue are durable', async () => {
|
||||
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||
const recoveries = [];
|
||||
const barrier = createBarrier({
|
||||
@@ -151,3 +368,35 @@ test('terminal recovery clears only after history-independent queue acknowledgem
|
||||
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' }]
|
||||
}]);
|
||||
});
|
||||
|
||||
@@ -301,6 +301,52 @@ test('an uncertain remote commit blocks retries, account fallback, and later sam
|
||||
assert.equal(summary.succeeded, 0);
|
||||
assert.equal(summary.failed, 2);
|
||||
assert.deepEqual(calls, [{ file: firstPath, apiKey: 'ACCOUNT_KEY_A' }]);
|
||||
assert.deepEqual(summary.files.flatMap(file => file.results).map(result => result.remoteCommitUncertain), [true, true]);
|
||||
});
|
||||
|
||||
test('an uncertain fallback commit stops all retries and further account rotation', async () => {
|
||||
const calls = [];
|
||||
loadManager(async (hoster, file, apiKey) => {
|
||||
calls.push(apiKey);
|
||||
if (apiKey === 'PRIMARY_KEY') {
|
||||
const error = new Error('Primary account rejected the request');
|
||||
error.accountError = true;
|
||||
throw error;
|
||||
}
|
||||
if (apiKey === 'FALLBACK_KEY') {
|
||||
const error = new Error('Remote commit could not be confirmed');
|
||||
error.remoteCommitUncertain = true;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
file_code: 'UNSAFE_THIRD_RESULT',
|
||||
download_url: 'https://byse.sx/d/UNSAFE_THIRD_RESULT'
|
||||
};
|
||||
});
|
||||
const hosterSettings = settings('byse.sx', 1);
|
||||
hosterSettings['byse.sx'].retries = 2;
|
||||
const manager = new UploadManager(hosterSettings);
|
||||
manager.on('account-failed', ({ accountId }) => {
|
||||
if (accountId === 'PRIMARY') {
|
||||
manager.switchAccount('byse.sx', { id: 'FALLBACK', apiKey: 'FALLBACK_KEY' });
|
||||
} else if (accountId === 'FALLBACK') {
|
||||
manager.switchAccount('byse.sx', { id: 'THIRD', apiKey: 'THIRD_KEY' });
|
||||
}
|
||||
});
|
||||
|
||||
const summary = await runBatch(manager, [{
|
||||
jobId: 'fallback-uncertain',
|
||||
file: distinctPath,
|
||||
hoster: 'byse.sx',
|
||||
accountId: 'PRIMARY',
|
||||
apiKey: 'PRIMARY_KEY'
|
||||
}]);
|
||||
const result = summary.files[0].results[0];
|
||||
|
||||
assert.deepEqual(calls, ['PRIMARY_KEY', 'FALLBACK_KEY']);
|
||||
assert.equal(result.status, 'error');
|
||||
assert.equal(result.remoteCommitUncertain, true);
|
||||
assert.equal(manager.getFailedAccountKeys().includes('byse.sx:FALLBACK'), false);
|
||||
});
|
||||
|
||||
test('recovery claims do not leak into a later batch on the same manager', async () => {
|
||||
@@ -722,7 +768,7 @@ test('batch completion clears Doodstream key and baseline caches', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('upload interval is enforced at the admitted upload start', async () => {
|
||||
test('upload interval admission uses the real serialized gate before every upload start', async () => {
|
||||
const events = [];
|
||||
let releaseFirst;
|
||||
let markFirstStarted;
|
||||
@@ -749,9 +795,10 @@ test('upload interval is enforced at the admitted upload start', async () => {
|
||||
const hosterSettings = settings('byse.sx', 1);
|
||||
hosterSettings['byse.sx'].timeIntervalSec = 1;
|
||||
const manager = new UploadManager(hosterSettings);
|
||||
const originalWait = manager._waitForInterval.bind(manager);
|
||||
manager._waitForInterval = async (hoster, intervalMs, signal, acquireSlots) => {
|
||||
events.push('interval');
|
||||
await acquireSlots();
|
||||
await originalWait(hoster, 0, signal, acquireSlots);
|
||||
};
|
||||
const batch = runBatch(manager, [
|
||||
{ jobId: 'interval-first', file: firstPath, hoster: 'byse.sx', accountId: 'BYSE_ACCOUNT', apiKey: 'BYSE_KEY' },
|
||||
@@ -761,11 +808,11 @@ test('upload interval is enforced at the admitted upload start', async () => {
|
||||
|
||||
await waitFor(firstStarted, 500, 'First admitted upload did not start');
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const intervalsBeforeRelease = events.filter(event => event === 'interval').length;
|
||||
const uploadsBeforeRelease = uploadCount;
|
||||
releaseFirst();
|
||||
const summary = await batch;
|
||||
|
||||
assert.equal(intervalsBeforeRelease, 1, JSON.stringify(events));
|
||||
assert.equal(uploadsBeforeRelease, 1, JSON.stringify(events));
|
||||
assert.equal(summary.succeeded, 3);
|
||||
assert.deepEqual(events.filter(event => event === 'interval'), ['interval', 'interval', 'interval']);
|
||||
});
|
||||
@@ -807,11 +854,14 @@ test('an interval wait never occupies the global slot of another hoster', async
|
||||
return originalWait(hoster, 0, signal, acquireSlots);
|
||||
};
|
||||
const batch = runBatch(manager, [
|
||||
{ jobId: 'interval-waiting-hoster', file: firstPath, hoster: 'byse.sx', accountId: 'BYSE_ACCOUNT', apiKey: 'BYSE_KEY' },
|
||||
{ jobId: 'interval-independent-hoster', file: distinctPath, hoster: 'voe.sx', accountId: 'VOE_ACCOUNT', apiKey: 'VOE_KEY' }
|
||||
{ jobId: 'interval-waiting-hoster', file: firstPath, hoster: 'byse.sx', accountId: 'BYSE_ACCOUNT', apiKey: 'BYSE_KEY' }
|
||||
]);
|
||||
|
||||
await waitFor(intervalWaiting, 500, 'Configured interval did not start waiting');
|
||||
const added = manager.addJobs([
|
||||
{ jobId: 'interval-independent-hoster', file: distinctPath, hoster: 'voe.sx', accountId: 'VOE_ACCOUNT', apiKey: 'VOE_KEY' }
|
||||
]);
|
||||
assert.equal(added.added, 1);
|
||||
let blockedError = null;
|
||||
try {
|
||||
await waitFor(otherStarted, 500, 'Interval wait occupied the global upload slot');
|
||||
|
||||
@@ -538,7 +538,269 @@ 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) => {
|
||||
|
||||
@@ -24,7 +24,7 @@ test('terminal recovery snapshots retain exact job outcomes and canonical links'
|
||||
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' } },
|
||||
{ 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' }
|
||||
]
|
||||
}]
|
||||
@@ -43,6 +43,7 @@ test('terminal recovery snapshots retain exact job outcomes and canonical links'
|
||||
status: 'error',
|
||||
error: 'rejected',
|
||||
failureDetails: { kind: 'hoster' },
|
||||
remoteCommitUncertain: true,
|
||||
result: null
|
||||
}
|
||||
]);
|
||||
@@ -52,12 +53,20 @@ test('recovery markers affect only their exact job IDs and never restart termina
|
||||
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
|
||||
}]
|
||||
};
|
||||
|
||||
@@ -66,13 +75,35 @@ test('recovery markers affect only their exact job IDs and never restart termina
|
||||
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');
|
||||
@@ -84,7 +115,7 @@ test('main and renderer keep recovery evidence until final queue persistence suc
|
||||
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 \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveRecovery\(null\)/);
|
||||
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/);
|
||||
@@ -101,5 +132,10 @@ test('main and renderer keep recovery evidence until final queue persistence suc
|
||||
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'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user