Files
feuerwerk/tools/e2e/70-guest.mjs
T
adminandClaude Opus 5.5 925a39a276
feuerwerk / gate (push) Successful in 4m51s
feuerwerk / suite (push) Successful in 19m23s
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>
2026-10-16 06:58:30 +02:00

249 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}