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>
171 lines
7.2 KiB
JavaScript
171 lines
7.2 KiB
JavaScript
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();
|
||
}
|
||
}
|