mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-03 03:32:25 +01:00
fix(research): retain reports after failed deletion (#7043)
This commit is contained in:
@@ -941,13 +941,32 @@ function _exitHistorySelectMode() {
|
|||||||
_renderJobs();
|
_renderJobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function _deleteResearchFromDisk(id) {
|
||||||
|
const response = await fetch(`${_apiBase}/api/research/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||||
|
if (!response.ok) throw new Error('Research deletion failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showDeleteFailure(count) {
|
||||||
|
if (typeof uiModule !== 'undefined' && uiModule?.showError) {
|
||||||
|
uiModule.showError(`Could not delete ${count} research ${count === 1 ? 'report' : 'reports'}. Please try again.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function _deleteHistoryJobs(ids) {
|
async function _deleteHistoryJobs(ids) {
|
||||||
const selected = new Set(ids);
|
const selected = new Set(ids);
|
||||||
const selectedJobs = jobs.getJobs().filter(job => selected.has(job.id));
|
const selectedJobs = jobs.getJobs().filter(job => selected.has(job.id));
|
||||||
await Promise.all(selectedJobs.filter(job => job.status === 'done').map(async job => {
|
let failed = 0;
|
||||||
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
|
await Promise.all(selectedJobs.map(async job => {
|
||||||
|
try {
|
||||||
|
if (job.status === 'done') await _deleteResearchFromDisk(job.id);
|
||||||
|
_historySelectedIds.delete(job.id);
|
||||||
|
jobs.removeJob(job.id);
|
||||||
|
} catch {
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
selectedJobs.forEach(job => jobs.removeJob(job.id));
|
if (failed) _showDeleteFailure(failed);
|
||||||
|
return failed === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _bulkDeleteHistory() {
|
async function _bulkDeleteHistory() {
|
||||||
@@ -957,8 +976,8 @@ async function _bulkDeleteHistory() {
|
|||||||
const ok = await window.styledConfirm(`Delete ${ids.length} research ${ids.length === 1 ? 'report' : 'reports'}?`, { confirmText: 'Delete', danger: true });
|
const ok = await window.styledConfirm(`Delete ${ids.length} research ${ids.length === 1 ? 'report' : 'reports'}?`, { confirmText: 'Delete', danger: true });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
await _deleteHistoryJobs(ids);
|
if (await _deleteHistoryJobs(ids)) _exitHistorySelectMode();
|
||||||
_exitHistorySelectMode();
|
else _updateHistoryBulkBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _clearHistory() {
|
async function _clearHistory() {
|
||||||
@@ -1296,7 +1315,12 @@ function _buildJobCard(job) {
|
|||||||
const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { confirmText: 'Delete', danger: true });
|
const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { confirmText: 'Delete', danger: true });
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
|
try {
|
||||||
|
await _deleteResearchFromDisk(job.id);
|
||||||
|
} catch {
|
||||||
|
_showDeleteFailure(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
overflow.close();
|
overflow.close();
|
||||||
_animateOutThenRemove(card, () => jobs.removeJob(job.id));
|
_animateOutThenRemove(card, () => jobs.removeJob(job.id));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""History controllers must not dismiss reports the server failed to delete."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _run(outcomes, *, bulk=False, clear=False, statuses=None):
|
||||||
|
if not shutil.which("node"):
|
||||||
|
pytest.skip("node is required to execute research controllers")
|
||||||
|
script = r"""
|
||||||
|
const fs = require('fs'), vm = require('vm');
|
||||||
|
const options = JSON.parse(process.argv[1]);
|
||||||
|
const saved = new Map();
|
||||||
|
const errors = [], calls = [];
|
||||||
|
const context = {
|
||||||
|
localStorage: {getItem:k=>saved.get(k)||null, setItem:(k,v)=>saved.set(k,v)},
|
||||||
|
document: {getElementById:()=>null},
|
||||||
|
uiModule: {showError:message=>errors.push(message)},
|
||||||
|
window: {styledConfirm:async()=>true},
|
||||||
|
fetch: async (url, opts) => {
|
||||||
|
const id = url.split('/').pop();calls.push({id, method:opts.method, credentials:opts.credentials});
|
||||||
|
const outcome = options.outcomes[id] ?? 200;
|
||||||
|
if (outcome === 'network') throw new Error('scripted network failure');
|
||||||
|
return {ok:outcome>=200 && outcome<300, status:outcome};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
vm.createContext(context);
|
||||||
|
const jobs = fs.readFileSync('static/js/research/jobs.js','utf8');
|
||||||
|
vm.runInContext(jobs.replace(/export /g, ''), context);
|
||||||
|
vm.runInContext('globalThis.jobs = {getJobs,removeJob};', context);
|
||||||
|
context.seed = Object.entries(options.statuses || {a:'done',b:'done',untouched:'done'}).map(([id,status])=>({id,status}));
|
||||||
|
vm.runInContext('getJobs().push(...seed);', context);
|
||||||
|
const panel = fs.readFileSync('static/js/research/panel.js','utf8');
|
||||||
|
const start = panel.indexOf('function _updateHistoryBulkBar(');
|
||||||
|
const end = panel.indexOf('function _renderJobs(', start);
|
||||||
|
if (start < 0 || end < 0) throw new Error('history controller boundaries missing');
|
||||||
|
vm.runInContext("_apiBase=''; let _historySelectMode=true; let _historySelectedIds=new Set(['a','b']); let _visibleHistoryIds=['a','b']; function _renderJobs(){};"+panel.slice(start,end), context);
|
||||||
|
(async()=>{
|
||||||
|
const command = options.clear ? '_clearHistory()' : options.bulk ? '_bulkDeleteHistory()' : "_deleteHistoryJobs(['a','b'])";
|
||||||
|
await vm.runInContext(command, context);
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ids:vm.runInContext('getJobs().map(j=>j.id)',context),
|
||||||
|
selected:vm.runInContext('[..._historySelectedIds]',context),
|
||||||
|
selectionMode:vm.runInContext('_historySelectMode',context),
|
||||||
|
dismissed:JSON.parse(saved.get('odysseus-research-dismissed')||'[]'),
|
||||||
|
errors,calls,
|
||||||
|
}));
|
||||||
|
})().catch(e=>{console.error(e);process.exitCode=1});
|
||||||
|
"""
|
||||||
|
args = dict(outcomes=outcomes, bulk=bulk, clear=clear, statuses=statuses)
|
||||||
|
result = subprocess.run(["node", "-e", script, json.dumps(args)], cwd=ROOT,
|
||||||
|
capture_output=True, text=True, timeout=30)
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure", [403, 404, 500, 503, "network"])
|
||||||
|
def test_failed_reports_remain_visible_and_undismissed(failure):
|
||||||
|
result = _run({"a": failure, "b": failure})
|
||||||
|
assert result["ids"] == ["a", "b", "untouched"]
|
||||||
|
assert result["dismissed"] == []
|
||||||
|
assert len(result["errors"]) == 1
|
||||||
|
assert "2" in result["errors"][0]
|
||||||
|
assert all(call["method"] == "DELETE" and call["credentials"] == "same-origin"
|
||||||
|
for call in result["calls"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("failure", [500, "network"])
|
||||||
|
def test_mixed_bulk_deletion_keeps_only_failed_selection_for_retry(failure):
|
||||||
|
result = _run({"a": 200, "b": failure}, bulk=True)
|
||||||
|
assert result["ids"] == ["b", "untouched"]
|
||||||
|
assert result["dismissed"] == ["a"]
|
||||||
|
assert result["selectionMode"] is True
|
||||||
|
assert result["selected"] == ["b"]
|
||||||
|
assert len(result["errors"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_bulk_deletion_exits_selection():
|
||||||
|
result = _run({"a": 200, "b": 200}, bulk=True)
|
||||||
|
assert result["ids"] == ["untouched"]
|
||||||
|
assert result["selectionMode"] is False
|
||||||
|
assert result["selected"] == []
|
||||||
|
assert result["dismissed"] == ["a", "b"]
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_failed_job_removal_needs_no_server_request():
|
||||||
|
result = _run({"a": 500}, statuses={"a": "error", "b": "cancelled"})
|
||||||
|
assert result["ids"] == []
|
||||||
|
assert result["calls"] == []
|
||||||
|
assert result["errors"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_history_preserves_running_jobs_and_failed_deletions():
|
||||||
|
result = _run({"a": 500}, clear=True, statuses={"a": "done", "b": "running"})
|
||||||
|
assert result["ids"] == ["a", "b"]
|
||||||
|
assert result["dismissed"] == []
|
||||||
|
assert [call["id"] for call in result["calls"]] == ["a"]
|
||||||
Reference in New Issue
Block a user