Raiju/tests/inline-backups-smoke.js
2026-10-25 06:05:42 +03:00

114 lines
6.6 KiB
JavaScript

/* Test the actual toolbar controller with isolated DOM/API state; never writes to a site. */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
process.env.TZ = 'Europe/Vienna';
const source = fs.readFileSync(path.join(__dirname, '../js/inline-editor.js'), 'utf8');
const start = source.indexOf(' // Persisted page backups');
const end = source.indexOf(' async function showAdminBar()', start);
assert.ok(start > 0 && end > start);
const backup = {filename: 'de_team_2026-09-16_100000_abcdef.json', timestamp: Date.parse('2026-09-16T10:00:00Z') / 1000};
const translations = JSON.parse(fs.readFileSync(path.join(__dirname, '../admin/lang/editor-de.json'), 'utf8'));
function harness(replies = []) {
const nodes = new Map();
function element() {
return {hidden: true, disabled: false, children: [], _value: '', classList: {toggle() {}},
setAttribute() {},
get value() { return this._value; }, set value(value) { this._value = value; },
get options() { return this.children; },
get selectedIndex() { return this.children.findIndex(option => option.value === this.value); },
appendChild(child) { this.children.push(child); if (this.children.length === 1) this.value = child.value; },
replaceChildren(...children) { this.children = []; this.value = ''; children.forEach(child => this.appendChild(child)); }
};
}
const ids = ['admin-page-backups', 'admin-backup-select', 'admin-btn-restore-backup', 'admin-bar'];
ids.forEach(id => nodes.set(id, element()));
const config = {currentPage: 'de_team', csrfToken: 'fixture', apiUrl: '/admin/api.php',
contentRevisions: {de_team: 'r1'}, dirtyPages: new Set(), editMode: false, saving: false};
const requests = [], confirmations = [], toasts = [], session = new Map();
let reloads = 0, cleanups = 0;
const context = vm.createContext({
document: {documentElement: {lang: 'de'}, getElementById: id => nodes.get(id),
createElement: () => element(), querySelectorAll: () => [nodes.get('admin-btn-restore-backup')]},
EditorConfig: config, window: {}, FormData, Intl, Date,
fetch: async (url, options) => {
requests.push({url, fields: options?.body ? Object.fromEntries(options.body) : null});
const reply = replies.shift();
assert.ok(reply, 'Unexpected API request');
if (reply instanceof Error) throw reply;
return {ok: (reply.status || 200) < 400, status: reply.status || 200,
json: async () => ({success: true, ...reply})};
},
t: (key, params = {}) => Object.entries(params).reduce((s, [name, value]) => s.replace(`{${name}}`, value), translations[key] || key),
showToast: (...args) => toasts.push(args),
showConfirmDialog: (...args) => confirmations.push(args),
flushActiveInlineEdits() {},
exitEditModeClean() { cleanups++; config.dirtyPages.clear(); config.editMode = false; },
sessionStorage: {setItem: (key, value) => session.set(key, value), removeItem: key => session.delete(key)},
location: {reload() { reloads++; }}
});
const controller = vm.runInContext(source.slice(start, end) + '\nPageBackups;', context);
return {controller, config, nodes, requests, confirmations, toasts, session,
reloads: () => reloads, cleanups: () => cleanups};
}
(async () => {
const h = harness([{data: [backup, {...backup, filename: 'de_team_child_2026-09-16_100000.json'}]}, {data: null}]);
await h.controller.load();
assert.equal(h.controller.items.length, 1);
assert.equal(h.nodes.get('admin-page-backups').hidden, false);
assert.equal(h.nodes.get('admin-backup-select').options[0].textContent, 'Backup 16.09.26 12:00');
h.config.editMode = true;
h.config.dirtyPages.add('de_shared');
h.controller.requestRestore();
assert.equal(h.requests.length, 1, 'Selection and opening confirmation never restore');
assert.match(h.confirmations[0][2], /Ungespeicherte.*gemeinsam/);
await h.confirmations[0][3]();
assert.deepEqual(h.requests[1].fields, {action: 'restore', page: 'de_team', backup: backup.filename, revision: 'r1', csrf_token: 'fixture'});
assert.equal(h.reloads(), 1);
assert.equal(h.cleanups(), 1);
assert.equal(h.session.get('site-edit-mode'), 'true');
const empty = harness([{data: []}]);
await empty.controller.load();
assert.equal(empty.nodes.get('admin-page-backups').hidden, true);
const news = harness(); news.config.isNewsPost = true;
await news.controller.load();
assert.equal(news.requests.length, 0);
const unavailable = harness([new Error('offline'), {data: [backup]}]);
await unavailable.controller.load();
assert.equal(unavailable.nodes.get('admin-btn-restore-backup').textContent, 'Erneut laden');
assert.equal(unavailable.nodes.get('admin-backup-select').disabled, true);
await unavailable.controller.load();
assert.equal(unavailable.nodes.get('admin-btn-restore-backup').textContent, 'Übernehmen');
unavailable.config.saving = true;
unavailable.controller.requestRestore();
assert.equal(unavailable.confirmations.length, 0, 'Restore cannot race an active save');
for (const reply of [{status: 409, success: false}, new Error('response lost')]) {
const failure = harness([{data: [backup]}, reply]);
await failure.controller.load();
failure.config.dirtyPages.add('de_team');
failure.controller.requestRestore();
await failure.confirmations[0][3]();
assert.equal(failure.config.dirtyPages.size, 1, 'Failure preserves unsaved edits');
assert.equal(failure.cleanups(), 0);
assert.equal(failure.reloads(), 0);
assert.equal(failure.nodes.get('admin-btn-restore-backup').disabled, true);
failure.controller.requestRestore();
assert.equal(failure.requests.length, 2, 'Stale or uncertain restores are never retried');
}
const browsing = harness([{data: [backup]}, {data: null}]);
await browsing.controller.load();
browsing.controller.requestRestore();
assert.match(browsing.confirmations[0][2], /gespeicherte Stand/);
await browsing.confirmations[0][3]();
assert.equal(browsing.session.has('site-edit-mode'), false);
assert.equal(browsing.reloads(), 1);
console.log('PASS inline backups: local dates, page scope, confirmation, unsaved edits, browse/edit modes, empty/error/retry states, save guard and failed/conflicting restore');
})().catch(error => {console.error(error); process.exitCode = 1;});