1.1.1: fullbak

This commit is contained in:
th committed 2026-04-25 00:40:53 +02:00
1 parent d59201100b
commit 01c13981c4
6 files changed
+443

No files matched your search

+119
View File
@@ -1943,6 +1943,102 @@ export function registerAllTests(): void {
})
})
suite('ruwbak — multi-project backup roundtrip', () => {
// A .ruwbak is a zip holding one manifest.json + one packaged
// .ruw per project. Covers the "back up everything in browser
// storage" / "restore a backup" flow exposed from the welcome
// dialog.
test('pack + unpack preserves each project with its metadata', async () => {
const { packBackup, unpackBackup } = await import('@/lib/backupFile')
const a = makeFreshStore()
a.addAsset({ kind: 'rect', name: 'A', width: 10, height: 10, fill: '#000' })
const projectA = JSON.parse(JSON.stringify(a.projectForSave))
const b = makeFreshStore()
b.addAsset({ kind: 'rect', name: 'B', width: 20, height: 20, fill: '#fff' })
const projectB = JSON.parse(JSON.stringify(b.projectForSave))
const bundle = packBackup([
{ id: 'id-a', name: 'Alpha', savedAt: 1, project: projectA },
{ id: 'id-b', name: 'Beta', savedAt: 2, project: projectB },
])
const out = unpackBackup(bundle)
expect(out.length).toBe(2)
expect(out[0].id).toBe('id-a')
expect(out[0].name).toBe('Alpha')
expect(out[0].savedAt).toBe(1)
expect(out[0].project.assets.some((x) => x.name === 'A')).toBe(true)
expect(out[1].id).toBe('id-b')
expect(out[1].project.assets.some((x) => x.name === 'B')).toBe(true)
})
test('backup survives asset data through the inner .ruw bundles', async () => {
const { packBackup, unpackBackup } = await import('@/lib/backupFile')
const s = makeFreshStore()
const imgSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAAC0lEQVQI12NgAAIAAAUAAeImBZsAAAAASUVORK5CYII='
s.addAsset({
kind: 'image', name: 'pixel', src: imgSrc, width: 1, height: 1,
})
const project = JSON.parse(JSON.stringify(s.projectForSave))
const bundle = packBackup([
{ id: 'only', name: 'Sole', savedAt: 0, project },
])
const [rec] = unpackBackup(bundle)
const img = rec.project.assets.find((a) => a.name === 'pixel')! as {
src: string
}
// Image round-trips all the way: through inner .ruw asset
// extraction + rehydration via the outer backup zip.
expect(img.src).toBe(imgSrc)
})
test('unpackBackup rejects non-zip + wrong magic + missing files', async () => {
const { unpackBackup, packBackup } = await import('@/lib/backupFile')
// Non-zip input.
let err: unknown
try { unpackBackup(new Uint8Array([0x7b, 0x7d])) } catch (e) { err = e }
expect((err as Error).message).toContain('ZIP')
// Wrong-magic manifest.
const { zipStore } = await import('@/lib/zipUtil')
const badManifest = zipStore([{
name: 'manifest.json',
data: new TextEncoder().encode(JSON.stringify({ magic: 'nope', version: 1, projects: [] })),
}])
err = null
try { unpackBackup(badManifest) } catch (e) { err = e }
expect((err as Error).message.toLowerCase()).toContain('magic')
// Missing referenced project file.
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const goodBundle = packBackup([
{ id: 'missing', name: 'Lost', savedAt: 1, project: JSON.parse(JSON.stringify(s.projectForSave)) },
])
// Strip the inner .ruw to simulate corruption.
const { unzipStore } = await import('@/lib/zipUtil')
const entries = unzipStore(goodBundle).filter(
(e) => !e.name.startsWith('projects/'),
)
const corrupt = zipStore(entries)
err = null
try { unpackBackup(corrupt) } catch (e) { err = e }
expect((err as Error).message.toLowerCase()).toContain('missing')
})
test('duplicate ids in the input get unique file paths inside the zip', async () => {
const { packBackup } = await import('@/lib/backupFile')
const { unzipStore } = await import('@/lib/zipUtil')
const s = makeFreshStore()
s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#fff' })
const project = JSON.parse(JSON.stringify(s.projectForSave))
const bundle = packBackup([
{ id: 'dup', name: 'A', savedAt: 1, project },
{ id: 'dup', name: 'B', savedAt: 2, project },
])
const files = unzipStore(bundle).map((e) => e.name).filter((n) => n.startsWith('projects/'))
// Two distinct project files, one suffixed to avoid collision.
expect(new Set(files).size).toBe(2)
})
})
suite('editor state — save / load roundtrip', () => {
// The store's editor block carries canvas zoom/pan, overlay
// toggles and timeline display settings. It rides along with the
@@ -2842,6 +2938,29 @@ export function registerAllTests(): void {
const back = await getStoredProject('definitely-missing-id-42')
expect(back).toBeNull()
})
test('listAllProjects returns every record — no 8-recents cap', async () => {
// Backup flow needs the full dump, not just the top 8 that
// the welcome dialog shows.
const { listAllProjects } = await import('@/lib/projectStore')
const s = makeFreshStore()
const ids: string[] = []
const prefix = `test-backup-${Date.now()}-`
for (let i = 0; i < 10; i++) ids.push(`${prefix}${i}`)
try {
for (const id of ids) {
await saveProjectToStore(s.project, { id, name: id })
await tick(1)
}
const all = await listAllProjects()
// At least our 10 show up, even though listRecentProjects
// would only return 8.
const seen = ids.filter((id) => all.some((r) => r.id === id))
expect(seen.length).toBe(10)
} finally {
for (const id of ids) await deleteStoredProject(id).catch(() => {})
}
})
})
suite('ui — WelcomeDialog', () => {