feat: add safe backup management and theme preferences

Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
This commit is contained in:
2026-10-17 00:36:21 +08:00
parent 09e202796f
commit 602917698a
20 changed files with 1240 additions and 83 deletions
+81
View File
@@ -0,0 +1,81 @@
import { expect, it, vi } from 'vitest';
const state = vi.hoisted(() => ({
slots: [] as any[], cursor: 0, resourceCursor: 0, resources: [] as any[],
}));
vi.mock('react', async original => ({
...await original<typeof import('react')>(),
useState(value: unknown) {
const slots = state.slots, i = state.cursor++;
if (!(i in slots)) slots[i] = value;
return [slots[i], (next: unknown) => {slots[i] = typeof next === 'function' ? next(slots[i]) : next;}];
},
}));
vi.mock('./ui', async original => ({
...await original<typeof import('./ui')>(),
useResource: () => state.resources[state.resourceCursor++],
useAction: () => ({busy: false, feedback: null}),
}));
vi.mock('./components/ConfirmDialog', () => ({useConfirm: () => ({element: null})}));
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
import { Instances } from './Instances';
import { ProfileEditor } from './ProfileEditor';
import { BackupPanel } from './BackupPanel';
function nodes(root: any, predicate: (node: any) => boolean): any[] {
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
if (!root || typeof root !== 'object') return [];
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
}
it('preserves the unsaved Profile draft on backup refresh and reloads it only after restore', async () => {
const instance = {id: 'one', name: 'Test instance', status: 'stopped', created_at: '', version: 'test'};
const instances = {data: [instance], loading: false, refresh: vi.fn(async () => {})};
const usage = {data: {used_bytes: 1, quota_bytes: 100}, loading: false, refresh: vi.fn(async () => {})};
const profile = {
data: {content: 'name: original'}, loading: false,
refresh: vi.fn(async () => {profile.data = {content: 'name: restored'};}),
};
const parentSlots: any[] = [], detailSlots: any[] = [];
function renderParent() {
state.slots = parentSlots; state.cursor = 0; state.resourceCursor = 0;
state.resources = [instances, usage];
return Instances();
}
function renderDetail() {
const detail = nodes(renderParent(), n => typeof n.type === 'function' && n.props.instance?.id === instance.id)[0];
expect(detail).toBeDefined();
state.slots = detailSlots; state.cursor = 0; state.resourceCursor = 0;
state.resources = [{data: []}, profile, {data: undefined}, {data: undefined}];
return detail.type(detail.props);
}
const editor = (tree: any) => nodes(tree, n => n.type === ProfileEditor && n.props.onChange)[0];
const backup = (tree: any) => nodes(tree, n => n.type === BackupPanel)[0];
const save = (tree: any) => nodes(tree, n => n.type === 'button' && n.props.children === '保存并启动')[0];
nodes(renderParent(), n => n.type === 'button' && n.props.children === '管理')[0].props.onClick();
let tree = renderDetail();
expect(editor(tree).props.value).toBe('name: original');
expect(save(tree).props.disabled).toBe(true);
editor(tree).props.onChange('name: unsaved draft');
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: unsaved draft');
expect(save(tree).props.disabled).toBe(false);
await backup(tree).props.refresh();
expect(instances.refresh).toHaveBeenCalledOnce();
expect(usage.refresh).toHaveBeenCalledOnce();
expect(profile.refresh).not.toHaveBeenCalled();
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: unsaved draft');
expect(save(tree).props.disabled).toBe(false);
await backup(tree).props.onRestored();
expect(profile.refresh).toHaveBeenCalledOnce();
expect(instances.refresh).toHaveBeenCalledTimes(2);
expect(usage.refresh).toHaveBeenCalledTimes(2);
tree = renderDetail();
expect(editor(tree).props.value).toBe('name: restored');
expect(save(tree).props.disabled).toBe(true);
});