feat: add script version history (#3085)

* feat: add script version history

* fix: validate canonical save-as paths before backup

* fix: stack script history footer on small screens
This commit is contained in:
whyour
2026-10-06 22:00:11 +08:00
committed by GitHub
parent 2cab112391
commit fa8910b3d6
22 changed files with 2487 additions and 80 deletions
+121
View File
@@ -0,0 +1,121 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const path = require('node:path');
const load = require('../helpers/load-security-module.cjs');
function fixture(send) {
const dialogs = [],
notices = [],
calls = [];
const request = {};
for (const method of ['put', 'post'])
request[method] = async (url, body, options) => {
calls.push({ method, body });
const result = await send(body);
if (result.status) return options.onError(result);
return result;
};
const { saveWithHistory } = load(
path.resolve(__dirname, '../../src/pages/script/saveWithHistory.ts'),
{
'@/utils/http': { request },
antd: {
Modal: {
confirm: (dialog) => {
dialogs.push(dialog);
return { update: (options) => Object.assign(dialog, options) };
},
},
message: Object.fromEntries(
['error', 'warning', 'success'].map((type) => [
type,
(text) => notices.push({ type, text }),
]),
),
},
'react-intl-universal': {
__esModule: true,
default: { get: (key) => key },
},
},
);
return { saveWithHistory, dialogs, notices, calls };
}
const unavailable = {
status: 413,
data: { historyUnavailable: true, currentHash: 'a'.repeat(64) },
};
const tick = () => new Promise((resolve) => setImmediate(resolve));
test('oversized editor save requires explicit confirmation and cancellation keeps the draft unsaved', async () => {
const f = fixture(() => unavailable);
const pending = f.saveWithHistory('put', '/api/scripts', {
filename: 'large.js',
content: 'small',
});
await tick();
assert.equal(f.calls.length, 1);
assert.equal(f.dialogs.length, 1);
assert.match(f.dialogs[0].content, /不会保留/);
f.dialogs[0].onCancel();
assert.equal(await pending, undefined);
assert.equal(f.calls.length, 1);
assert.equal(f.notices.length, 0);
});
test('debugger confirmation retries POST with the exact preview hash and displays a warning', async () => {
const f = fixture((body) =>
body.skipHistory
? { code: 200, data: { historyRecorded: false } }
: unavailable,
);
const pending = f.saveWithHistory('post', '/api/scripts', {
content: 'small',
});
await tick();
await f.dialogs[0].onOk();
assert.equal((await pending).code, 200);
assert.equal(f.dialogs[0].cancelButtonProps.disabled, true);
assert.equal(f.dialogs[0].keyboard, false);
assert.equal(f.calls[1].method, 'post');
assert.equal(f.calls[1].body.expectedHash, unavailable.data.currentHash);
assert.equal(f.calls[1].body.skipHistory, true);
assert.deepEqual(
f.notices.map((v) => v.type),
['warning'],
);
});
test('a stale confirmation reports the conflict without marking the editor saved', async () => {
const f = fixture((body) =>
body.skipHistory
? { status: 409, data: { message: 'changed' } }
: unavailable,
);
const pending = f.saveWithHistory('put', '/api/scripts', {});
await tick();
await f.dialogs[0].onOk();
assert.equal(await pending, undefined);
assert.deepEqual(f.notices, [{ type: 'error', text: 'changed' }]);
});
test('unrelated errors never offer history bypass, and post-commit cleanup failures remain successful saves', async () => {
const failed = fixture(() => ({
status: 500,
data: { message: 'storage failed' },
}));
assert.equal(
await failed.saveWithHistory('put', '/api/scripts', {}),
undefined,
);
assert.equal(failed.dialogs.length, 0);
const committed = fixture(() => ({
code: 200,
data: { cleanupPending: true },
}));
assert.equal(
(await committed.saveWithHistory('put', '/api/scripts', {})).code,
200,
);
assert.equal(committed.notices[0].type, 'warning');
});