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>
252 lines
11 KiB
JavaScript
252 lines
11 KiB
JavaScript
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();
|
||
}
|
||
}
|