Let guests try a demo project without an account (#428)
The editor was only open to somebody with an account. A guest now picks
a demo project on the sign-in screen and gets the whole editor on a copy
held in the tab. The server writes nothing for a guest, so there is no
guest session, no guest folder and no quota to keep.
- Demo copies are frozen copies like a release, in data/demos/. An
administrator makes, updates and removes one with "Demo copy..." in
the project's main menu. GET demos and GET demos/{token} need no
session. An empty project is just a demo that is empty.
- Demo material: an administrator offers an uploaded file with "Offer
as demo" in the pickers (PATCH {demo}, data/demo-files.json). Every
list carries it with demo: true, for accounts and guests alike.
Signed out, a list holds the demo material alone, without the owner.
- An offered file is only an administrator's to delete. A file a demo
copy names counts as used, like one a release names.
- The guest's project lives in MemoryProjectStore. Every store that
would write refuses in the tab before a request is made, and the
autosave is not attached at all.
- The main menu drops projects, snapshots and release for a guest. The
pickers drop Upload and Delete, a dropped or pasted file is refused,
and a recording is saved as a download.
- The toolbar says the work is not saved. The Account menu holds
"Sign in..." instead of Logout and the password.
- A guest's demo never goes into the address: a reload brings the
sign-in screen back, not the copy. Found by e2e 70.
- GET plugins needs no session any more, since a demo may switch one
on. It only reads the server's own modules.
Tests:
- e2e 70 (atx): ALL PASSED, 13 checks. Counter-test with the guest
autosave sent to the server: 70f and 70g red on the PUT.
- tests/core/guest.test.ts: 9 tests. PHP: demos 20, files 46,
rights 80 checks (the matrix asks every new route).
- Gate (atx): CHECK EXIT 0, 314 files, 4852 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce1f25f2eb
commit
925a39a276
30 files changed
+1397
-128
No files matched your search
@@ -0,0 +1,248 @@
|
||||
import { authHeaders, e2eAdminPassword, E2E_ADMIN, logIn, SESSION_COOKIE } from '../lib/auth.mjs';
|
||||
import { sceneObject } from '../lib/builders.mjs';
|
||||
import { pickAssetType } from './lib/scaffold.mjs';
|
||||
|
||||
/** The project this step makes and deletes again; a run that died halfway leaves it for the next. */
|
||||
const TEMP_NAME = 'Guest 70';
|
||||
|
||||
function game(id) {
|
||||
return {
|
||||
formatVersion: 2,
|
||||
id,
|
||||
name: TEMP_NAME,
|
||||
activeSceneId: 'scene',
|
||||
assets: [
|
||||
{ id: 'scene', type: 'scene', name: 'Level', props: {}, objects: [sceneObject('crate', 'crate')] },
|
||||
{ id: 'crate', type: 'sprite', name: 'Crate', props: { width: 40, height: 40, color: 0xe0553f } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Trying the editor without an account (#428): an administrator makes the demo copy of a project
|
||||
* from the main menu and offers a sound as demo material; a guest picks the copy on the sign-in
|
||||
* screen, changes it, and not one request that writes leaves the tab. Signing in from the account
|
||||
* menu and picking the demo again brings the copy back as it was.
|
||||
*
|
||||
* Works on a throwaway project of its own, like step 60, and hands the editor back signed in.
|
||||
*/
|
||||
export async function guest(t) {
|
||||
const { check, cdp, json, locate, find, click, mouse, sleep, waitUntil, settle, press } = t;
|
||||
const { clickToolbar, clickText, shot, baseUrl, navigate, exceptions, saved } = t;
|
||||
|
||||
const admin = await logIn(baseUrl, E2E_ADMIN, await e2eAdminPassword());
|
||||
const send = async (method, route, { as = 'e2e', body, type = 'application/json' } = {}) => {
|
||||
const cookie = as === 'admin' ? { Cookie: admin } : as === 'e2e' ? await authHeaders(baseUrl) : {};
|
||||
return fetch(`${baseUrl}api/index.php?r=${route}`, {
|
||||
method,
|
||||
headers: { ...cookie, 'Content-Type': type },
|
||||
body: body instanceof Uint8Array || body === undefined ? body : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
};
|
||||
const sweep = async () => {
|
||||
for (const project of await (await send('GET', 'projects')).json()) {
|
||||
if (project.name === TEMP_NAME) await send('DELETE', `projects/${project.id}`);
|
||||
}
|
||||
};
|
||||
// Pressed by hand, as in step 45: the sign-in screen comes and goes between the frames `click` waits for.
|
||||
const pressAt = async (spot) => {
|
||||
await mouse('mouseMoved', spot.x, spot.y);
|
||||
await mouse('mousePressed', spot.x, spot.y);
|
||||
await mouse('mouseReleased', spot.x, spot.y);
|
||||
};
|
||||
const clickLast = async (text) => {
|
||||
await settle();
|
||||
const at = await locate(text, true);
|
||||
await click(at.x, at.y);
|
||||
};
|
||||
const mainMenu = async () => {
|
||||
await clickToolbar('menu');
|
||||
const rows = await json('__gm.menuRows()');
|
||||
await press('Escape');
|
||||
return rows;
|
||||
};
|
||||
|
||||
// Every request of the tab that is not a read, from the moment the guest picks a demo on.
|
||||
let watching = false;
|
||||
const writes = [];
|
||||
cdp.on((msg) => {
|
||||
if (!watching || msg.method !== 'Network.requestWillBeSent') return;
|
||||
const { method, url } = msg.params.request;
|
||||
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') writes.push(`${method} ${url}`);
|
||||
});
|
||||
|
||||
await saved();
|
||||
await sweep();
|
||||
const made = await (await send('POST', 'projects', { body: { name: TEMP_NAME } })).json();
|
||||
const put = await send('PUT', `projects/${made.id}`, {
|
||||
body: { revision: made.revision, project: game(made.id) },
|
||||
});
|
||||
if (!put.ok) throw new Error(`could not write the project: ${put.status}`);
|
||||
// A sound of this run's own to offer: random bytes behind a WAV header, as in step 34.
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(48));
|
||||
bytes.set(
|
||||
[...'RIFF'].map((c) => c.charCodeAt(0)),
|
||||
0,
|
||||
);
|
||||
bytes.set(
|
||||
[...'WAVE'].map((c) => c.charCodeAt(0)),
|
||||
8,
|
||||
);
|
||||
const spare = (
|
||||
await (await send('POST', 'sounds&name=demo-70.wav', { body: bytes, type: 'audio/wav' })).json()
|
||||
).file;
|
||||
|
||||
try {
|
||||
// 70a. What guests get is the administrators' call: an account can neither make a demo copy nor
|
||||
// offer a file.
|
||||
const refused = {
|
||||
copy: (await send('POST', `projects/${made.id}/demo`)).status,
|
||||
offer: (await send('PATCH', `sounds/${spare}`, { body: { demo: true } })).status,
|
||||
};
|
||||
check(
|
||||
'an account that is no administrator makes no demo copy and offers no file (403)',
|
||||
refused.copy === 403 && refused.offer === 403,
|
||||
JSON.stringify(refused),
|
||||
);
|
||||
|
||||
// 70b. The administrator makes the copy from the main menu of the project.
|
||||
await cdp.send('Network.setCookie', { name: SESSION_COOKIE, value: admin.split('=')[1], url: baseUrl });
|
||||
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 administrator has the throwaway project open',
|
||||
});
|
||||
const adminMenu = await mainMenu();
|
||||
await clickToolbar('menu');
|
||||
await clickText('Demo copy…');
|
||||
await clickLast('Make demo copy');
|
||||
await waitUntil('__gm.state.notice.startsWith("Demo copy made")', { label: 'the demo copy is made' });
|
||||
const listed = await (await send('GET', 'demos', { as: 'nobody' })).json();
|
||||
const token = listed.find((demo) => demo.name === TEMP_NAME)?.token ?? '';
|
||||
check(
|
||||
"the administrator's main menu makes the demo copy, which is listed without a session",
|
||||
adminMenu.includes('Demo copy…') && /^[a-f0-9]{32}$/.test(token),
|
||||
JSON.stringify({ adminMenu, listed }),
|
||||
);
|
||||
|
||||
// 70c. Demo material: offered by the administrator, listed to nobody in particular, and no longer
|
||||
// the uploader's to delete.
|
||||
const offered = (await send('PATCH', `sounds/${spare}`, { as: 'admin', body: { demo: true } })).status;
|
||||
const open = await (await send('GET', 'sounds', { as: 'nobody' })).json();
|
||||
const mine = (await (await send('GET', 'sounds')).json()).find((item) => item.file === spare);
|
||||
const keep = (await send('DELETE', `sounds/${spare}`)).status;
|
||||
check(
|
||||
'an offered file is listed without a session, without its owner, and only an administrator deletes it',
|
||||
offered === 204 &&
|
||||
open.some((item) => item.file === spare && item.demo === true && item.owner === null) &&
|
||||
open.every((item) => item.demo === true) &&
|
||||
mine?.demo === true &&
|
||||
keep === 409,
|
||||
JSON.stringify({ offered, open: open.map((i) => [i.file, i.demo, i.owner]), mine, keep }),
|
||||
);
|
||||
|
||||
// 70d. Without a session the sign-in screen offers the demos; picking one opens it as a guest.
|
||||
await cdp.send('Network.clearBrowserCookies');
|
||||
await cdp.send('Page.navigate', { url: baseUrl });
|
||||
await waitUntil('Boolean(window.__gm?.login)', { timeout: 15000, label: 'the sign-in screen is up' });
|
||||
watching = true;
|
||||
const pickDemo = async () => {
|
||||
await pressAt(await locate('Try without an account'));
|
||||
await sleep(600);
|
||||
await pressAt(await locate(TEMP_NAME));
|
||||
await waitUntil(`window.__gm?.state?.project?.id === ${JSON.stringify(token)}`, {
|
||||
timeout: 15000,
|
||||
label: 'the guest has the demo open',
|
||||
});
|
||||
await sleep(500);
|
||||
};
|
||||
await pickDemo();
|
||||
const entered = {
|
||||
title: await json('document.title'),
|
||||
notice: await json('__gm.state.notice'),
|
||||
status: Boolean(await find('Guest – not saved')),
|
||||
assets: await json('__gm.state.project.assets.length'),
|
||||
address: await json('location.search'),
|
||||
};
|
||||
const guestMenu = await mainMenu();
|
||||
check(
|
||||
'a guest picks the demo on the sign-in screen and gets the copy, with the toolbar saying nothing is saved',
|
||||
entered.title === 'Feuerwerk – Guest' &&
|
||||
entered.notice.startsWith('You are trying a demo as a guest') &&
|
||||
entered.status &&
|
||||
entered.assets === 2 &&
|
||||
!entered.address.includes('project='),
|
||||
JSON.stringify(entered),
|
||||
);
|
||||
check(
|
||||
"a guest's main menu neither switches projects nor has copies made",
|
||||
['New project', 'Open project…', 'Snapshots…', 'Release…', 'Demo copy…'].every(
|
||||
(row) => !guestMenu.includes(row),
|
||||
) && guestMenu.includes('Project settings…'),
|
||||
JSON.stringify(guestMenu),
|
||||
);
|
||||
await shot('70-guest');
|
||||
|
||||
// 70e. The pickers hold the demo material and no button that would write.
|
||||
await clickText('+');
|
||||
await pickAssetType(t, 'Sound');
|
||||
await sleep(500);
|
||||
const picker = await json('__gm.soundPicker()');
|
||||
await press('Escape');
|
||||
await settle();
|
||||
check(
|
||||
"a guest's picker lists the demo material, without Upload, Delete or the demo switch",
|
||||
picker?.files.includes(spare) &&
|
||||
picker.demo.includes(spare) &&
|
||||
picker.files.every((file) => picker.demo.includes(file)) &&
|
||||
!picker.uploadShown &&
|
||||
picker.deleteButton === null &&
|
||||
picker.offerButton === null,
|
||||
JSON.stringify(picker),
|
||||
);
|
||||
|
||||
// 70f. A change stays in the tab: well past the autosave's second, nothing has gone out.
|
||||
await clickText('+');
|
||||
await pickAssetType(t, 'Sprite');
|
||||
await sleep(2500);
|
||||
const changed = await json('__gm.state.project.assets.length');
|
||||
const copy = await (await send('GET', `demos/${token}`, { as: 'nobody' })).json();
|
||||
check(
|
||||
"a guest's change stays in the tab: no request that writes, and the copy on the server as it was",
|
||||
changed === 3 && writes.length === 0 && copy.project.assets.length === 2,
|
||||
JSON.stringify({ changed, writes, served: copy.project.assets.length }),
|
||||
);
|
||||
|
||||
// 70g. Sign in… goes back to the sign-in screen; the demo picked again is the copy as it was.
|
||||
await pressAt(await locate('Account'));
|
||||
await sleep(300);
|
||||
const accountMenu = await json('({ rows: __gm.menuRows(), enabled: __gm.menuEnabled() })');
|
||||
await pressAt(await locate('Sign in…'));
|
||||
await waitUntil('Boolean(window.__gm?.login) && !window.__gm?.state', {
|
||||
label: 'the sign-in screen is back',
|
||||
});
|
||||
await pickDemo();
|
||||
const again = await json('__gm.state.project.assets.length');
|
||||
watching = false;
|
||||
check(
|
||||
"the guest's account menu signs in instead, and the demo picked again is the copy as it was",
|
||||
accountMenu.rows[0] === 'Guest – nothing is saved' &&
|
||||
accountMenu.enabled[0] === false &&
|
||||
accountMenu.rows.includes('Sign in…') &&
|
||||
!accountMenu.rows.includes('Logout') &&
|
||||
again === 2 &&
|
||||
writes.length === 0,
|
||||
JSON.stringify({ accountMenu, again, writes }),
|
||||
);
|
||||
check('the guest session threw nothing', exceptions().length === 0, exceptions().join(' | '));
|
||||
} finally {
|
||||
watching = false;
|
||||
await sweep();
|
||||
await send('PATCH', `sounds/${spare}`, { as: 'admin', body: { demo: false } });
|
||||
await send('DELETE', `sounds/${spare}`);
|
||||
await send('POST', 'auth/logout', { as: 'admin' });
|
||||
await navigate();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user