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. Escape on the question is its Cancel: the list comes back and nothing is restored. const cameBack = (expression, label) => waitUntil(expression, { label }).then( () => true, () => false, ); await openSnapshots(); await clickLast('Clean start'); await clickLast('Restore'); await press('Escape'); const listBack = await cameBack('__gm.snapshots() !== null', 'the list came back'); check( 'Escape on "Restore snapshot?" leads back to the list and restores nothing', listBack && (await json('__gm.state.project.name')) === RENAMED && (await json('__gm.state.revision')) === later.revision, JSON.stringify({ listBack, name: await json('__gm.state.project.name') }), ); if (listBack) await clickLast('Cancel'); // 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. The project goes by the picker. Its question stands in for the picker: Cancel leads back to // it, and so does the deletion, with the project gone from the list. await clickToolbar('menu'); await clickText('Open project…'); await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' }); await clickLast('Delete'); await clickLast('Cancel'); const pickerBack = await cameBack('__gm.projectPicker() !== null', 'the picker came back'); check( 'Cancel on "Delete project?" leads back to the picker and keeps the project', pickerBack && (await stored()).name === RENAMED, JSON.stringify({ pickerBack, picker: await json('__gm.projectPicker()') }), ); if (pickerBack) { await clickLast('Delete'); await clickLast('Delete'); } else await api('DELETE', `projects/${made.id}`); const pickerAgain = await cameBack( `__gm.projectPicker() !== null && !__gm.projectPicker().names.includes(${JSON.stringify(RENAMED)})`, 'the picker came back without the project', ); check( 'deleting from the picker leads back to it, without the project', pickerAgain, JSON.stringify(await json('__gm.projectPicker()')), ); if (pickerAgain) await clickLast('Cancel'); // Deleting the project deletes its snapshots: the route answers 404 afterwards. 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(); } }