Autosave only keeps the latest revision. Before a risky change there
was no way to put a known good state aside and go back to it later.
The main menu now has "Snapshots..." to create, restore and delete
named copies of the open project.
- Server: SnapshotStore.php keeps each snapshot as a copy of the
project JSON under data/snapshots/<project>/ (git-ignored). Four
routes under projects/{id}/snapshots use the same owner check as the
project, so an admin also sees foreign snapshots.
- Create writes the autosave first. With unsaved edits (play with open
edits, failed save, conflict) no snapshot is made and a message says
why. The rule is the pure function in src/editor/snapshots.ts.
- An empty name falls back to the local date and time.
- Restore asks first, then keeps the current state as "Before
restoring <name>". It writes the snapshot back as a new revision and
reloads the project, so the undo list is empty. A restore can itself
be undone by restoring that snapshot.
- Deleting a project deletes its snapshots too. Images, sounds, fonts
and pixel art are content-addressed and never deleted, so a snapshot
can always reach them.
- Client: SnapshotStore and HttpSnapshotStore in src/core/store, the
SnapshotsDialog, and the flows in ProjectSession. A stale revision
(409) becomes StaleRevisionError.
- CI runs the new PHP test in the gate job next to accounts.
Not covered by e2e: the lock during play and the conflict case. Both
are unit tests only.
Tests:
- check.sh: 164 files, 2401 tests, CHECK EXIT 0.
- tests/php/snapshots.test.php: 14 checks, all passed.
- tests/editor/snapshots.test.ts: 5 tests for the time format, the
blocker rule, the routes and 409.
- New e2e module 50 on a throwaway project: create, rename, restore,
undo the restore, delete, then delete the project and get 404 on
its snapshots. 46b expects the new menu entry. e2e 46,50: 20 checks
passed. The check sum goes from 1385 to 1393 (computed).
- Counter-checks, originals restored and compared with cmp: blocker
always allowed fails 1 unit test. No sorting fails 2 PHP checks.
Restore without keeping the old state fails 50c and aborts 50d.
- The first sort test with 2 snapshots passed by chance half the time
because of random ids. It now uses 8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
68 lines
2.8 KiB
TypeScript
68 lines
2.8 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { HttpSnapshotStore } from '../../src/core/store/HttpSnapshotStore';
|
|
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
|
|
import { snapshotBlocker, snapshotTime } from '../../src/editor/snapshots';
|
|
|
|
function stubFetch(body: BodyInit | null, init: ResponseInit) {
|
|
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
|
|
vi.stubGlobal('fetch', mock);
|
|
return mock;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('snapshot rules (#231)', () => {
|
|
it('shows the time in local time, minutes padded', () => {
|
|
expect(snapshotTime(new Date(2026, 0, 5, 7, 3))).toBe('2026-01-05 07:03');
|
|
});
|
|
|
|
it('allows a snapshot only when everything is saved', () => {
|
|
expect(snapshotBlocker('saved')).toBeNull();
|
|
for (const status of ['dirty', 'saving', 'error', 'conflict'] as const) {
|
|
expect(snapshotBlocker(status)).toEqual(expect.any(String));
|
|
}
|
|
expect(snapshotBlocker('conflict')).toContain('changed elsewhere');
|
|
});
|
|
});
|
|
|
|
describe('HttpSnapshotStore (#231)', () => {
|
|
it('lists, creates and deletes under the project', async () => {
|
|
const mock = stubFetch(JSON.stringify([]), { status: 200 });
|
|
const store = new HttpSnapshotStore('/api');
|
|
await store.list('p1');
|
|
await store.create('p1', 'before the boss');
|
|
stubFetch(null, { status: 204 });
|
|
await store.delete('p1', 's1');
|
|
const calls = [...mock.mock.calls, ...vi.mocked(fetch).mock.calls].map(([url, init]) => [
|
|
decodeURIComponent((url as string).split('r=')[1] ?? ''),
|
|
init?.method,
|
|
init?.body,
|
|
]);
|
|
expect(calls).toEqual([
|
|
['projects/p1/snapshots', 'GET', undefined],
|
|
['projects/p1/snapshots', 'POST', JSON.stringify({ name: 'before the boss' })],
|
|
['projects/p1/snapshots/s1', 'DELETE', undefined],
|
|
]);
|
|
});
|
|
|
|
it('restores with the revision the editor holds and hands back the kept stand', async () => {
|
|
const before = { id: 's2', name: 'Before restoring a', revision: 7, createdAt: '' };
|
|
const mock = stubFetch(JSON.stringify({ revision: 8, before }), { status: 200 });
|
|
const result = await new HttpSnapshotStore('/api').restore('p1', 's1', 7);
|
|
expect(result).toEqual({ revision: 8, before });
|
|
expect(decodeURIComponent(mock.mock.calls[0]?.[0] as string)).toContain(
|
|
'r=projects/p1/snapshots/s1/restore',
|
|
);
|
|
expect(mock.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ revision: 7 }));
|
|
});
|
|
|
|
it('turns a 409 into a stale revision', async () => {
|
|
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 9 }), { status: 409 });
|
|
const failure = await new HttpSnapshotStore('/api').restore('p1', 's1', 7).catch((e: unknown) => e);
|
|
expect(failure).toBeInstanceOf(StaleRevisionError);
|
|
expect((failure as StaleRevisionError).serverRevision).toBe(9);
|
|
});
|
|
});
|