Files
adminandClaude Opus 5.5 0d7672a5af
feuerwerk / gate (push) Successful in 4m34s
feuerwerk / suite (push) Failing after 18m31s
Play the guest's changed demo in e2e 70 (#428)
The test plan of #428 asks a guest to change the demo and play it,
with no request that writes reaching the server. e2e 70 only changed
it.

- 70f now plays the changed demo and stops it before it counts the
  requests.

Tests: e2e 70 (atx) ALL PASSED, 13 checks, mode "play", no writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 07:19:31 +02:00

252 lines
11 KiB
JavaScript
Raw Permalink 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, played, stays in the tab: well past the autosave's second, nothing has gone out.
await clickText('+');
await pickAssetType(t, 'Sprite');
await t.play();
const mode = await json('__gm.state.mode');
await t.stop();
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 changes the demo and plays it: no request that writes, and the copy on the server as it was',
mode === 'play' && changed === 3 && writes.length === 0 && copy.project.assets.length === 2,
JSON.stringify({ mode, 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();
}
}