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>
This commit is contained in:
1 parent
17a6fd0339
commit
94749b4f3e
18 files changed
+849
-7
No files matched your search
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The snapshot store (#231) on a scratch directory. Plain PHP like accounts.test.php; CI runs it
|
||||
* with `php tests/php/snapshots.test.php` and reads the last line.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/../../api/lib/Store.php';
|
||||
require __DIR__ . '/../../api/lib/SnapshotStore.php';
|
||||
|
||||
$failed = 0;
|
||||
$passed = 0;
|
||||
|
||||
function check(string $name, bool $ok, mixed $got = null): void
|
||||
{
|
||||
global $failed, $passed;
|
||||
if ($ok) {
|
||||
$passed++;
|
||||
echo "PASS {$name}\n";
|
||||
return;
|
||||
}
|
||||
$failed++;
|
||||
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got), "\n";
|
||||
}
|
||||
|
||||
$dir = sys_get_temp_dir() . '/game-maker-php-snapshots-' . bin2hex(random_bytes(4));
|
||||
mkdir($dir);
|
||||
$snapshots = new SnapshotStore($dir);
|
||||
|
||||
check('a project without snapshots lists none', $snapshots->list('p1') === []);
|
||||
|
||||
$doc = ['revision' => 4, 'project' => ['id' => 'p1', 'name' => 'Game', 'assets' => [['id' => 'a']]]];
|
||||
$first = $snapshots->create('p1', $doc, 'first');
|
||||
$second = $snapshots->create('p1', ['revision' => 5] + $doc, 'second');
|
||||
check('a snapshot keeps its name and the revision it copied', $first['name'] === 'first' && $first['revision'] === 4, $first);
|
||||
check('two snapshots get two ids', $first['id'] !== $second['id']);
|
||||
check('the summary carries no project', !isset($first['project']));
|
||||
|
||||
$listed = $snapshots->list('p1');
|
||||
check(
|
||||
'the list is newest first, also within one second',
|
||||
array_column($listed, 'name') === ['second', 'first'],
|
||||
array_column($listed, 'name'),
|
||||
);
|
||||
// The ids are random, so two files come back from the folder in the right order half the time
|
||||
// anyway. With eight, the folder's own order matching by chance is one in 40320.
|
||||
$more = new SnapshotStore("{$dir}/order");
|
||||
foreach (range(1, 8) as $n) {
|
||||
$more->create('p', $doc, "n{$n}");
|
||||
}
|
||||
check(
|
||||
'eight made in a row list in reverse order of making',
|
||||
array_column($more->list('p'), 'name') === ['n8', 'n7', 'n6', 'n5', 'n4', 'n3', 'n2', 'n1'],
|
||||
array_column($more->list('p'), 'name'),
|
||||
);
|
||||
|
||||
$loaded = $snapshots->load('p1', $first['id']);
|
||||
check('a snapshot loads the project as it was copied', $loaded !== null && $loaded['project'] === $doc['project'], $loaded);
|
||||
check('a snapshot of another project is not found', $snapshots->load('p2', $first['id']) === null);
|
||||
|
||||
file_put_contents("{$dir}/p1/broken.json", '{"id":"broken"');
|
||||
check('a broken file is left out of the list', count($snapshots->list('p1')) === 2, $snapshots->list('p1'));
|
||||
unlink("{$dir}/p1/broken.json");
|
||||
|
||||
check('deleting a snapshot says so', $snapshots->delete('p1', $first['id']));
|
||||
check('it is gone from the list', array_column($snapshots->list('p1'), 'name') === ['second']);
|
||||
check('deleting it again says it was not there', !$snapshots->delete('p1', $first['id']));
|
||||
|
||||
$snapshots->create('p2', $doc, 'other');
|
||||
$snapshots->deleteAll('p1');
|
||||
check('deleting all removes the folder of that project', !is_dir("{$dir}/p1"));
|
||||
check('and leaves the snapshots of other projects', count($snapshots->list('p2')) === 1);
|
||||
|
||||
exec('rm -rf ' . escapeshellarg($dir));
|
||||
|
||||
echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n";
|
||||
exit($failed === 0 ? 0 : 1);
|
||||
Reference in new issue
Block a user