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
@@ -33,10 +33,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GM_TEST_WORKERS: 2
|
GM_TEST_WORKERS: 2
|
||||||
|
|
||||||
# The API's own rules (#180, #179). Not part of check.sh: the host the editor is built on
|
# The API's own rules (#180, #179, #231). Not part of check.sh: the host the editor is built
|
||||||
# has no PHP, the image does.
|
# on has no PHP, the image does.
|
||||||
- name: PHP tests
|
- name: PHP tests
|
||||||
run: php tests/php/accounts.test.php
|
run: |
|
||||||
|
php tests/php/accounts.test.php
|
||||||
|
php tests/php/snapshots.test.php
|
||||||
|
|
||||||
suite:
|
suite:
|
||||||
runs-on: game-maker-ci
|
runs-on: game-maker-ci
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
/data/e2e-password.txt
|
/data/e2e-password.txt
|
||||||
/data/e2e-admin-password.txt
|
/data/e2e-admin-password.txt
|
||||||
/data/sessions/
|
/data/sessions/
|
||||||
|
/data/snapshots/
|
||||||
/data/users.json
|
/data/users.json
|
||||||
/data/textures/
|
/data/textures/
|
||||||
/data/fonts/
|
/data/fonts/
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
|||||||
The browser suite and the three example games need a machine with a GPU:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1385 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1393 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
+99
-2
@@ -31,7 +31,14 @@ declare(strict_types=1);
|
|||||||
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
||||||
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
|
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
|
||||||
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
|
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
|
||||||
* DELETE ?r=projects/{id} → 204
|
* DELETE ?r=projects/{id} → 204 (its snapshots go with it)
|
||||||
|
*
|
||||||
|
* GET ?r=projects/{id}/snapshots → [{id,name,revision,createdAt}], newest first
|
||||||
|
* POST ?r=projects/{id}/snapshots {name?} → 201 {id,name,revision,createdAt}
|
||||||
|
* DELETE ?r=projects/{id}/snapshots/{sid} → 204
|
||||||
|
* POST ?r=projects/{id}/snapshots/{sid}/restore {revision} → {revision,updatedAt,before} | 409
|
||||||
|
* (a snapshot copies the project as stored; restoring keeps the
|
||||||
|
* stand it replaces as the snapshot `before`, #231)
|
||||||
*
|
*
|
||||||
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt}]
|
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt}]
|
||||||
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
||||||
@@ -65,6 +72,7 @@ require __DIR__ . '/lib/PixelStore.php';
|
|||||||
require __DIR__ . '/lib/PluginStore.php';
|
require __DIR__ . '/lib/PluginStore.php';
|
||||||
require __DIR__ . '/lib/Response.php';
|
require __DIR__ . '/lib/Response.php';
|
||||||
require __DIR__ . '/lib/SessionStore.php';
|
require __DIR__ . '/lib/SessionStore.php';
|
||||||
|
require __DIR__ . '/lib/SnapshotStore.php';
|
||||||
require __DIR__ . '/lib/SoundStore.php';
|
require __DIR__ . '/lib/SoundStore.php';
|
||||||
require __DIR__ . '/lib/Store.php';
|
require __DIR__ . '/lib/Store.php';
|
||||||
require __DIR__ . '/lib/TextureStore.php';
|
require __DIR__ . '/lib/TextureStore.php';
|
||||||
@@ -168,6 +176,14 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
|||||||
Response::error('Invalid project id', 400);
|
Response::error('Invalid project id', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$snapshots = new SnapshotStore(dirname(__DIR__) . '/data/snapshots');
|
||||||
|
if (($parts[2] ?? null) === 'snapshots') {
|
||||||
|
routeSnapshots($parts, $method, $store, $snapshots, $id);
|
||||||
|
}
|
||||||
|
if (count($parts) > 2) {
|
||||||
|
Response::error('Not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
if ($method === 'GET') {
|
if ($method === 'GET') {
|
||||||
$doc = $store->load($id);
|
$doc = $store->load($id);
|
||||||
if ($doc === null) {
|
if ($doc === null) {
|
||||||
@@ -196,12 +212,92 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
|||||||
if (!$store->delete($id)) {
|
if (!$store->delete($id)) {
|
||||||
Response::error('Project not found', 404);
|
Response::error('Project not found', 404);
|
||||||
}
|
}
|
||||||
|
$snapshots->deleteAll($id);
|
||||||
Response::noContent();
|
Response::noContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
Response::error('Method not allowed', 405);
|
Response::error('Method not allowed', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snapshots of one project (#231). Whoever may load the project may see, make, restore and
|
||||||
|
* delete its snapshots; for everybody else the project, and with it this route, is not there.
|
||||||
|
*
|
||||||
|
* @param list<string> $parts
|
||||||
|
*/
|
||||||
|
function routeSnapshots(array $parts, string $method, Store $store, SnapshotStore $snapshots, string $id): never
|
||||||
|
{
|
||||||
|
$doc = $store->load($id);
|
||||||
|
if ($doc === null) {
|
||||||
|
Response::error('Project not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($parts) === 3) {
|
||||||
|
if ($method === 'GET') {
|
||||||
|
Response::json($snapshots->list($id));
|
||||||
|
}
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$name = snapshotName(Response::body());
|
||||||
|
Response::json($snapshots->create($id, $doc, $name === '' ? gmdate('Y-m-d H:i') . ' UTC' : $name), 201);
|
||||||
|
}
|
||||||
|
Response::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$snapshotId = $parts[3];
|
||||||
|
if (!Store::isValidId($snapshotId) || count($parts) > 5) {
|
||||||
|
Response::error('Not found', 404);
|
||||||
|
}
|
||||||
|
$snapshot = $snapshots->load($id, $snapshotId);
|
||||||
|
if ($snapshot === null) {
|
||||||
|
Response::error('Snapshot not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($parts) === 4) {
|
||||||
|
if ($method !== 'DELETE') {
|
||||||
|
Response::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
$snapshots->delete($id, $snapshotId);
|
||||||
|
Response::noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parts[4] !== 'restore') {
|
||||||
|
Response::error('Not found', 404);
|
||||||
|
}
|
||||||
|
if ($method !== 'POST') {
|
||||||
|
Response::error('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
$body = Response::body();
|
||||||
|
if (!isset($body['revision']) || !is_int($body['revision'])) {
|
||||||
|
Response::error('Body must contain integer "revision"', 422);
|
||||||
|
}
|
||||||
|
// The same guard a save has: a tab that is behind must not throw away what another one saved.
|
||||||
|
if ($body['revision'] !== $doc['revision']) {
|
||||||
|
Response::error('Project was modified elsewhere', 409, ['revision' => $doc['revision']]);
|
||||||
|
}
|
||||||
|
// The stand being replaced is kept first, so a restore can itself be taken back.
|
||||||
|
$before = $snapshots->create($id, $doc, mb_substr('Before restoring ' . $snapshot['name'], 0, 120));
|
||||||
|
try {
|
||||||
|
$result = $store->save($id, $doc['revision'], $snapshot['project']);
|
||||||
|
} catch (StaleRevisionException $e) {
|
||||||
|
$snapshots->delete($id, $before['id']);
|
||||||
|
Response::error('Project was modified elsewhere', 409, ['revision' => $e->currentRevision]);
|
||||||
|
}
|
||||||
|
if ($result === null) {
|
||||||
|
$snapshots->delete($id, $before['id']);
|
||||||
|
Response::error('Project not found', 404);
|
||||||
|
}
|
||||||
|
Response::json(['revision' => $result['revision'], 'updatedAt' => $result['updatedAt'], 'before' => $before]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotName(array $body): string
|
||||||
|
{
|
||||||
|
$name = trim((string) ($body['name'] ?? ''));
|
||||||
|
if (mb_strlen($name) > 120) {
|
||||||
|
Response::error('Snapshot name must be at most 120 characters', 422);
|
||||||
|
}
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
|
||||||
/** @param list<string> $parts */
|
/** @param list<string> $parts */
|
||||||
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
|
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
|
||||||
{
|
{
|
||||||
@@ -472,7 +568,8 @@ try {
|
|||||||
$route = trim((string) ($_GET['r'] ?? ''), '/');
|
$route = trim((string) ($_GET['r'] ?? ''), '/');
|
||||||
$parts = $route === '' ? [] : explode('/', $route);
|
$parts = $route === '' ? [] : explode('/', $route);
|
||||||
|
|
||||||
if (count($parts) < 1 || count($parts) > 2) {
|
// Only a project reaches deeper than two parts: `projects/{id}/snapshots/{snapshotId}/restore`.
|
||||||
|
if (count($parts) < 1 || count($parts) > ($parts[0] === 'projects' ? 5 : 2)) {
|
||||||
Response::error('Not found', 404);
|
Response::error('Not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshots of projects (#231): `data/snapshots/{projectId}/{snapshotId}.json`, each a copy of the
|
||||||
|
* project as it was stored, with { id, name, revision, createdAt, project }.
|
||||||
|
*
|
||||||
|
* A copy of the project document is all a snapshot needs: images, fonts, sounds and cels are
|
||||||
|
* content-addressed and never removed, so whatever the copy names is still there to load.
|
||||||
|
*
|
||||||
|
* The store checks nothing about who asks. The router reaches it only after `Store::load` found
|
||||||
|
* the project for that user, so the snapshots of a project are seen by exactly those who see it.
|
||||||
|
*/
|
||||||
|
final class SnapshotStore
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $dir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{id:string,name:string,revision:int,createdAt:string}>, newest first */
|
||||||
|
public function list(string $projectId): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach (glob($this->folder($projectId) . '/*.json') ?: [] as $file) {
|
||||||
|
$snapshot = $this->readFile($file);
|
||||||
|
if ($snapshot !== null) {
|
||||||
|
$items[] = self::summary($snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($items, static fn(array $a, array $b): int => strcmp($b['createdAt'], $a['createdAt']));
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a stored project document into a new snapshot.
|
||||||
|
*
|
||||||
|
* @param array{revision:int,project:array} $doc
|
||||||
|
* @return array{id:string,name:string,revision:int,createdAt:string}
|
||||||
|
*/
|
||||||
|
public function create(string $projectId, array $doc, string $name): array
|
||||||
|
{
|
||||||
|
$folder = $this->folder($projectId);
|
||||||
|
if (!is_dir($folder) && !mkdir($folder, 0775, true) && !is_dir($folder)) {
|
||||||
|
throw new RuntimeException("Cannot create snapshot directory {$folder}");
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
$id = Store::newId();
|
||||||
|
} while (file_exists($this->path($projectId, $id)));
|
||||||
|
|
||||||
|
$snapshot = [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'revision' => $doc['revision'],
|
||||||
|
// Microseconds, so two snapshots made within one second still list in the order made.
|
||||||
|
'createdAt' => (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s.uP'),
|
||||||
|
'project' => $doc['project'],
|
||||||
|
];
|
||||||
|
$json = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
if ($json === false) {
|
||||||
|
throw new RuntimeException('Cannot encode snapshot');
|
||||||
|
}
|
||||||
|
$path = $this->path($projectId, $id);
|
||||||
|
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||||
|
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
|
||||||
|
@unlink($tmp);
|
||||||
|
throw new RuntimeException('Cannot write snapshot file');
|
||||||
|
}
|
||||||
|
return self::summary($snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{id:string,name:string,revision:int,createdAt:string,project:array}|null */
|
||||||
|
public function load(string $projectId, string $id): ?array
|
||||||
|
{
|
||||||
|
return $this->readFile($this->path($projectId, $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $projectId, string $id): bool
|
||||||
|
{
|
||||||
|
$path = $this->path($projectId, $id);
|
||||||
|
return is_file($path) && unlink($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What deleting the project does to its snapshots. */
|
||||||
|
public function deleteAll(string $projectId): void
|
||||||
|
{
|
||||||
|
$folder = $this->folder($projectId);
|
||||||
|
foreach (glob($folder . '/*.json') ?: [] as $file) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
if (is_dir($folder)) {
|
||||||
|
@rmdir($folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{id:string,name:string,revision:int,createdAt:string} */
|
||||||
|
private static function summary(array $snapshot): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $snapshot['id'],
|
||||||
|
'name' => $snapshot['name'],
|
||||||
|
'revision' => $snapshot['revision'],
|
||||||
|
'createdAt' => $snapshot['createdAt'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function folder(string $projectId): string
|
||||||
|
{
|
||||||
|
return $this->dir . '/' . $projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function path(string $projectId, string $id): string
|
||||||
|
{
|
||||||
|
return $this->folder($projectId) . '/' . $id . '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readFile(string $path): ?array
|
||||||
|
{
|
||||||
|
if (!is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
$snapshot = $raw === false ? null : json_decode($raw, true);
|
||||||
|
if (!is_array($snapshot) || !isset($snapshot['id'], $snapshot['project']) || !is_array($snapshot['project'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$snapshot['name'] = is_string($snapshot['name'] ?? null) ? $snapshot['name'] : '';
|
||||||
|
$snapshot['revision'] = (int) ($snapshot['revision'] ?? 0);
|
||||||
|
$snapshot['createdAt'] = is_string($snapshot['createdAt'] ?? null) ? $snapshot['createdAt'] : '';
|
||||||
|
return $snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { apiRequest } from './http';
|
||||||
|
import { StaleRevisionError } from './ProjectStore';
|
||||||
|
import type { SnapshotStore, SnapshotSummary } from './SnapshotStore';
|
||||||
|
|
||||||
|
export class HttpSnapshotStore implements SnapshotStore {
|
||||||
|
constructor(private readonly endpoint: string) {}
|
||||||
|
|
||||||
|
list(projectId: string): Promise<SnapshotSummary[]> {
|
||||||
|
return this.request('GET', route(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
create(projectId: string, name: string): Promise<SnapshotSummary> {
|
||||||
|
return this.request('POST', route(projectId), { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
restore(
|
||||||
|
projectId: string,
|
||||||
|
snapshotId: string,
|
||||||
|
revision: number,
|
||||||
|
): Promise<{ revision: number; before: SnapshotSummary }> {
|
||||||
|
return this.request('POST', `${route(projectId, snapshotId)}/restore`, { revision });
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(projectId: string, snapshotId: string): Promise<void> {
|
||||||
|
return this.request('DELETE', route(projectId, snapshotId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private request<T>(method: string, path: string, payload?: unknown): Promise<T> {
|
||||||
|
return apiRequest<T>(
|
||||||
|
this.endpoint,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {},
|
||||||
|
body: payload !== undefined ? JSON.stringify(payload) : undefined,
|
||||||
|
},
|
||||||
|
(status, data) =>
|
||||||
|
status === 409
|
||||||
|
? new StaleRevisionError((data as { revision?: number } | null)?.revision ?? -1)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function route(projectId: string, snapshotId?: string): string {
|
||||||
|
const base = `projects/${encodeURIComponent(projectId)}/snapshots`;
|
||||||
|
return snapshotId === undefined ? base : `${base}/${encodeURIComponent(snapshotId)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/** One snapshot of a project as the list shows it (#231). */
|
||||||
|
export interface SnapshotSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** The project revision the snapshot copied. */
|
||||||
|
revision: number;
|
||||||
|
/** ISO time, UTC. */
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snapshots of a project: copies of the project as the server stored it. A restore writes the
|
||||||
|
* copy back as a new revision and keeps the stand it replaced as a snapshot of its own, `before`.
|
||||||
|
*/
|
||||||
|
export interface SnapshotStore {
|
||||||
|
list(projectId: string): Promise<SnapshotSummary[]>;
|
||||||
|
/** An empty name lets the server name it by the time. */
|
||||||
|
create(projectId: string, name: string): Promise<SnapshotSummary>;
|
||||||
|
/** Rejects with `StaleRevisionError` when `revision` is not the stored one. */
|
||||||
|
restore(
|
||||||
|
projectId: string,
|
||||||
|
snapshotId: string,
|
||||||
|
revision: number,
|
||||||
|
): Promise<{ revision: number; before: SnapshotSummary }>;
|
||||||
|
delete(projectId: string, snapshotId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { AuthStore, SignedIn } from '../core/store/AuthStore';
|
|||||||
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
import { HttpAuthStore } from '../core/store/HttpAuthStore';
|
||||||
import { HttpFileStore } from '../core/store/HttpFileStore';
|
import { HttpFileStore } from '../core/store/HttpFileStore';
|
||||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||||
|
import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore';
|
||||||
import { HttpUsersStore } from '../core/store/HttpUsersStore';
|
import { HttpUsersStore } from '../core/store/HttpUsersStore';
|
||||||
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore';
|
||||||
import { loadPhysics } from '../runtime/physics/engine';
|
import { loadPhysics } from '../runtime/physics/engine';
|
||||||
@@ -108,6 +109,7 @@ export async function startEditor(app: Application, root: VStack): Promise<void>
|
|||||||
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
|
new HttpFileStore<UploadedTexture, StoredTexture>(API_ENDPOINT, 'textures'),
|
||||||
new HttpFileStore(API_ENDPOINT, 'fonts'),
|
new HttpFileStore(API_ENDPOINT, 'fonts'),
|
||||||
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
new HttpFileStore(API_ENDPOINT, 'sounds'),
|
||||||
|
new HttpSnapshotStore(API_ENDPOINT),
|
||||||
{
|
{
|
||||||
user,
|
user,
|
||||||
role,
|
role,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Role } from '../core/store/AuthStore';
|
|||||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||||
import { pluginsOf } from '../core/store/projectPlugins';
|
import { pluginsOf } from '../core/store/projectPlugins';
|
||||||
import type { FontStore } from '../core/store/FontStore';
|
import type { FontStore } from '../core/store/FontStore';
|
||||||
|
import type { SnapshotStore } from '../core/store/SnapshotStore';
|
||||||
import type { SoundStore } from '../core/store/SoundStore';
|
import type { SoundStore } from '../core/store/SoundStore';
|
||||||
import type { TextureStore } from '../core/store/TextureStore';
|
import type { TextureStore } from '../core/store/TextureStore';
|
||||||
import type { UsersStore } from '../core/store/UsersStore';
|
import type { UsersStore } from '../core/store/UsersStore';
|
||||||
@@ -35,6 +36,7 @@ import { TAB_OF_ASSET } from './assetTabs';
|
|||||||
import { attachAutosave } from './autosave';
|
import { attachAutosave } from './autosave';
|
||||||
import { PasswordDialog } from './auth/PasswordDialog';
|
import { PasswordDialog } from './auth/PasswordDialog';
|
||||||
import type { OpenProjectDialog } from './OpenProjectDialog';
|
import type { OpenProjectDialog } from './OpenProjectDialog';
|
||||||
|
import type { SnapshotsDialog } from './SnapshotsDialog';
|
||||||
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
|
import type { ProjectSettingsDialog } from './ProjectSettingsDialog';
|
||||||
import { dialogSlot } from './FormDialog';
|
import { dialogSlot } from './FormDialog';
|
||||||
import { ContainerEditor } from './container/ContainerEditor';
|
import { ContainerEditor } from './container/ContainerEditor';
|
||||||
@@ -256,6 +258,7 @@ export class Editor implements PlayHost {
|
|||||||
textures: TextureStore,
|
textures: TextureStore,
|
||||||
fonts: FontStore,
|
fonts: FontStore,
|
||||||
sounds: SoundStore,
|
sounds: SoundStore,
|
||||||
|
snapshots: SnapshotStore,
|
||||||
account: EditorAccount,
|
account: EditorAccount,
|
||||||
) {
|
) {
|
||||||
this.viewport = new SceneViewport(state);
|
this.viewport = new SceneViewport(state);
|
||||||
@@ -291,6 +294,7 @@ export class Editor implements PlayHost {
|
|||||||
() => {
|
() => {
|
||||||
if (this.play.isPlaying) this.play.stop();
|
if (this.play.isPlaying) this.play.stop();
|
||||||
},
|
},
|
||||||
|
{ store: snapshots, flush: () => this.autosave.flush() },
|
||||||
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
|
// Every project brings its own plugins (#119); until `startPlugins` has run there is no
|
||||||
// manager, and a project with plugins simply has none of them.
|
// manager, and a project with plugins simply has none of them.
|
||||||
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
|
async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))),
|
||||||
@@ -524,6 +528,11 @@ export class Editor implements PlayHost {
|
|||||||
return this.projects.projectPicker();
|
return this.projects.projectPicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The open "Snapshots" dialog (#231), for the check scripts. */
|
||||||
|
snapshotsDialog(): SnapshotsDialog | null {
|
||||||
|
return this.projects.snapshotList();
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
|
/** Whether `id` names a tab already – a built-in one by its own id, a plugin's by the id it gave. */
|
||||||
hasTab(id: string): boolean {
|
hasTab(id: string): boolean {
|
||||||
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
|
return Object.hasOwn(TAB_LABELS, id) || this.pluginPanes.has(`plugin:${id}`);
|
||||||
|
|||||||
@@ -7,17 +7,21 @@ import {
|
|||||||
SetPlayFromCommand,
|
SetPlayFromCommand,
|
||||||
SetSymbolScaleCommand,
|
SetSymbolScaleCommand,
|
||||||
} from '../core/commands/projectCommands';
|
} from '../core/commands/projectCommands';
|
||||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore';
|
||||||
|
import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore';
|
||||||
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
|
import { Dialog, Menu, type MenuEntry, type Widget } from '../ui';
|
||||||
import { dialogSlot } from './FormDialog';
|
import { dialogSlot } from './FormDialog';
|
||||||
import { OpenProjectDialog } from './OpenProjectDialog';
|
import { OpenProjectDialog } from './OpenProjectDialog';
|
||||||
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
|
import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog';
|
||||||
import { clearSaved, hasSaved } from './saves';
|
import { clearSaved, hasSaved } from './saves';
|
||||||
|
import { snapshotBlocker, snapshotTime } from './snapshots';
|
||||||
|
import { SnapshotsDialog } from './SnapshotsDialog';
|
||||||
import type { EditorState } from './state';
|
import type { EditorState } from './state';
|
||||||
|
|
||||||
export class ProjectSession {
|
export class ProjectSession {
|
||||||
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
|
private readonly settingsDialog = dialogSlot<ProjectSettingsDialog>();
|
||||||
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
|
private readonly pickerDialog = dialogSlot<OpenProjectDialog>();
|
||||||
|
private readonly snapshotsDialog = dialogSlot<SnapshotsDialog>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
|
* `beforeSwitch` runs before a different project is loaded or the current one is deleted: a
|
||||||
@@ -27,6 +31,8 @@ export class ProjectSession {
|
|||||||
private readonly state: EditorState,
|
private readonly state: EditorState,
|
||||||
private readonly store: ProjectStore,
|
private readonly store: ProjectStore,
|
||||||
private readonly beforeSwitch: () => void,
|
private readonly beforeSwitch: () => void,
|
||||||
|
/** `flush` writes what the autosave still holds, so a snapshot copies the stand on screen (#231). */
|
||||||
|
private readonly snapshots: { store: SnapshotStore; flush: () => Promise<void> },
|
||||||
/**
|
/**
|
||||||
* Runs on the project as it came off the server, before it is read into the model: the plugins
|
* Runs on the project as it came off the server, before it is read into the model: the plugins
|
||||||
* this project switches on are loaded there (#119), so that what they register is in place
|
* this project switches on are loaded there (#119), so that what they register is in place
|
||||||
@@ -94,6 +100,7 @@ export class ProjectSession {
|
|||||||
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
{ label: 'New project', onSelect: () => void this.run(this.createNext()) },
|
||||||
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
{ label: 'Open project…', onSelect: () => this.openProjectPicker() },
|
||||||
{ separator: true },
|
{ separator: true },
|
||||||
|
{ label: 'Snapshots…', onSelect: () => this.openSnapshots() },
|
||||||
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
{ label: 'Project settings…', onSelect: () => this.openProjectSettings() },
|
||||||
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
{ label: 'Clear saved data', onSelect: () => this.clearSavedData() },
|
||||||
...more,
|
...more,
|
||||||
@@ -141,6 +148,92 @@ export class ProjectSession {
|
|||||||
]).openCentered();
|
]).openCentered();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The snapshots of the open project (#231). */
|
||||||
|
openSnapshots(): void {
|
||||||
|
const projectId = this.state.project.id;
|
||||||
|
void this.run(
|
||||||
|
(async () => {
|
||||||
|
const list = await this.snapshots.store.list(projectId);
|
||||||
|
this.snapshotsDialog.open(
|
||||||
|
new SnapshotsDialog(list, {
|
||||||
|
onCreate: (name) => void this.run(this.createSnapshot(projectId, name)),
|
||||||
|
onRestore: (snapshot) => this.confirmRestore(projectId, snapshot),
|
||||||
|
onDelete: (snapshot) => this.confirmDeleteSnapshot(projectId, snapshot),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
})(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The open snapshots dialog, for the check scripts. */
|
||||||
|
snapshotList(): SnapshotsDialog | null {
|
||||||
|
return this.snapshotsDialog.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSnapshot(projectId: string, name: string): Promise<void> {
|
||||||
|
await this.snapshots.flush();
|
||||||
|
const blocker = snapshotBlocker(this.state.saveStatus);
|
||||||
|
if (blocker) {
|
||||||
|
this.state.setNotice(blocker, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const made = await this.snapshots.store.create(projectId, name || snapshotTime(new Date()));
|
||||||
|
this.state.setNotice(`Snapshot "${made.name}" created`);
|
||||||
|
this.openSnapshots();
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirmRestore(projectId: string, snapshot: SnapshotSummary): void {
|
||||||
|
new Dialog(
|
||||||
|
`Restore snapshot "${snapshot.name}"?`,
|
||||||
|
'The project as it is now is kept as a snapshot of its own first, so this can be taken back.',
|
||||||
|
[
|
||||||
|
{ label: 'Cancel' },
|
||||||
|
{
|
||||||
|
label: 'Restore',
|
||||||
|
primary: true,
|
||||||
|
onClick: () => void this.run(this.restoreSnapshot(projectId, snapshot)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
).openCentered();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the snapshot back on the server, then loads the project again: the undo history starts over. */
|
||||||
|
private async restoreSnapshot(projectId: string, snapshot: SnapshotSummary): Promise<void> {
|
||||||
|
this.beforeSwitch();
|
||||||
|
await this.snapshots.flush();
|
||||||
|
const blocker = snapshotBlocker(this.state.saveStatus);
|
||||||
|
if (blocker) {
|
||||||
|
this.state.setNotice(blocker, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { before } = await this.snapshots.store.restore(projectId, snapshot.id, this.state.revision);
|
||||||
|
await this.open(projectId);
|
||||||
|
this.state.setNotice(`Restored "${snapshot.name}" – the previous stand is kept as "${before.name}"`);
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof StaleRevisionError)) throw error;
|
||||||
|
this.state.setSaveStatus('conflict');
|
||||||
|
this.state.setNotice('This project was changed elsewhere. Reload to continue.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private confirmDeleteSnapshot(projectId: string, snapshot: SnapshotSummary): void {
|
||||||
|
new Dialog(`Delete snapshot "${snapshot.name}"?`, 'The snapshot is removed permanently.', [
|
||||||
|
{ label: 'Cancel', onClick: () => this.openSnapshots() },
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
onClick: () =>
|
||||||
|
void this.run(
|
||||||
|
(async () => {
|
||||||
|
await this.snapshots.store.delete(projectId, snapshot.id);
|
||||||
|
this.openSnapshots();
|
||||||
|
})(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]).openCentered();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Name, the scene Play starts, the symbol scale and the collision layers (#152) in one dialog
|
* Name, the scene Play starts, the symbol scale and the collision layers (#152) in one dialog
|
||||||
* (#183). Each is an undo step of its own – one each, and only for what actually changed.
|
* (#183). Each is an undo step of its own – one each, and only for what actually changed.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { SnapshotSummary } from '../core/store/SnapshotStore';
|
||||||
|
import { Button, HStack, Label, ListView, Popup, ScrollContainer, TextField, Theme } from '../ui';
|
||||||
|
import { dialogButtons } from './FormDialog';
|
||||||
|
import { snapshotTime } from './snapshots';
|
||||||
|
|
||||||
|
const LIST_WIDTH = 360;
|
||||||
|
const LIST_HEIGHT = 220;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Snapshots…" (#231): the snapshots of the open project, newest first, a field to name the next
|
||||||
|
* one, and Restore and Delete on the one picked.
|
||||||
|
*
|
||||||
|
* Like the project picker, Restore and Delete close the dialog and hand the choice on: the caller
|
||||||
|
* asks for confirmation, and a question over a list it is no longer about would only confuse.
|
||||||
|
* Create keeps it open – the caller opens it again with the new snapshot in the list.
|
||||||
|
*/
|
||||||
|
export class SnapshotsDialog extends Popup {
|
||||||
|
private readonly list = new ListView();
|
||||||
|
private readonly scroll = new ScrollContainer();
|
||||||
|
private readonly nameField: TextField;
|
||||||
|
private readonly restoreButton: Button;
|
||||||
|
private readonly deleteButton: Button;
|
||||||
|
private picked: string | null;
|
||||||
|
private readonly rows: { name: string; time: string }[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
snapshots: readonly SnapshotSummary[],
|
||||||
|
actions: {
|
||||||
|
onCreate(name: string): void;
|
||||||
|
onRestore(snapshot: SnapshotSummary): void;
|
||||||
|
onDelete(snapshot: SnapshotSummary): void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap });
|
||||||
|
this.picked = snapshots[0]?.id ?? null;
|
||||||
|
this.rows = snapshots.map((s) => ({ name: s.name, time: snapshotTime(new Date(s.createdAt)) }));
|
||||||
|
|
||||||
|
this.content.add(new Label('Snapshots', { style: 'bodyBold' }));
|
||||||
|
|
||||||
|
const create = (): void => actions.onCreate(this.nameField.value.trim());
|
||||||
|
this.nameField = new TextField('', { placeholder: 'Name (optional)', width: 220, onSubmit: create });
|
||||||
|
const createButton = new Button('Create snapshot', { variant: 'primary', onClick: create });
|
||||||
|
const createRow = new HStack({ gap: Theme.gap, align: 'center' });
|
||||||
|
createRow.add(this.nameField, 1);
|
||||||
|
createRow.add(createButton);
|
||||||
|
this.content.add(createRow);
|
||||||
|
|
||||||
|
this.list.minWidth = LIST_WIDTH;
|
||||||
|
this.list.setItems(snapshots.map((s, i) => ({ id: s.id, label: s.name, hint: this.rows[i]?.time })));
|
||||||
|
this.list.select(this.picked, true);
|
||||||
|
this.list.onSelect = (id) => {
|
||||||
|
this.picked = id;
|
||||||
|
this.refresh();
|
||||||
|
};
|
||||||
|
this.scroll.minWidth = LIST_WIDTH;
|
||||||
|
this.scroll.minHeight = LIST_HEIGHT;
|
||||||
|
this.scroll.setContent(this.list);
|
||||||
|
this.content.add(this.scroll);
|
||||||
|
if (snapshots.length === 0) this.content.add(new Label('No snapshots yet', { style: 'muted' }));
|
||||||
|
|
||||||
|
const withPicked = (action: (snapshot: SnapshotSummary) => void) => () => {
|
||||||
|
const snapshot = snapshots.find((s) => s.id === this.picked);
|
||||||
|
if (snapshot) this.closeThen(() => action(snapshot))();
|
||||||
|
};
|
||||||
|
this.deleteButton = new Button('Delete', { variant: 'danger', onClick: withPicked(actions.onDelete) });
|
||||||
|
this.restoreButton = new Button('Restore', { onClick: withPicked(actions.onRestore) });
|
||||||
|
this.content.add(dialogButtons(this.closeThen(), this.restoreButton, this.deleteButton));
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the list shows and which row is picked (test hook). */
|
||||||
|
info(): { rows: { name: string; time: string }[]; picked: string | null } {
|
||||||
|
return { rows: this.rows.map((r) => ({ ...r })), picked: this.picked };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name field, for the check scripts to type into. */
|
||||||
|
get nameControl(): TextField {
|
||||||
|
return this.nameField;
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
this.restoreButton.setEnabled(this.picked !== null);
|
||||||
|
this.deleteButton.setEnabled(this.picked !== null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -234,6 +234,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
|||||||
},
|
},
|
||||||
/** The "Open project" dialog (#183): the projects it lists and the row picked. */
|
/** The "Open project" dialog (#183): the projects it lists and the row picked. */
|
||||||
projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
|
projectPicker: () => editor.projectPickerDialog()?.info() ?? null,
|
||||||
|
/** The "Snapshots" dialog (#231): its rows, newest first, the row picked and its name field. */
|
||||||
|
snapshots: () => {
|
||||||
|
const dialog = editor.snapshotsDialog();
|
||||||
|
return dialog ? { ...dialog.info(), nameAt: centreOf(dialog.nameControl) } : null;
|
||||||
|
},
|
||||||
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
|
/** The dividers left and right of the centre column – dragging them narrows the tab row. */
|
||||||
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
panelSplitters: () => ({ left: centreOf(editor.leftSplitter), right: centreOf(editor.rightSplitter) }),
|
||||||
/** The left column: asset panel on top, scene objects below, and the divider between them. */
|
/** The left column: asset panel on top, scene objects below, and the divider between them. */
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { SaveStatus } from './state';
|
||||||
|
|
||||||
|
/** When a snapshot was made, in the local time of the browser: `2026-09-25 17:39`. */
|
||||||
|
export function snapshotTime(at: Date): string {
|
||||||
|
const two = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies
|
||||||
|
* what it has stored, so the editor has to have saved everything first: a snapshot without the
|
||||||
|
* last edits would be a stand nobody ever saw.
|
||||||
|
*/
|
||||||
|
export function snapshotBlocker(status: SaveStatus): string | null {
|
||||||
|
switch (status) {
|
||||||
|
case 'saved':
|
||||||
|
return null;
|
||||||
|
case 'conflict':
|
||||||
|
return 'This project was changed elsewhere. Reload to continue.';
|
||||||
|
case 'error':
|
||||||
|
return 'The project could not be saved, so there is nothing current to take a snapshot of';
|
||||||
|
default:
|
||||||
|
return 'The latest changes are not saved yet – stop the game or wait a moment';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -55,6 +55,7 @@ export async function topbar(t) {
|
|||||||
'New project',
|
'New project',
|
||||||
'Open project…',
|
'Open project…',
|
||||||
null,
|
null,
|
||||||
|
'Snapshots…',
|
||||||
'Project settings…',
|
'Project settings…',
|
||||||
'Clear saved data',
|
'Clear saved data',
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { authHeaders, forgetSession } from '../lib/auth.mjs';
|
||||||
|
|
||||||
|
/** The project this step makes and deletes again; a run that died halfway leaves it for the next. */
|
||||||
|
const TEMP_NAME = 'Snapshots 50';
|
||||||
|
const RENAMED = 'Renamed by 50';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshots of a project (#231): made from the menu, restored over a later stand, the replaced
|
||||||
|
* stand kept as a snapshot of its own, and deleted with the project.
|
||||||
|
*
|
||||||
|
* Works on a throwaway project of its own, because a restore reloads the project it is done on.
|
||||||
|
* The last thing it does is delete that project and open the fixture again.
|
||||||
|
*/
|
||||||
|
export async function snapshots(t) {
|
||||||
|
const { check, cdp, json, click, press, typeText, locate, sleep, waitUntil, settle } = t;
|
||||||
|
const { clickToolbar, clickText, shot, saved, baseUrl, navigate } = t;
|
||||||
|
|
||||||
|
const send = async (method, route, body) =>
|
||||||
|
fetch(`${baseUrl}api/index.php?r=${route}`, {
|
||||||
|
method,
|
||||||
|
headers: { ...(await authHeaders(baseUrl)), 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
|
});
|
||||||
|
// Step 45 changes the password of `e2e`, which ends the session the tools had cached.
|
||||||
|
const api = async (method, route, body) => {
|
||||||
|
const first = await send(method, route, body);
|
||||||
|
if (first.status !== 401) return first;
|
||||||
|
forgetSession(baseUrl);
|
||||||
|
return send(method, route, body);
|
||||||
|
};
|
||||||
|
const sweep = async () => {
|
||||||
|
for (const project of await (await api('GET', 'projects')).json()) {
|
||||||
|
if (project.name === TEMP_NAME || project.name === RENAMED)
|
||||||
|
await api('DELETE', `projects/${project.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/** The last-drawn `text` – the dialog's, over a row or a menu that says the same. */
|
||||||
|
const clickLast = async (text) => {
|
||||||
|
await settle();
|
||||||
|
const at = await locate(text, true);
|
||||||
|
await click(at.x, at.y);
|
||||||
|
};
|
||||||
|
const openSnapshots = async () => {
|
||||||
|
await clickToolbar('menu');
|
||||||
|
await clickText('Snapshots…');
|
||||||
|
await waitUntil('__gm.snapshots() !== null', { label: 'the snapshots dialog opened' });
|
||||||
|
};
|
||||||
|
const notice = () => json('__gm.state.notice');
|
||||||
|
|
||||||
|
await sweep();
|
||||||
|
const made = await (await api('POST', 'projects', { name: TEMP_NAME })).json();
|
||||||
|
const stored = async () => (await (await api('GET', `projects/${made.id}`)).json()).project;
|
||||||
|
try {
|
||||||
|
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${made.id}` });
|
||||||
|
await sleep(4000);
|
||||||
|
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(made.id)}`, {
|
||||||
|
timeout: 15000,
|
||||||
|
label: 'the throwaway project is open',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 50a. A new project has no snapshots; a named one is made from what is saved and listed.
|
||||||
|
await openSnapshots();
|
||||||
|
const empty = await json('__gm.snapshots()');
|
||||||
|
const field = empty?.nameAt;
|
||||||
|
await click(field.x, field.y);
|
||||||
|
await typeText('Clean start');
|
||||||
|
await press('Enter');
|
||||||
|
await waitUntil('__gm.snapshots()?.rows.length === 1', { label: 'the dialog shows the new snapshot' });
|
||||||
|
const one = await json('__gm.snapshots()');
|
||||||
|
const listed = await (await api('GET', `projects/${made.id}/snapshots`)).json();
|
||||||
|
check(
|
||||||
|
'Create snapshot copies the saved project under the name typed, and the list shows it',
|
||||||
|
empty.rows.length === 0 &&
|
||||||
|
one.rows[0]?.name === 'Clean start' &&
|
||||||
|
/^\d{4}-\d\d-\d\d \d\d:\d\d$/.test(one.rows[0]?.time ?? '') &&
|
||||||
|
listed.length === 1 &&
|
||||||
|
listed[0].revision === (await json('__gm.state.revision')) &&
|
||||||
|
(await notice()) === 'Snapshot "Clean start" created',
|
||||||
|
JSON.stringify({ empty, one, listed, notice: await notice() }),
|
||||||
|
);
|
||||||
|
await shot('50-snapshots');
|
||||||
|
await clickLast('Cancel');
|
||||||
|
|
||||||
|
// 50b. A later stand: the project renamed through the settings, and saved.
|
||||||
|
await clickToolbar('menu');
|
||||||
|
await clickText('Project settings…');
|
||||||
|
const nameField = await locate(TEMP_NAME, true);
|
||||||
|
await t.doubleClick(nameField.x, nameField.y);
|
||||||
|
await press('a', { ctrl: true });
|
||||||
|
await typeText(RENAMED);
|
||||||
|
await press('Enter');
|
||||||
|
await clickText('Apply');
|
||||||
|
await saved();
|
||||||
|
const later = {
|
||||||
|
name: await json('__gm.state.project.name'),
|
||||||
|
revision: await json('__gm.state.revision'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 50c. Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads
|
||||||
|
// the project with an empty undo history, and tells so.
|
||||||
|
await openSnapshots();
|
||||||
|
await clickLast('Clean start');
|
||||||
|
await clickLast('Restore');
|
||||||
|
await clickLast('Restore');
|
||||||
|
await waitUntil('__gm.state.notice.startsWith("Restored")', { label: 'the restore finished' });
|
||||||
|
const restored = {
|
||||||
|
name: await json('__gm.state.project.name'),
|
||||||
|
revision: await json('__gm.state.revision'),
|
||||||
|
canUndo: await json('__gm.state.commands.canUndo'),
|
||||||
|
notice: await notice(),
|
||||||
|
stored: (await stored()).name,
|
||||||
|
list: (await (await api('GET', `projects/${made.id}/snapshots`)).json()).map((s) => s.name),
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
'Restore brings the snapshot back as a new revision and keeps the replaced stand as a snapshot',
|
||||||
|
later.name === RENAMED &&
|
||||||
|
restored.name === TEMP_NAME &&
|
||||||
|
restored.stored === TEMP_NAME &&
|
||||||
|
restored.revision === later.revision + 1 &&
|
||||||
|
restored.canUndo === false &&
|
||||||
|
JSON.stringify(restored.list) === JSON.stringify(['Before restoring Clean start', 'Clean start']) &&
|
||||||
|
restored.notice ===
|
||||||
|
'Restored "Clean start" – the previous stand is kept as "Before restoring Clean start"',
|
||||||
|
JSON.stringify({ later, restored }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 50d. That kept stand restores like any other: the restore is taken back.
|
||||||
|
await openSnapshots();
|
||||||
|
await clickLast('Before restoring Clean start');
|
||||||
|
await clickLast('Restore');
|
||||||
|
await clickLast('Restore');
|
||||||
|
await waitUntil('__gm.state.notice.startsWith("Restored \\"Before")', {
|
||||||
|
label: 'the second restore finished',
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
'restoring the kept stand takes the restore back',
|
||||||
|
(await json('__gm.state.project.name')) === RENAMED && (await stored()).name === RENAMED,
|
||||||
|
JSON.stringify({ name: await json('__gm.state.project.name'), stored: (await stored()).name }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 50e. Delete asks, then takes the one picked out of the list.
|
||||||
|
await openSnapshots();
|
||||||
|
const before = (await json('__gm.snapshots()')).rows.map((r) => r.name);
|
||||||
|
await clickLast('Clean start');
|
||||||
|
await clickLast('Delete');
|
||||||
|
await clickLast('Delete');
|
||||||
|
await waitUntil(`__gm.snapshots()?.rows.length === ${before.length - 1}`, {
|
||||||
|
label: 'the dialog shows one snapshot fewer',
|
||||||
|
});
|
||||||
|
const after = (await json('__gm.snapshots()')).rows.map((r) => r.name);
|
||||||
|
check(
|
||||||
|
'Delete removes the snapshot picked and nothing else',
|
||||||
|
before.includes('Clean start') &&
|
||||||
|
!after.includes('Clean start') &&
|
||||||
|
after.length === before.length - 1 &&
|
||||||
|
after.every((name) => before.includes(name)),
|
||||||
|
JSON.stringify({ before, after }),
|
||||||
|
);
|
||||||
|
await clickLast('Cancel');
|
||||||
|
|
||||||
|
// 50f. Deleting the project deletes its snapshots: the route answers 404 afterwards.
|
||||||
|
await api('DELETE', `projects/${made.id}`);
|
||||||
|
const gone = await api('GET', `projects/${made.id}/snapshots`);
|
||||||
|
check('deleting the project takes its snapshots along', gone.status === 404, String(gone.status));
|
||||||
|
} finally {
|
||||||
|
await sweep();
|
||||||
|
await navigate();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import { topbar } from './46-topbar.mjs';
|
|||||||
import { vectorArt } from './47-vectorart.mjs';
|
import { vectorArt } from './47-vectorart.mjs';
|
||||||
import { pixelArt } from './48-pixelart.mjs';
|
import { pixelArt } from './48-pixelart.mjs';
|
||||||
import { users } from './49-users.mjs';
|
import { users } from './49-users.mjs';
|
||||||
|
import { snapshots } from './50-snapshots.mjs';
|
||||||
|
|
||||||
export const STEPS = [
|
export const STEPS = [
|
||||||
hierarchy,
|
hierarchy,
|
||||||
@@ -103,4 +104,5 @@ export const STEPS = [
|
|||||||
vectorArt,
|
vectorArt,
|
||||||
pixelArt,
|
pixelArt,
|
||||||
users,
|
users,
|
||||||
|
snapshots,
|
||||||
];
|
];
|
||||||
Reference in new issue
Block a user