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(); } }