Let guests try a demo project without an account (#428)
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s

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:
adminandClaude Opus 5.5 committed 2026-10-16 06:58:30 +02:00
1 parent ce1f25f2eb
commit 925a39a276
30 files changed
+1397 -128

No files matched your search

+248
View File
@@ -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();
}
}
+2
View File
@@ -72,6 +72,7 @@ import { video } from './66-video.mjs';
import { recording } from './67-recording.mjs';
import { composition } from './68-composition.mjs';
import { compositionTab } from './69-composition-tab.mjs';
import { guest } from './70-guest.mjs';
export const STEPS = [
hierarchy,
@@ -143,4 +144,5 @@ export const STEPS = [
recording,
composition,
compositionTab,
guest,
];