Files
adminandClaude Opus 5.5 94749b4f3e
game-maker / gate (push) Successful in 3m6s
game-maker / suite (push) Successful in 9m38s
Let a project keep named snapshots and restore them (#231)
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>
2026-09-25 18:21:52 +02:00

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);
});
});