From 925a39a27644f2fcc7d34c3fab9bfe98c8ad0085 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Fri, 16 Oct 2026 06:58:30 +0200 Subject: [PATCH] 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 --- .gitea/workflows/ci.yml | 1 + .gitignore | 2 + api/index.php | 156 ++++++++++++++--- api/lib/DemoFiles.php | 62 +++++++ api/lib/FileUsage.php | 10 +- api/lib/ReleaseStore.php | 22 +++ src/core/store/DemoStore.ts | 49 ++++++ src/core/store/FileStore.ts | 7 + src/core/store/HttpFileStore.ts | 18 +- src/core/store/MemoryProjectStore.ts | 53 ++++++ src/editor/App.ts | 151 +++++++++++----- src/editor/Editor.ts | 56 ++++-- src/editor/FilePicker.ts | 90 ++++++++-- src/editor/ProjectSession.ts | 97 ++++++++++- src/editor/auth/LoginScreen.ts | 34 ++++ src/editor/fileImport.ts | 7 +- src/editor/guestStores.ts | 37 ++++ src/editor/panels/Toolbar.ts | 10 +- src/editor/recordingUploads.ts | 20 ++- src/editor/session.ts | 52 ++++++ src/editor/snapshots.ts | 9 +- src/editor/sound/SoundPicker.ts | 2 + src/editor/texture/ImagePicker.ts | 5 +- src/editor/video/VideoPicker.ts | 4 +- tests/core/guest.test.ts | 148 ++++++++++++++++ tests/php/demos.test.php | 97 +++++++++++ tests/php/files.test.php | 6 +- tests/php/rights.test.php | 70 +++++++- tools/e2e/70-guest.mjs | 248 +++++++++++++++++++++++++++ tools/e2e/index.mjs | 2 + 30 files changed, 1397 insertions(+), 128 deletions(-) create mode 100644 api/lib/DemoFiles.php create mode 100644 src/core/store/DemoStore.ts create mode 100644 src/core/store/MemoryProjectStore.ts create mode 100644 src/editor/guestStores.ts create mode 100644 src/editor/session.ts create mode 100644 tests/core/guest.test.ts create mode 100644 tests/php/demos.test.php create mode 100644 tools/e2e/70-guest.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e616f898..5d330eb3 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: php tests/php/files.test.php php tests/php/videos.test.php php tests/php/limits.test.php + php tests/php/demos.test.php php tests/php/rights.test.php suite: diff --git a/.gitignore b/.gitignore index b3910ef7..2a69c223 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ /data/sign-ins/ /data/snapshots/ /data/releases/ +/data/demos/ +/data/demo-files.json /data/users.json /data/textures/ /data/fonts/ diff --git a/api/index.php b/api/index.php index 82a3ddfc..8c1253a6 100644 --- a/api/index.php +++ b/api/index.php @@ -58,10 +58,19 @@ declare(strict_types=1); * DELETE ?r=projects/{id}/release → 204 | 404 * (one link per project, kept when publishing again, #266) * - * Released games are the one thing open to everybody, the token being the key: + * Released games are open to everybody, the token being the key: * * GET ?r=releases/{token} → {name,publishedAt,project} | 404 * + * So are the demo copies a guest opens in the editor (#428), frozen copies like a release that an + * administrator makes and updates per project: + * + * GET ?r=demos → [{token,name,publishedAt}], by name + * GET ?r=demos/{token} → {name,publishedAt,project} | 404 + * GET ?r=projects/{id}/demo → {token,revision,publishedAt} | null (administrators only) + * POST ?r=projects/{id}/demo → {token,revision,publishedAt} (administrators only) + * DELETE ?r=projects/{id}/demo → 204 | 404 (administrators only) + * * GET ?r=textures → [{file,name,width,height,bytes,updatedAt,owner,deletable}] * POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes} * DELETE ?r=textures/{file} → 204 | 403 | 404 | 409 (still used) @@ -89,12 +98,22 @@ declare(strict_types=1); * (#344). A `file` without a folder belongs to nobody. A list shows the files of the one asking and * nobody's, an administrator's every file; `owner` is the account a file belongs to (null for * nobody, '' for a deleted account). A file goes only at its owner's or an administrator's hand, - * and only while no project, snapshot or release uses it. + * and only while no project, snapshot, release or demo copy uses it. + * + * An administrator offers a file to everybody as demo material (#428); every list carries those + * files too, with `demo: true`, and without a session a list holds nothing else. Only an + * administrator deletes one while it is offered. + * + * PATCH ?r=textures/{file} {demo} → 204 | 403 | 404 | 422 (administrators only) + * PATCH ?r=fonts/{file} {demo} → the same + * PATCH ?r=sounds/{file} {demo} → the same + * PATCH ?r=videos/{file} {demo} → the same * * POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130) * * GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name * GET ?r=plugins/{file} → the module source as text/javascript + * (both without a session as well: a guest's demo may switch one on, #428) * * `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and * empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}` @@ -107,6 +126,7 @@ declare(strict_types=1); require __DIR__ . '/lib/AccountRules.php'; require __DIR__ . '/lib/AtomicFile.php'; require __DIR__ . '/lib/Auth.php'; +require __DIR__ . '/lib/DemoFiles.php'; require __DIR__ . '/lib/FileUsage.php'; require __DIR__ . '/lib/HashedFileStore.php'; // before the font, sound and texture stores built on it require __DIR__ . '/lib/FontStore.php'; @@ -305,6 +325,14 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl if (($parts[2] ?? null) === 'release' && count($parts) === 3) { routeRelease($method, $store, $releases, $id); } + $demos = new ReleaseStore(DATA_DIR . '/demos'); + if (($parts[2] ?? null) === 'demo' && count($parts) === 3) { + // What guests get to see is the administrators' call (#428). + if (!$seesAll) { + Response::error('Administrators only', 403); + } + routeRelease($method, $store, $demos, $id, 'Project has no demo copy'); + } if (count($parts) > 2) { Response::error('Not found', 404); } @@ -349,6 +377,7 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl } $snapshots->deleteAll($id); $releases->takeDown($id); + $demos->takeDown($id); Response::noContent(); } @@ -432,11 +461,17 @@ function routeSnapshots(array $parts, string $method, Store $store, SnapshotStor } /** - * The released copy of one project (#266). Whoever may load the project may publish it, update it - * and take it down; publishing copies the project as stored, not what a tab has not saved yet. + * The released copy of one project (#266), or its demo copy (#428). Whoever may load the project may + * publish it, update it and take it down; publishing copies the project as stored, not what a tab + * has not saved yet. */ -function routeRelease(string $method, Store $store, ReleaseStore $releases, string $id): never -{ +function routeRelease( + string $method, + Store $store, + ReleaseStore $releases, + string $id, + string $none = 'Project is not released', +): never { $doc = $store->load($id); if ($doc === null) { Response::error('Project not found', 404); @@ -449,26 +484,32 @@ function routeRelease(string $method, Store $store, ReleaseStore $releases, stri } if ($method === 'DELETE') { if (!$releases->takeDown($id)) { - Response::error('Project is not released', 404); + Response::error($none, 404); } Response::noContent(); } Response::error('Method not allowed', 405); } -/** The one route open without a sign-in: a released game, found by its token. */ -function routeReleases(array $parts, string $method): never +/** + * What is open without a sign-in: a released game (#266) and a demo copy (#428), each found by its + * token. The demos are listed as well, for a guest to pick one. + */ +function routeCopies(array $parts, string $method, ReleaseStore $copies, string $missing, bool $listed): never { - if ($method !== 'GET' || count($parts) !== 2) { + if ($method !== 'GET' || count($parts) > 2 || (count($parts) === 1 && !$listed)) { Response::error('Not found', 404); } - $release = (new ReleaseStore(DATA_DIR . '/releases'))->load($parts[1]); - if ($release === null) { - Response::error('Game not found', 404); + if (count($parts) === 1) { + Response::json($copies->list()); + } + $copy = $copies->load($parts[1]); + if ($copy === null) { + Response::error($missing, 404); } // The copy goes out under the token, not the project's own id: that one is nobody's business. - $project = ['id' => $release['token']] + $release['project']; - Response::json(['name' => $release['name'], 'publishedAt' => $release['publishedAt'], 'project' => $project]); + $project = ['id' => $copy['token']] + $copy['project']; + Response::json(['name' => $copy['name'], 'publishedAt' => $copy['publishedAt'], 'project' => $project]); } function snapshotName(array $body): string @@ -598,7 +639,8 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): * Images, fonts and sounds, stored under the hash of their bytes in a folder per account (#344). * Everybody signed in uploads into their own folder and lists their own files and nobody's; an * administrator lists every file. Deleting one is for its owner or an administrator, and refused - * while a project of any account still uses it. + * while a project of any account still uses it. What an administrator offers as demo material + * (#428) is in every list. * * @param list $parts * @param class-string $invalid what the store throws for bytes it cannot take @@ -616,9 +658,11 @@ function routeFiles( string $invalid, Closure $fits, ): never { + $kind = $parts[0]; + $demos = new DemoFiles(DATA_DIR . '/demo-files.json'); if (count($parts) === 1) { if ($method === 'GET') { - Response::json($store->list($users->folderOf($user), $isAdmin, $users->folderOwners())); + Response::json(filesOffered($store, $kind, $user, $isAdmin, $users)); } if ($method === 'POST') { $tooLarge = "{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB'; @@ -662,6 +706,22 @@ function routeFiles( Response::json($record); } + if ($method === 'PATCH') { + $file = implode('/', array_slice($parts, 1)); + if (!$isAdmin) { + Response::error('Administrators only', 403); + } + if (!$store->exists($file)) { + Response::error("{$noun} not found", 404); + } + $body = Response::body(); + if (!is_bool($body['demo'] ?? null)) { + Response::error('Body must contain boolean "demo"', 422); + } + $demos->set($kind, $file, $body['demo']); + Response::noContent(); + } + if ($method === 'DELETE') { $file = implode('/', array_slice($parts, 1)); // Asked before whether there is one: somebody else's folder tells a stranger nothing. @@ -672,17 +732,56 @@ function routeFiles( if (!$store->exists($file)) { Response::error("{$noun} not found", 404); } + // Everybody may have it in a project by now; whether that ends is the administrators' call. + if (!$isAdmin && $demos->has($kind, $file)) { + Response::error('Offered to everybody as demo material: only an administrator can take that back', 409); + } $uses = (new FileUsage(DATA_DIR))->of($file); if ($uses !== []) { Response::error(FileUsage::refusal($uses, $user, $isAdmin), 409); } $store->delete($file); + $demos->set($kind, $file, false); Response::noContent(); } Response::error('Method not allowed', 405); } +/** + * What a picker of `$kind` lists: the files of the one asking and nobody's, an administrator's every + * file (#344), and everything offered as demo material (#428), with `demo` saying which those are. + * Signed out (`$user` null) it is the demo material alone, without who uploaded it. + * + * @return list> + */ +function filesOffered(HashedFileStore $store, string $kind, ?string $user, bool $isAdmin, UserStore $users): array +{ + $demo = (new DemoFiles(DATA_DIR . '/demo-files.json'))->of($kind); + $owners = $users->folderOwners(); + $items = $user === null ? [] : $store->list($users->folderOf($user), $isAdmin, $owners); + if ($demo !== [] && !$isAdmin) { + $listed = array_column($items, 'file'); + $hidden = $user === null ? ['owner' => null] : []; + foreach ($store->list('', true, $owners) as $item) { + if (in_array($item['file'], $demo, true) && !in_array($item['file'], $listed, true)) { + $items[] = ['deletable' => false, ...$hidden] + $item; + } + } + } + return array_map(static fn(array $item): array => $item + ['demo' => in_array($item['file'], $demo, true)], $items); +} + +function fileStore(string $kind): HashedFileStore +{ + return match ($kind) { + 'textures' => new TextureStore(DATA_DIR . '/textures'), + 'fonts' => new FontStore(DATA_DIR . '/fonts'), + 'sounds' => new SoundStore(DATA_DIR . '/sounds'), + 'videos' => new VideoStore(DATA_DIR . '/videos'), + }; +} + /** @param list $parts */ function routePixels(array $parts, string $method): never { @@ -768,13 +867,24 @@ try { routeUsers($parts, $method, $auth, $users); } if ($parts[0] === 'releases') { - routeReleases($parts, $method); + routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/releases'), 'Game not found', false); + } + if ($parts[0] === 'demos') { + routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/demos'), 'Demo not found', true); + } + // A guest's editor runs the plugins a demo switches on (#428); they are the server's own code, + // put there by its administrator, and only to be read. + if ($parts[0] === 'plugins' && $method === 'GET') { + routePlugins($parts, $method); + } + // A guest's pickers list the demo material without a session (#428), and nothing else. + if (in_array($parts[0], DemoFiles::KINDS, true) && count($parts) === 1 && $method === 'GET' && $auth->user() === null) { + Response::json(filesOffered(fileStore($parts[0]), $parts[0], null, false, $users)); } // Being signed in is the price of admission for everything below, so an uploaded file cannot be // dropped in by a stranger. $user = $auth->require(); $isAdmin = $auth->role($user) === 'admin'; - $data = DATA_DIR; $fits = static function (int $adding) use ($user, $isAdmin, $users): void { if ($isAdmin) { return; @@ -793,7 +903,7 @@ try { $user, $isAdmin, $users, - new TextureStore("{$data}/textures"), + fileStore('textures'), MAX_TEXTURE_BYTES, 'Image', InvalidImageException::class, @@ -805,7 +915,7 @@ try { $user, $isAdmin, $users, - new FontStore("{$data}/fonts"), + fileStore('fonts'), MAX_FONT_BYTES, 'Font', InvalidFontException::class, @@ -817,7 +927,7 @@ try { $user, $isAdmin, $users, - new SoundStore("{$data}/sounds"), + fileStore('sounds'), MAX_SOUND_BYTES, 'Sound', InvalidSoundException::class, @@ -829,7 +939,7 @@ try { $user, $isAdmin, $users, - new VideoStore("{$data}/videos"), + fileStore('videos'), MAX_VIDEO_BYTES, 'Video', InvalidVideoException::class, diff --git a/api/lib/DemoFiles.php b/api/lib/DemoFiles.php new file mode 100644 index 00000000..ae137539 --- /dev/null +++ b/api/lib/DemoFiles.php @@ -0,0 +1,62 @@ + the offered files of one kind, in the order they were offered */ + public function of(string $kind): array + { + $files = $this->read()[$kind] ?? []; + return array_values(array_filter($files, 'is_string')); + } + + public function has(string $kind, string $file): bool + { + return in_array($file, $this->of($kind), true); + } + + /** Offers a file or takes the offer back; answers whether that changed anything. */ + public function set(string $kind, string $file, bool $offered): bool + { + if (!in_array($kind, self::KINDS, true)) { + throw new InvalidArgumentException("Unknown kind: {$kind}"); + } + $all = $this->read(); + $files = $this->of($kind); + $has = in_array($file, $files, true); + if ($has === $offered) { + return false; + } + $all[$kind] = $offered ? [...$files, $file] : array_values(array_diff($files, [$file])); + $json = json_encode($all, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + if ($json === false) { + throw new RuntimeException('Cannot encode the demo files'); + } + AtomicFile::write($this->path, $json . "\n", AtomicFile::HIDDEN, 'demo file list'); + return true; + } + + /** @return array> */ + private function read(): array + { + if (!is_file($this->path)) { + return []; + } + $raw = file_get_contents($this->path); + $data = $raw === false ? null : json_decode($raw, true); + return is_array($data) ? $data : []; + } +} diff --git a/api/lib/FileUsage.php b/api/lib/FileUsage.php index a807ee87..f0030459 100644 --- a/api/lib/FileUsage.php +++ b/api/lib/FileUsage.php @@ -4,8 +4,9 @@ declare(strict_types=1); /** * Which projects still use a stored image, font or sound (#344) – asked before one is deleted. * - * A project counts when the file is named in the project itself, in one of its snapshots or in its - * release: restoring a snapshot or playing a released game must not find a hole. Any string value + * A project counts when the file is named in the project itself, in one of its snapshots, in its + * release or in its demo copy: restoring a snapshot, playing a released game or trying a demo must + * not find a hole. Any string value * equal to the stored name counts, wherever in the document it is; that may refuse a file nothing * really draws, but never lets one go that something does. */ @@ -31,7 +32,8 @@ final class FileUsage $used[basename(dirname($path))] = true; } } - foreach (glob("{$this->data}/releases/*.json") ?: [] as $path) { + // A demo copy (#428) is a release a guest opens in the editor, and stored like one. + foreach ([...glob("{$this->data}/releases/*.json") ?: [], ...glob("{$this->data}/demos/*.json") ?: []] as $path) { $release = $this->decoded($path, $file); if ($release !== null && is_string($release['projectId'] ?? null) && self::uses($release['project'] ?? null, $file)) { $used[$release['projectId']] = true; @@ -74,7 +76,7 @@ final class FileUsage $named[] = $others === 1 ? 'a project of another account' : "{$others} projects of other accounts"; } $list = count($named) > 1 ? implode(', ', array_slice($named, 0, -1)) . ' and ' . end($named) : $named[0]; - return "Still used by {$list}, counting snapshots and releases"; + return "Still used by {$list}, counting snapshots, releases and demo copies"; } /** Whether a value holds `$file` as a string anywhere inside it. */ diff --git a/api/lib/ReleaseStore.php b/api/lib/ReleaseStore.php index 17824dc1..a24ce389 100644 --- a/api/lib/ReleaseStore.php +++ b/api/lib/ReleaseStore.php @@ -13,6 +13,9 @@ declare(strict_types=1); * * Like the snapshots, the store checks nothing about who asks: the router reaches the project half * only after `Store::load` found the project for that user. + * + * Demo copies (#428) are the same thing on `data/demos/`: a frozen copy a guest opens in the editor + * instead of the player, made and updated by an administrator. */ final class ReleaseStore { @@ -82,6 +85,25 @@ final class ReleaseStore ]; } + /** + * Every copy there is, by name: what a guest picks a demo from (#428), which is this store on a + * directory of its own. + * + * @return list + */ + public function list(): array + { + $items = []; + foreach (glob($this->dir . '/*.json') ?: [] as $path) { + $release = $this->load(basename($path, '.json')); + if ($release !== null) { + $items[] = ['token' => $release['token'], 'name' => $release['name'], 'publishedAt' => $release['publishedAt']]; + } + } + usort($items, static fn(array $a, array $b): int => strnatcasecmp($a['name'], $b['name'])); + return $items; + } + /** Makes the link dead. False when the project had none. */ public function takeDown(string $projectId): bool { diff --git a/src/core/store/DemoStore.ts b/src/core/store/DemoStore.ts new file mode 100644 index 00000000..bf7c034f --- /dev/null +++ b/src/core/store/DemoStore.ts @@ -0,0 +1,49 @@ +import { apiRequest } from './http'; +import type { ReleasedGame, ReleaseStore, ReleaseSummary } from './ReleaseStore'; + +/** A demo copy as a guest picks it (#428). */ +export interface DemoSummary { + token: string; + name: string; + /** ISO time, UTC: when the copy was made or last updated. */ + publishedAt: string; +} + +/** + * Demo copies (#428): frozen copies of projects a guest opens in the editor without an account, + * stored like a release. Listing and loading one needs no sign-in; making, updating and removing the + * copy of a project (the `ReleaseStore` half) is for administrators. + */ +export interface DemoStore extends ReleaseStore { + list(): Promise; + /** The copy, under its token as the project id. */ + load(token: string): Promise; +} + +export class HttpDemoStore implements DemoStore { + constructor(private readonly endpoint: string) {} + + list(): Promise { + return apiRequest(this.endpoint, 'GET', 'demos'); + } + + load(token: string): Promise { + return apiRequest(this.endpoint, 'GET', `demos/${token}`); + } + + of(projectId: string): Promise { + return apiRequest(this.endpoint, 'GET', route(projectId)); + } + + publish(projectId: string): Promise { + return apiRequest(this.endpoint, 'POST', route(projectId)); + } + + takeDown(projectId: string): Promise { + return apiRequest(this.endpoint, 'DELETE', route(projectId)); + } +} + +function route(projectId: string): string { + return `projects/${encodeURIComponent(projectId)}/demo`; +} diff --git a/src/core/store/FileStore.ts b/src/core/store/FileStore.ts index c5f817db..3e04105f 100644 --- a/src/core/store/FileStore.ts +++ b/src/core/store/FileStore.ts @@ -16,6 +16,8 @@ export interface StoredFile extends UploadedFile { owner: string | null; /** Whether the one asking may delete it – whether a project still uses it, only the delete finds out. */ deletable: boolean; + /** Offered to everybody as demo material by an administrator (#428); absent from an older server. */ + demo?: boolean; } /** @@ -30,4 +32,9 @@ export interface FileStore; list(): Promise; delete(file: string): Promise; + /** + * Offers a file to everybody as demo material, or takes that back (#428). Only there for an + * administrator; the pickers show the switch when it is. + */ + setDemo?: (file: string, demo: boolean) => Promise; } diff --git a/src/core/store/HttpFileStore.ts b/src/core/store/HttpFileStore.ts index 9a30d234..6e981c3a 100644 --- a/src/core/store/HttpFileStore.ts +++ b/src/core/store/HttpFileStore.ts @@ -1,5 +1,5 @@ import type { FileStore, StoredFile, UploadedFile } from './FileStore'; -import { apiRequest } from './http'; +import { apiRequest, jsonBody } from './http'; import type { StoredVideo, UploadedVideo, VideoStore, VideoUploadOptions } from './VideoStore'; /** @@ -13,10 +13,20 @@ export class HttpFileStore< U extends UploadedFile = UploadedFile, S extends StoredFile = StoredFile, > implements FileStore { + readonly setDemo?: (file: string, demo: boolean) => Promise; + + /** `offersDemos` for an administrator's session: the server refuses everybody else anyway (#428). */ constructor( protected readonly endpoint: string, private readonly route: FileRoute, - ) {} + offersDemos = false, + ) { + if (offersDemos) { + this.setDemo = async (file, demo) => { + await apiRequest(this.endpoint, 'PATCH', `${this.route}/${file}`, jsonBody({ demo }), refusal); + }; + } + } async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise { return this.send(bytes, type, { name }); @@ -42,8 +52,8 @@ export class HttpFileStore< /** Videos: an upload may be kept without converting it, and converted later. */ export class HttpVideoStore extends HttpFileStore implements VideoStore { - constructor(endpoint: string) { - super(endpoint, 'videos'); + constructor(endpoint: string, offersDemos = false) { + super(endpoint, 'videos', offersDemos); } override async upload( diff --git a/src/core/store/MemoryProjectStore.ts b/src/core/store/MemoryProjectStore.ts new file mode 100644 index 00000000..31953d58 --- /dev/null +++ b/src/core/store/MemoryProjectStore.ts @@ -0,0 +1,53 @@ +import type { Project } from '../model/Project'; +import { + type LoadedProject, + type ProjectStore, + type ProjectSummary, + StaleRevisionError, +} from './ProjectStore'; +import { deserializeProject, serializeProject } from './serialize'; + +/** + * A guest's project (#428): one demo copy, held in this tab and nowhere else. Saving keeps it here, + * so nothing a guest changes reaches the server, and a reload starts from the copy again. There is + * no second project to create, open or delete. + */ +export class MemoryProjectStore implements ProjectStore { + private revision = 1; + private readonly updatedAt = new Date().toISOString(); + + constructor( + private readonly id: string, + private readonly name: string, + private raw: unknown, + private readonly refusal: string, + ) {} + + list(): Promise { + return Promise.resolve([ + { id: this.id, name: this.name, revision: this.revision, updatedAt: this.updatedAt }, + ]); + } + + async load(id: string, prepare?: (raw: unknown) => Promise): Promise { + if (id !== this.id) throw new Error(`Project not found: ${id}`); + await prepare?.(this.raw); + // Saves are matched by id, so the copy answers to the token whatever its file said. + const raw = typeof this.raw === 'object' && this.raw !== null ? { ...this.raw, id: this.id } : this.raw; + return { project: deserializeProject(raw, this.id), revision: this.revision }; + } + + save(project: Project, revision: number): Promise { + if (revision !== this.revision) return Promise.reject(new StaleRevisionError(this.revision)); + this.raw = serializeProject(project); + return Promise.resolve(++this.revision); + } + + create(_name: string): Promise { + return Promise.reject(new Error(this.refusal)); + } + + delete(_id: string): Promise { + return Promise.reject(new Error(this.refusal)); + } +} diff --git a/src/editor/App.ts b/src/editor/App.ts index 8c64a20f..de81603d 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -1,11 +1,13 @@ import type { Application } from 'pixi.js'; import type { AuthStore, SignedIn } from '../core/store/AuthStore'; +import { type DemoStore, type DemoSummary, HttpDemoStore } from '../core/store/DemoStore'; import { HttpAuthStore } from '../core/store/HttpAuthStore'; import { HttpFileStore, HttpVideoStore } from '../core/store/HttpFileStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; import { HttpReleaseStore } from '../core/store/HttpReleaseStore'; import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore'; import { HttpUsersStore } from '../core/store/HttpUsersStore'; +import { MemoryProjectStore } from '../core/store/MemoryProjectStore'; import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore'; import { loadPhysics } from '../runtime/physics/engine'; import { projectPictureFactory } from '../viewport/generatedPictures'; @@ -18,25 +20,40 @@ import { installLoginHook } from './auth/loginHook'; import { consoleLog } from './console/log'; import { installDebugHook } from './debugHook'; import { Editor } from './Editor'; +import { guestFiles, guestReleases, guestSnapshots, guestUsers, guestVideos } from './guestStores'; import { isDragFrame } from './framePreview'; import { startPlugins } from './plugins/startPlugins'; import { CelUploadStore } from './pixelart/celUploadStore'; import { API_ENDPOINT } from '../core/site'; import { EditorState } from './state'; +import { GUEST_REFUSAL, setSession } from './session'; + +/** Who the sign-in screen let in: an account, or a guest with the demo copy they picked (#428). */ +type Entry = { account: SignedIn } | { demo: DemoSummary }; /** - * Shows the sign-in screen until somebody is signed in, and answers with their name. Returns + * Shows the sign-in screen until somebody is signed in or picks a demo, and answers with who. Returns * right away when the session from an earlier visit is still good – which is what lets a * deployment replace the editor under an open tab without asking for the password again. */ -async function signedInUser(app: Application, root: VStack, auth: AuthStore): Promise { +async function signedInUser( + app: Application, + root: VStack, + auth: AuthStore, + demos: DemoStore, +): Promise { const existing = await auth.session(); - if (existing !== null) return existing; + if (existing !== null) return { account: existing }; - let done: (account: SignedIn) => void = () => {}; - const screen = new LoginScreen(auth, (account) => done(account)); + let done: (entry: Entry) => void = () => {}; + const screen = new LoginScreen( + auth, + demos, + (account) => done({ account }), + (demo) => done({ demo }), + ); const removeHook = installLoginHook(app, screen); - const account = await new Promise((resolve) => { + const entry = await new Promise((resolve) => { done = resolve; root.add(screen, 1); root.resize(app.screen.width, app.screen.height); @@ -50,7 +67,7 @@ async function signedInUser(app: Application, root: VStack, auth: AuthStore): Pr // the corner. root.resize(0, 0); screen.destroy({ children: true }); - return account; + return entry; } /** @@ -65,8 +82,13 @@ export async function startEditor(app: Application, root: VStack): Promise const auth = new HttpAuthStore(API_ENDPOINT); setUpProjectFiles(app.renderer); + const demos = new HttpDemoStore(API_ENDPOINT); + const openSession = async (): Promise => { - const { user, role } = await signedInUser(app, root, auth); + const entry = await signedInUser(app, root, auth, demos); + const guest = 'demo' in entry; + const { user, role } = guest ? { user: 'Guest', role: 'user' as const } : entry.account; + setSession({ guest, role }); // Who is signed in goes into the window title: the toolbar is crowded enough, and the title is // also what tells two tabs on two accounts apart. document.title = `Feuerwerk – ${user}`; @@ -79,38 +101,70 @@ export async function startEditor(app: Application, root: VStack): Promise state.on('project', () => { if (!isDragFrame()) preloadProjectFiles(state.project); }); - const editor = new Editor( - app, - root, - state, - new CelUploadStore( - new HttpProjectStore(API_ENDPOINT), - pixelCels, - new HttpFileStore(API_ENDPOINT, 'pixels'), - ), - new HttpFileStore(API_ENDPOINT, 'textures'), - new HttpFileStore(API_ENDPOINT, 'fonts'), - new HttpFileStore(API_ENDPOINT, 'sounds'), - new HttpVideoStore(API_ENDPOINT), - new HttpSnapshotStore(API_ENDPOINT), - new HttpReleaseStore(API_ENDPOINT), - { - user, - role, - users: new HttpUsersStore(API_ENDPOINT), - logOut: () => - void endSession(editor).catch((error: unknown) => { - console.error('[auth] Signing out failed:', error); - }), - // Straight through to the store: the server checks the old password, and the session this - // tab holds is the one it keeps – so there is nothing to do here afterwards. - changePassword: (current, next) => auth.changePassword(current, next), - }, - ); + const admin = role === 'admin'; + const textures = new HttpFileStore(API_ENDPOINT, 'textures', admin); + const fonts = new HttpFileStore(API_ENDPOINT, 'fonts', admin); + const sounds = new HttpFileStore(API_ENDPOINT, 'sounds', admin); + const videos = new HttpVideoStore(API_ENDPOINT, admin); + const logOut = (): void => + void endSession(editor, guest).catch((error: unknown) => { + console.error('[auth] Signing out failed:', error); + }); + // A guest (#428) gets the demo copy in a store of the tab's own, and every store that would + // write refuses before it asks: nothing a guest does reaches the server's writing routes. + const editor = guest + ? new Editor( + app, + root, + state, + await guestProject(demos, entry.demo), + guestFiles(textures), + guestFiles(fonts), + guestFiles(sounds), + guestVideos(videos), + guestSnapshots, + guestReleases, + { + user, + role, + guest: true, + users: guestUsers, + logOut, + changePassword: () => Promise.reject(new Error(GUEST_REFUSAL)), + }, + ) + : new Editor( + app, + root, + state, + new CelUploadStore( + new HttpProjectStore(API_ENDPOINT), + pixelCels, + new HttpFileStore(API_ENDPOINT, 'pixels'), + ), + textures, + fonts, + sounds, + videos, + new HttpSnapshotStore(API_ENDPOINT), + new HttpReleaseStore(API_ENDPOINT), + { + user, + role, + users: new HttpUsersStore(API_ENDPOINT), + demos: admin ? demos : undefined, + logOut, + // Straight through to the store: the server checks the old password, and the session + // this tab holds is the one it keeps – so there is nothing to do here afterwards. + changePassword: (current, next) => auth.changePassword(current, next), + }, + ); installDebugHook(app, state, editor); // Before the project, not after it (#119): which plugins run is written in the project file, // and `boot()` hands that list over before it reads the file into the model. await startPlugins(API_ENDPOINT, state, editor); + // A guest has one project; a `?project=` of an account's would only fail to open. + if (guest) forgetProjectInAddress(); await editor.boot(); loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error)); }; @@ -120,19 +174,34 @@ export async function startEditor(app: Application, root: VStack): Promise * of everything, then the server drops the session, and the sign-in screen comes back. The * editor goes first, so its last save still travels with a session the server accepts. */ - const endSession = async (editor: Editor): Promise => { + const endSession = async (editor: Editor, guest: boolean): Promise => { await editor.dispose(); document.title = 'Feuerwerk'; // The console panel is page-wide, the lines in it are not: what one account's project printed // is none of the next one's business. consoleLog.clear(); - try { - await auth.logOut(); - } catch (error) { - console.error('[auth] Signing out did not reach the server:', error); + // A guest has no session on the server to end. + if (!guest) { + try { + await auth.logOut(); + } catch (error) { + console.error('[auth] Signing out did not reach the server:', error); + } } await openSession(); }; await openSession(); } + +/** The demo copy a guest picked, in a store of the tab's own (#428). */ +async function guestProject(demos: DemoStore, demo: DemoSummary): Promise { + const copy = await demos.load(demo.token); + return new MemoryProjectStore(demo.token, copy.name, copy.project, GUEST_REFUSAL); +} + +function forgetProjectInAddress(): void { + const url = new URL(location.href); + url.searchParams.delete('project'); + history.replaceState(null, '', url); +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index e4553761..1eaa9f87 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -4,6 +4,7 @@ import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project'; import { assetTitle } from '../core/music/embedded'; import { mixOf, videoBusesOf } from '../core/music/mixer'; import type { Role } from '../core/store/AuthStore'; +import type { DemoStore } from '../core/store/DemoStore'; import type { ProjectStore } from '../core/store/ProjectStore'; import { pluginsOf } from '../core/store/projectPlugins'; import type { FontStore } from '../core/store/FontStore'; @@ -158,7 +159,11 @@ export interface EditorAccount { user: string; /** What the editor offers: an administrator gets the user management (#180, #179). */ role: Role; + /** Somebody trying a demo copy without an account (#428): nothing they do is kept. */ + guest?: boolean; users: UsersStore; + /** An administrator's: making the demo copy of the open project (#428). */ + demos?: DemoStore; logOut: () => void; /** Rejects with `WrongCredentialsError` when the current password does not match (#182). */ changePassword: (current: string, next: string) => Promise; @@ -365,6 +370,7 @@ export class Editor implements PlayHost { private readonly cleanups: (() => void)[] = []; private readonly autosave: { flush: () => Promise; stop: () => void }; private disposed = false; + private readonly guest: boolean; // Each starts where its divider was left in this browser (`panelSizes.ts`), else at the number here. private leftWidth = panelSize('left', 250, MIN_LEFT_WIDTH, MAX_LEFT_WIDTH); private rightWidth = panelSize('right', 350, MIN_RIGHT_WIDTH, MAX_RIGHT_WIDTH); @@ -448,11 +454,13 @@ export class Editor implements PlayHost { () => { if (this.play.isPlaying) this.play.stop(); }, - { snapshots, releases, flush: () => this.autosave.flush() }, + { snapshots, releases, demos: account.demos, flush: () => this.autosave.flush() }, + { guest: account.guest === true, role: account.role }, // Every project brings its own plugins (#119); until `startPlugins` has run there is no // manager, and a project with plugins simply has none of them. async (raw) => void (await this.plugins?.syncTo(pluginsOf(raw))), ); + this.guest = account.guest === true; this.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId); this.graphEditor.canvas.timeProvider = () => this.play.time; @@ -476,16 +484,20 @@ export class Editor implements PlayHost { : null; this.managementPane = new ManagementPane(this.usersPane, this.pluginsPane); - this.toolbar = new Toolbar(state, { - openMainMenu: (anchor) => - this.projects.openMainMenu(anchor, this.managementEntries(), [ - { label: 'Master routing', onSelect: () => this.openDocument('routing') }, - ]), - togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), - togglePause: () => this.play.togglePause(), - step: () => this.play.step(), - openAccountMenu: (anchor) => this.openAccountMenu(anchor, account), - }); + this.toolbar = new Toolbar( + state, + { + openMainMenu: (anchor) => + this.projects.openMainMenu(anchor, this.managementEntries(), [ + { label: 'Master routing', onSelect: () => this.openDocument('routing') }, + ]), + togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()), + togglePause: () => this.play.togglePause(), + step: () => this.play.step(), + openAccountMenu: (anchor) => this.openAccountMenu(anchor, account), + }, + account.guest === true, + ); root.add(this.toolbar); this.workspace = new HStack(); @@ -614,7 +626,10 @@ export class Editor implements PlayHost { // A press in the editor takes the keys back from a window (#371). this.workspace.on('pointerdowncapture', () => (this.pressedWindow = null)); - this.autosave = attachAutosave(state, store); + // A guest's project lives in the tab alone (#428): there is nowhere to save it to. + this.autosave = this.guest + ? { flush: () => Promise.resolve(), stop: () => {} } + : attachAutosave(state, store); this.cleanups.push( installShortcuts(state, { play: () => this.play.play(), @@ -863,6 +878,14 @@ export class Editor implements PlayHost { * picked: it says who is signed in without costing the crowded toolbar another label. */ private openAccountMenu(anchor: Widget, account: EditorAccount): void { + if (this.guest) { + new Menu([ + { label: 'Guest – nothing is saved', disabled: true, onSelect: () => {} }, + { separator: true }, + { label: 'Sign in…', onSelect: () => account.logOut() }, + ]).openBelow(anchor); + return; + } new Menu([ { label: `Signed in as ${account.user}`, disabled: true, onSelect: () => {} }, { separator: true }, @@ -1348,8 +1371,13 @@ export class Editor implements PlayHost { }; } - boot(): Promise { - return this.projects.boot(); + async boot(): Promise { + await this.projects.boot(); + if (this.guest) { + this.state.setNotice( + 'You are trying a demo as a guest: change anything, play it, record it – nothing is saved. Sign in from the menu at the far right to keep your work.', + ); + } } /** diff --git a/src/editor/FilePicker.ts b/src/editor/FilePicker.ts index 9beba5d1..062deaae 100644 --- a/src/editor/FilePicker.ts +++ b/src/editor/FilePicker.ts @@ -26,6 +26,7 @@ import { crispText, } from '../ui'; import { dialogButtons } from './FormDialog'; +import { allows } from './session'; /** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */ export const PICKER_WIDTH = 560; @@ -55,10 +56,15 @@ function ownerText(owner: string | null): string { /** The line under the list for a picked file: its upload name and size, whose it is, and who wears it. */ function describeStored(item: PickerItem): string { const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes); - const owned = `${size} – ${ownerText(item.owner)}`; + const owned = `${size} – ${ownerText(item.owner)}${demoText(item)}`; return item.assetName ? `${owned} – already in the project as "${item.assetName}"` : owned; } +/** Said after what a file is when an administrator offers it to everybody (#428). */ +export function demoText(item: StoredFile): string { + return item.demo ? ' – demo material' : ''; +} + export interface PickerRow { /** A Container holding the rest, not a Graphics with children: Pixi v8 only lets containers have them. */ box: Container; @@ -116,6 +122,10 @@ export abstract class PickerList return this.items.filter((item) => item.assetName !== null).map((item) => item.file); } + get demoFiles(): string[] { + return this.items.filter((item) => item.demo === true).map((item) => item.file); + } + /** The captions as drawn, read off the rows themselves rather than recomputed (test hook). */ get captions(): string[] { return this.rows.map((row) => row.caption.text); @@ -203,6 +213,16 @@ export abstract class RowList ex /** What the Delete button says while it waits for the second press that really deletes. */ const ARMED_LABEL = 'Delete for good'; +const OFFER_LABEL = 'Offer as demo'; +const WITHDRAW_LABEL = 'Stop offering'; + +interface ButtonInfo { + enabled: boolean; + label: string; + shown: string; + x: number; + y: number; +} export interface PickerWords { title: string; @@ -243,6 +263,8 @@ export abstract class FilePicker extends Popup { private readonly detail: Label; private readonly use: Button; private readonly remove: Button; + /** An administrator's switch for offering the picked file as demo material (#428). */ + private readonly offer: Button | null; private readonly buttons: HStack; /** The file the Delete button was pressed for once; the second press deletes it. */ private armed: string | null = null; @@ -254,6 +276,8 @@ export abstract class FilePicker extends Popup { private readonly deleteFile: (file: string) => Promise, /** Buttons of a kind of picker of its own, after Delete; `picked` says what is picked. */ protected readonly extras: readonly Button[] = [], + /** There for an administrator only, see `FileStore.setDemo`. */ + private readonly setDemo?: (file: string, demo: boolean) => Promise, ) { super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); this.content.add(new Label(words.title, { style: 'bodyBold' })); @@ -280,36 +304,52 @@ export abstract class FilePicker extends Popup { this.remove = new Button('Delete', { variant: 'danger' }); this.remove.onClick = () => void this.pressDelete(); this.remove.setEnabled(false); - this.buttons = dialogButtons(this.closeThen(), this.use, [uploadButton, this.remove, ...extras]); + this.offer = setDemo ? new Button(OFFER_LABEL) : null; + if (this.offer) { + this.offer.onClick = () => void this.toggleDemo(); + this.offer.setEnabled(false); + } + // A guest (#428) uploads and deletes nothing: the buttons are not there rather than refused. + const writes = allows('upload') ? [uploadButton, this.remove] : []; + this.buttons = dialogButtons(this.closeThen(), this.use, [ + ...writes, + ...extras, + ...(this.offer ? [this.offer] : []), + ]); this.content.add(this.buttons); } /** * What the dialog offers, which of it the project already wears, what is picked, the line under - * the list and the Delete button with its centre (test hook). + * the list, the Delete button with its centre and, for an administrator, the demo switch; a + * button that is not in the dialog is null (test hook). */ debugInfo(): { files: string[]; used: string[]; + demo: string[]; captions: string[]; selected: string | null; detail: string; - deleteButton: { enabled: boolean; label: string; shown: string; x: number; y: number }; + deleteButton: ButtonInfo | null; + uploadShown: boolean; + offerButton: ButtonInfo | null; } { - const at = this.remove.getGlobalPosition(); + const info = (button: Button): ButtonInfo => { + const at = button.getGlobalPosition(); + const centre = { x: at.x + button.w / 2, y: at.y + button.h / 2 }; + return { enabled: button.enabled, label: button.text, shown: button.shownText, ...centre }; + }; return { files: this.list.fileList, used: this.list.usedFiles, + demo: this.list.demoFiles, captions: this.list.captions, selected: this.list.selected?.file ?? null, detail: this.detail.text, - deleteButton: { - enabled: this.remove.enabled, - label: this.remove.text, - shown: this.remove.shownText, - x: at.x + this.remove.w / 2, - y: at.y + this.remove.h / 2, - }, + deleteButton: this.remove.parent ? info(this.remove) : null, + uploadShown: this.remove.parent !== null, + offerButton: this.offer ? info(this.offer) : null, }; } @@ -352,6 +392,11 @@ export abstract class FilePicker extends Popup { this.setDeleteLabel('Delete'); this.use.setEnabled(item !== null && this.canUse(item)); this.remove.setEnabled(item?.deletable ?? false); + if (this.offer) { + this.offer.setEnabled(item !== null); + this.offer.setText(item?.demo ? WITHDRAW_LABEL : OFFER_LABEL); + this.buttons.layout(); + } this.picked(item); this.detail.setText(item ? this.describe(item) : (this.words.idle ?? '')); this.refreshLayout(); @@ -401,6 +446,27 @@ export abstract class FilePicker extends Popup { this.refreshLayout(); } + /** Offers the picked file to everybody as demo material, or takes that back (#428). */ + private async toggleDemo(): Promise { + const item = this.list.selected; + if (!item || !this.setDemo) return; + const offered = !item.demo; + try { + await this.setDemo(item.file, offered); + } catch (error) { + this.say(`Not changed: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (this.list.destroyed) return; + await this.load(); + this.list.select(item.file); + this.say( + offered + ? `Every account and every guest can use ${item.name || `this ${this.words.noun}`} now.` + : `${item.name || `This ${this.words.noun}`} is no longer offered as demo material.`, + ); + } + private confirm(): void { const item = this.list.selected; if (!item || !this.canUse(item)) return; diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 9600a68f..696ce32d 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -12,6 +12,7 @@ import { SetWhilePlayingCommand, } from '../core/commands/projectCommands'; import { assetRefChoices } from '../core/properties/assetRef'; +import type { DemoStore } from '../core/store/DemoStore'; import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore'; import { releaseUrl, type ReleaseStore } from '../core/store/ReleaseStore'; import type { SnapshotStore, SnapshotSummary } from '../core/store/SnapshotStore'; @@ -24,6 +25,7 @@ import { ProjectSettingsDialog, settingsChanges } from './ProjectSettingsDialog' import { clearSaved, hasSaved } from './saves'; import { snapshotBlocker, snapshotTime } from './snapshots'; import { SnapshotsDialog } from './SnapshotsDialog'; +import { type Session, sessionAllows } from './session'; import type { EditorState } from './state'; export class ProjectSession { @@ -41,10 +43,18 @@ export class ProjectSession { private readonly store: ProjectStore, private readonly beforeSwitch: () => void, /** - * What the server copies of the project: snapshots (#231) and the release (#266). `flush` writes - * what the autosave still holds, so a copy is of the stand on screen. + * What the server copies of the project: snapshots (#231), the release (#266) and, for an + * administrator, the demo copy (#428). `flush` writes what the autosave still holds, so a copy is + * of the stand on screen. */ - private readonly copies: { snapshots: SnapshotStore; releases: ReleaseStore; flush: () => Promise }, + private readonly copies: { + snapshots: SnapshotStore; + releases: ReleaseStore; + demos?: DemoStore; + flush: () => Promise; + }, + /** What the menu offers: a guest (#428) neither switches projects nor has copies made. */ + private readonly session: Session, /** * Runs on the project as it came off the server, before it is read into the model: the plugins * this project switches on are loaded there (#119), so that what they register is in place @@ -79,6 +89,8 @@ export class ProjectSession { this.beforeSwitch(); const loaded = await this.store.load(id, this.prepare); this.state.loadProject(loaded.project, loaded.revision); + // A guest's demo is nobody's project: a reload brings the sign-in screen, not the copy (#428). + if (this.session.guest) return; const url = new URL(location.href); url.searchParams.set('project', id); history.replaceState(null, '', url); @@ -109,12 +121,20 @@ export class ProjectSession { * the editor adds that is not about the project (#179). */ openMainMenu(anchor: Widget, more: MenuEntry[] = [], project: MenuEntry[] = []): void { + const may = (action: Parameters[1]) => sessionAllows(this.session, action); const items: MenuEntry[] = [ - { label: 'New project', onSelect: () => void this.run(this.createNext()) }, - { label: 'Open project…', onSelect: () => this.openProjectPicker() }, - { separator: true }, - { label: 'Snapshots…', onSelect: () => this.openSnapshots() }, - { label: 'Release…', onSelect: () => this.openRelease() }, + ...(may('projects') + ? [ + { label: 'New project', onSelect: () => void this.run(this.createNext()) }, + { label: 'Open project…', onSelect: () => this.openProjectPicker() }, + { separator: true } as const, + ] + : []), + ...(may('snapshots') ? [{ label: 'Snapshots…', onSelect: () => this.openSnapshots() }] : []), + ...(may('release') ? [{ label: 'Release…', onSelect: () => this.openRelease() }] : []), + ...(may('demoCopy') && this.copies.demos + ? [{ label: 'Demo copy…', onSelect: () => this.openDemoCopy() }] + : []), { label: 'Project settings…', onSelect: () => this.openProjectSettings() }, ...project, { label: 'Clear saved data', onSelect: () => this.clearSavedData() }, @@ -123,6 +143,67 @@ export class ProjectSession { new Menu(items).openBelow(anchor); } + /** + * The demo copy of the open project (#428): what a guest gets when they pick it on the sign-in + * screen. It is the project as stored, like a release, so the dialog says how old it is and + * whether the project has moved on since. + */ + openDemoCopy(): void { + const demos = this.copies.demos; + if (!demos) return; + const projectId = this.state.project.id; + void this.run( + (async () => { + const copy = await demos.of(projectId); + const cancel = { label: 'Close', onClick: () => {} }; + const update = { + label: copy ? 'Update demo copy' : 'Make demo copy', + primary: true, + onClick: () => void this.run(this.publishDemo(projectId, copy !== null)), + }; + if (!copy) { + new Dialog( + 'Demo copy', + 'Guests can try a copy of this project without an account. It is the project as it is saved now; later changes reach them when you update the copy.', + [cancel, update], + ).openCentered(); + return; + } + const behind = copy.revision !== this.state.revision ? ' The project has changed since.' : ''; + new Dialog( + 'Demo copy', + `Guests can pick this project. Their copy is from ${new Date(copy.publishedAt).toLocaleString()}.${behind}`, + [ + { + label: 'Remove', + danger: true, + onClick: () => + void this.run( + (async () => { + await demos.takeDown(projectId); + this.state.setNotice('Demo copy removed – guests no longer see this project'); + })(), + ), + }, + cancel, + update, + ], + ).openCentered(); + })(), + ); + } + + private async publishDemo(projectId: string, again: boolean): Promise { + await this.copies.flush(); + const blocker = snapshotBlocker(this.state.saveStatus, 'demo'); + if (blocker) { + this.state.setNotice(blocker, 'error'); + return; + } + await this.copies.demos?.publish(projectId); + this.state.setNotice(again ? 'Demo copy updated' : 'Demo copy made – guests can pick this project now'); + } + /** Forgets what persistent variables saved in this browser (#166), so the next run is a first start. */ private clearSavedData(): void { const id = this.state.project.id; diff --git a/src/editor/auth/LoginScreen.ts b/src/editor/auth/LoginScreen.ts index 51158df2..3736d829 100644 --- a/src/editor/auth/LoginScreen.ts +++ b/src/editor/auth/LoginScreen.ts @@ -1,11 +1,13 @@ import { Graphics } from 'pixi.js'; import logoSvg from '../../../public/brand/feuerwerk-logo-stacked-dark.svg?raw'; import { type AuthStore, type SignedIn, WrongCredentialsError } from '../../core/store/AuthStore'; +import type { DemoStore, DemoSummary } from '../../core/store/DemoStore'; import { Button, drawFloatingBox, drawSvg, Label, + Menu, Spacer, TextField, Theme, @@ -77,20 +79,26 @@ class Card extends VStack { * * It is shown instead of the editor, not over it – nothing of a project is loaded while nobody is * signed in, so there is nothing behind it to hide. + * + * Below the form a guest picks a demo copy to try the editor without an account (#428). */ export class LoginScreen extends VStack { private readonly logo = new Logo(); private readonly user: TextField; private readonly password: TextField; private readonly submit: Button; + private readonly tryButton: Button; private readonly status = new FormStatus((busy) => { this.submit.setText(busy ? 'Signing in…' : 'Sign in'); this.submit.setEnabled(!busy); + this.tryButton.setEnabled(!busy); }); constructor( private readonly auth: AuthStore, + private readonly demos: DemoStore, private readonly onSignedIn: (account: SignedIn) => void, + private readonly onGuest: (demo: DemoSummary) => void, ) { super({ align: 'center', gap: Theme.gap }); this.add(new Spacer(), 1); @@ -126,6 +134,13 @@ export class LoginScreen extends VStack { onClick: () => void this.signIn(), }); card.add(this.submit); + card.add(new Spacer(Theme.padding)); + card.add(new Label('No account? Try a demo project – nothing is saved.', { style: 'small' })); + this.tryButton = new Button('Try without an account', { + width: FIELD_WIDTH, + onClick: () => void this.pickDemo(), + }); + card.add(this.tryButton); this.add(card); // Twice the space below as above, so logo and card sit a little above the middle. @@ -142,6 +157,25 @@ export class LoginScreen extends VStack { return this.status.info(); } + /** The demo copies under the button, one entry each; picking one starts the guest session. */ + private async pickDemo(): Promise { + if (this.status.busy) return; + let demos: DemoSummary[]; + try { + demos = await this.demos.list(); + } catch (error) { + this.status.say(`Could not list the demos: ${error instanceof Error ? error.message : String(error)}`); + return; + } + if (demos.length === 0) { + this.status.say('There are no demo projects yet'); + return; + } + new Menu(demos.map((demo) => ({ label: demo.name, onSelect: () => this.onGuest(demo) }))).openBelow( + this.tryButton, + ); + } + private async signIn(): Promise { if (this.status.busy) return; const username = this.user.value.trim(); diff --git a/src/editor/fileImport.ts b/src/editor/fileImport.ts index ec959eec..bb10bec8 100644 --- a/src/editor/fileImport.ts +++ b/src/editor/fileImport.ts @@ -3,6 +3,7 @@ import { importSounds, isSoundFileName, type SoundImportDeps } from './soundImpo import { importImages, type TextureImportDeps } from './textureImport'; import { openVideoPicker } from './video/VideoPicker'; import { isVideoFileName, uploadVideos, type VideoImportDeps } from './videoImport'; +import { allows, GUEST_REFUSAL } from './session'; export type FileImportDeps = TextureImportDeps & FontImportDeps & SoundImportDeps & VideoImportDeps; @@ -28,12 +29,16 @@ export function installFileImport(target: HTMLElement, deps: FileImportDeps): () return; } event.preventDefault(); + if (!allows('upload')) { + deps.state.setNotice(GUEST_REFUSAL); + return; + } run(files, deps); }; const onPaste = (event: ClipboardEvent) => { if (isTextFieldFocused()) return; const files = sortFiles(event.clipboardData); - if (files.none) return; + if (files.none || !allows('upload')) return; event.preventDefault(); run(files, deps); }; diff --git a/src/editor/guestStores.ts b/src/editor/guestStores.ts new file mode 100644 index 00000000..aa342032 --- /dev/null +++ b/src/editor/guestStores.ts @@ -0,0 +1,37 @@ +import type { FileStore, StoredFile, UploadedFile } from '../core/store/FileStore'; +import type { ReleaseStore } from '../core/store/ReleaseStore'; +import type { SnapshotStore } from '../core/store/SnapshotStore'; +import type { UsersStore } from '../core/store/UsersStore'; +import type { VideoStore } from '../core/store/VideoStore'; +import { GUEST_REFUSAL } from './session'; + +/** + * What a guest's editor gets instead of the server's stores (#428). Files are listed as the server + * offers them to somebody signed out, which is the demo material; everything that would write is + * refused here in the tab, before a request is made. The rest of the editor works on these as on + * any store, so no path through it can reach the server's writing routes. + */ +function refuse(): Promise { + return Promise.reject(new Error(GUEST_REFUSAL)); +} + +export function guestFiles( + server: FileStore, +): FileStore { + return { list: () => server.list(), upload: refuse, delete: refuse }; +} + +export function guestVideos(server: VideoStore): VideoStore { + return { list: () => server.list(), upload: refuse, delete: refuse, convert: refuse }; +} + +export const guestSnapshots: SnapshotStore = { + list: refuse, + create: refuse, + restore: refuse, + delete: refuse, +}; + +export const guestReleases: ReleaseStore = { of: refuse, publish: refuse, takeDown: refuse }; + +export const guestUsers: UsersStore = { list: refuse, create: refuse, update: refuse, remove: refuse }; diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index ba91e50f..7f7d10fe 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -26,6 +26,8 @@ const SAVE_LABELS: Record = { conflict: 'Conflict – reload', }; +const GUEST_LABEL = 'Guest – not saved'; + /** Square, because an icon has no reading direction that would ask for a wider box. */ const ICON_BUTTON = Theme.controlHeight; @@ -68,6 +70,8 @@ export class Toolbar extends HStack { constructor( private readonly state: EditorState, actions: ToolbarActions, + /** A guest's project is never saved (#428), and the place of the save status says so. */ + private readonly guest = false, ) { super({ gap: Theme.gap, padding: Theme.headerPadding, align: 'center' }); this.minHeight = Theme.controlHeight + Theme.headerPadding * 2; @@ -195,8 +199,10 @@ export class Toolbar extends HStack { this.pauseButton.invalidate(); this.stepButton.setEnabled(!editing && state.paused); this.stepButton.invalidate(); - this.status.setText(SAVE_LABELS[state.saveStatus]); - this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small'); + this.status.setText(this.guest ? GUEST_LABEL : SAVE_LABELS[state.saveStatus]); + this.status.setStyle( + this.guest || state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small', + ); } /** diff --git a/src/editor/recordingUploads.ts b/src/editor/recordingUploads.ts index a4c6fff5..cdce5064 100644 --- a/src/editor/recordingUploads.ts +++ b/src/editor/recordingUploads.ts @@ -1,13 +1,19 @@ import type { UploadedVideo } from '../core/store/VideoStore'; import type { MadeRecording } from '../viewport/RecordingTake'; import type { RecordingUploadOptions } from '../core/recording/recording'; +import { downloadBlob } from '../ui'; +import { allows } from './session'; import { adoptVideo, type VideoImportDeps } from './videoImport'; /** How often the server is asked whether a recording is converted, as the video picker does. */ const POLL_MS = 1000; -/** `kept`: uploaded without converting it; `converted`: converted, but not to be imported. */ -type UploadState = 'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed'; +/** + * `kept`: uploaded without converting it; `converted`: converted, but not to be imported; + * `downloaded`: a guest's, handed to the browser instead (#428). + */ +type UploadState = + 'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed' | 'downloaded'; /** What became of a recording on its way into the project (test hook). */ export interface RecordingUpload { @@ -26,6 +32,8 @@ export interface RecordingUpload { * while the editor is not playing: a run plays a copy of the project that is thrown away, and so * would the asset be. A recording may stop short of either step, as "Start recording…" or the node * asked: it is always uploaded, and the video picker has it. + * + * A guest keeps nothing on the server (#428): their recording is saved as a download instead. */ export class RecordingUploads { private readonly uploads: RecordingUpload[] = []; @@ -42,6 +50,14 @@ export class RecordingUploads { error: '', }; this.uploads.push(upload); + if (!allows('upload')) { + downloadBlob(recording.blob, `${recording.name}.mkv`); + upload.state = 'downloaded'; + this.deps.state.setNotice( + `Saved the recording "${recording.name}" as a download – guests keep nothing on the server.`, + ); + return; + } void this.bring(recording, upload, options).catch((error: unknown) => { upload.state = 'failed'; upload.error = error instanceof Error ? error.message : String(error); diff --git a/src/editor/session.ts b/src/editor/session.ts new file mode 100644 index 00000000..b4c960e2 --- /dev/null +++ b/src/editor/session.ts @@ -0,0 +1,52 @@ +import type { Role } from '../core/store/AuthStore'; + +/** + * Who sits at the editor (#428): somebody signed in, with their role, or a guest trying a demo + * copy without an account. + */ +export interface Session { + guest: boolean; + role: Role; +} + +/** + * Everything the editor does that writes to the server or manages it. A guest's project lives in + * the tab alone, so a guest does none of these; an account does all of them but the + * administrators' own. + */ +export type SessionAction = + | 'save' + | 'upload' + | 'deleteFile' + | 'projects' + | 'snapshots' + | 'release' + | 'password' + | 'users' + | 'demoCopy' + | 'offerDemo'; + +const ADMINISTRATORS_ONLY: ReadonlySet = new Set(['users', 'demoCopy', 'offerDemo']); + +/** Whether `session` may do `action`. The server decides again; this is what the editor offers. */ +export function sessionAllows(session: Session, action: SessionAction): boolean { + if (session.guest) return false; + return !ADMINISTRATORS_ONLY.has(action) || session.role === 'admin'; +} + +/** What a guest is told when something would need the server. */ +export const GUEST_REFUSAL = 'Guests keep nothing on the server – sign in to keep your work'; + +let current: Session = { guest: false, role: 'user' }; + +/** + * The session of this page, for the places that are not handed the account: the pickers, a file + * dropped on the editor, a finished recording. One page holds one session at a time. + */ +export function setSession(session: Session): void { + current = session; +} + +export function allows(action: SessionAction): boolean { + return sessionAllows(current, action); +} diff --git a/src/editor/snapshots.ts b/src/editor/snapshots.ts index 5088075e..8783a5f7 100644 --- a/src/editor/snapshots.ts +++ b/src/editor/snapshots.ts @@ -6,14 +6,17 @@ export function snapshotTime(at: Date): string { return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`; } +const COPY_WORDS = { snapshot: 'take a snapshot of', release: 'release', demo: 'make a demo copy of' }; + /** * Why a snapshot cannot be made or restored right now, or null when it can (#231). The server copies * what it has stored, so the editor has to have saved everything first: a snapshot without the - * last edits would be a stand nobody ever saw. A release is a copy as well (#266) and asks the same. + * last edits would be a stand nobody ever saw. A release (#266) and a demo copy (#428) are copies as + * well and ask the same. */ export function snapshotBlocker( status: SaveStatus, - copy: 'snapshot' | 'release' = 'snapshot', + copy: 'snapshot' | 'release' | 'demo' = 'snapshot', ): string | null { switch (status) { case 'saved': @@ -21,7 +24,7 @@ export function snapshotBlocker( case 'conflict': return 'This project was changed elsewhere. Reload to continue.'; case 'error': - return `The project could not be saved, so there is nothing current to ${copy === 'release' ? 'release' : 'take a snapshot of'}`; + return `The project could not be saved, so there is nothing current to ${COPY_WORDS[copy]}`; default: return 'The latest changes are not saved yet – stop the game or wait a moment'; } diff --git a/src/editor/sound/SoundPicker.ts b/src/editor/sound/SoundPicker.ts index 244fe05f..ba9b925e 100644 --- a/src/editor/sound/SoundPicker.ts +++ b/src/editor/sound/SoundPicker.ts @@ -99,6 +99,8 @@ class SoundPicker extends FilePicker { }, () => pickAndImportSounds(deps), (file) => deps.sounds.delete(file), + [], + deps.sounds.setDemo, ); } diff --git a/src/editor/texture/ImagePicker.ts b/src/editor/texture/ImagePicker.ts index 7c1a4723..5eef56e4 100644 --- a/src/editor/texture/ImagePicker.ts +++ b/src/editor/texture/ImagePicker.ts @@ -4,6 +4,7 @@ import { formatBytes, type Size, textStyle, truncate, crispText } from '../../ui import { textureLibrary } from '../../viewport/TextureLibrary'; import { currentPicker, + demoText, FilePicker, openPicker, PICKER_WIDTH, @@ -112,6 +113,8 @@ class ImagePicker extends FilePicker { }, () => pickAndImportImages(deps), (file) => deps.textures.delete(file), + [], + deps.textures.setDemo, ); } @@ -136,7 +139,7 @@ class ImagePicker extends FilePicker { protected override describe(item: ImageItem): string { if (item.width <= 0) return 'The server cannot read this file as an image.'; const measurements = `${item.width} × ${item.height} px, ${formatBytes(item.bytes)}`; - const size = item.name ? `${item.name} – ${measurements}` : measurements; + const size = `${item.name ? `${item.name} – ${measurements}` : measurements}${demoText(item)}`; return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size; } diff --git a/src/editor/video/VideoPicker.ts b/src/editor/video/VideoPicker.ts index 34736b27..b182e556 100644 --- a/src/editor/video/VideoPicker.ts +++ b/src/editor/video/VideoPicker.ts @@ -5,6 +5,7 @@ import { posterLibrary, videoFileUrl } from '../../viewport/VideoLibrary'; import { pickFiles } from '../assetImport'; import { currentPicker, + demoText, FilePicker, openPicker, type PickerItem, @@ -108,6 +109,7 @@ class VideoPicker extends FilePicker { new Button('Download', { tooltip: 'Save the video as it was uploaded' }), new Button('Convert', { tooltip: 'Convert it on the server, so a project can use it' }), ], + deps.videos.setDemo, ); const [download, convert] = this.extras; download!.onClick = () => this.download(); @@ -214,7 +216,7 @@ class VideoPicker extends FilePicker { protected override describe(item: VideoItem): string { const size = item.status === 'ready' || item.status === 'unconverted' ? ` – ${formatBytes(item.bytes)}` : ''; - return `${item.name || 'Video'}${size} – ${videoStatusText(item)}`; + return `${item.name || 'Video'}${size} – ${videoStatusText(item)}${demoText(item)}`; } private itemOf(file: string): VideoItem | null { diff --git a/tests/core/guest.test.ts b/tests/core/guest.test.ts new file mode 100644 index 00000000..f64e122c --- /dev/null +++ b/tests/core/guest.test.ts @@ -0,0 +1,148 @@ +/** + * Trying the editor without an account (#428): what a session may do, the guest's project held in + * the tab, the stores that refuse before they ask, and the routes of demo copies and demo material. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HttpDemoStore } from '../../src/core/store/DemoStore'; +import { HttpFileStore } from '../../src/core/store/HttpFileStore'; +import { MemoryProjectStore } from '../../src/core/store/MemoryProjectStore'; +import { StaleRevisionError } from '../../src/core/store/ProjectStore'; +import { serializeProject } from '../../src/core/store/serialize'; +import { guestFiles, guestReleases, guestSnapshots, guestVideos } from '../../src/editor/guestStores'; +import { GUEST_REFUSAL, type SessionAction, sessionAllows } from '../../src/editor/session'; +import { snapshotBlocker } from '../../src/editor/snapshots'; +import { makeProject } from '../helpers'; + +const TOKEN = '0123456789abcdef0123456789abcdef'; +const FILE = `a590cab0f8b7/${'b'.repeat(64)}.png`; + +function stubFetch(body: BodyInit | null, init: ResponseInit) { + const mock = vi.fn(() => Promise.resolve(new Response(body, init))); + vi.stubGlobal('fetch', mock); + return mock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('what a session may do', () => { + const actions: SessionAction[] = [ + 'save', + 'upload', + 'deleteFile', + 'projects', + 'snapshots', + 'release', + 'password', + 'users', + 'demoCopy', + 'offerDemo', + ]; + + it('a guest writes nothing to the server and manages nothing', () => { + for (const action of actions) + expect(sessionAllows({ guest: true, role: 'user' }, action), action).toBe(false); + // Not even a guest who somehow carried an administrator's role. + for (const action of actions) + expect(sessionAllows({ guest: true, role: 'admin' }, action), action).toBe(false); + }); + + it('an account does all of it but the administrators’ own', () => { + const user = actions.filter((action) => sessionAllows({ guest: false, role: 'user' }, action)); + expect(user).toEqual(['save', 'upload', 'deleteFile', 'projects', 'snapshots', 'release', 'password']); + expect(actions.every((action) => sessionAllows({ guest: false, role: 'admin' }, action))).toBe(true); + }); +}); + +describe("a guest's project, held in the tab", () => { + const raw = () => serializeProject(makeProject().project); + + it('is the one project there is, under the token, and loads after `prepare` saw it', async () => { + const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL); + expect(await store.list()).toEqual([ + expect.objectContaining({ id: TOKEN, name: 'Platformer', revision: 1 }), + ]); + const seen: unknown[] = []; + const loaded = await store.load(TOKEN, (json) => { + seen.push(json); + return Promise.resolve(); + }); + expect(seen).toHaveLength(1); + expect(loaded.project.id).toBe(TOKEN); + expect(loaded.revision).toBe(1); + await expect(store.load('another')).rejects.toThrow(/not found/); + }); + + it('keeps a save in the tab, with the revision moving on as on the server', async () => { + const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL); + const { project } = await store.load(TOKEN); + project.name = 'Changed'; + expect(await store.save(project, 1)).toBe(2); + expect((await store.load(TOKEN)).project.name).toBe('Changed'); + await expect(store.save(project, 1)).rejects.toBeInstanceOf(StaleRevisionError); + }); + + it('refuses to make or delete a project, with what a guest is told', async () => { + const store = new MemoryProjectStore(TOKEN, 'Platformer', raw(), GUEST_REFUSAL); + await expect(store.create('New')).rejects.toThrow(GUEST_REFUSAL); + await expect(store.delete(TOKEN)).rejects.toThrow(GUEST_REFUSAL); + }); +}); + +describe("a guest's stores", () => { + it('list what the server offers, and refuse every write without asking it', async () => { + const mock = stubFetch(JSON.stringify([{ file: FILE, demo: true }]), { status: 200 }); + const textures = guestFiles(new HttpFileStore('/api', 'textures')); + expect(await textures.list()).toEqual([{ file: FILE, demo: true }]); + expect(mock).toHaveBeenCalledTimes(1); + + await expect(textures.upload(new Blob(['x']), 'image/png', 'x.png')).rejects.toThrow(GUEST_REFUSAL); + await expect(textures.delete(FILE)).rejects.toThrow(GUEST_REFUSAL); + const videos = guestVideos({ + ...new HttpFileStore('/api', 'videos'), + convert: () => Promise.reject(new Error('unreached')), + } as never); + await expect(videos.convert('x.mp4')).rejects.toThrow(GUEST_REFUSAL); + await expect(guestSnapshots.create(TOKEN, 'one')).rejects.toThrow(GUEST_REFUSAL); + await expect(guestReleases.publish(TOKEN)).rejects.toThrow(GUEST_REFUSAL); + expect(mock).toHaveBeenCalledTimes(1); + }); +}); + +describe('demo copies and demo material', () => { + it('are listed and loaded without a project id, and made per project', async () => { + const mock = stubFetch(JSON.stringify([]), { status: 200 }); + const demos = new HttpDemoStore('/api'); + await demos.list(); + await demos.load(TOKEN); + await demos.publish('p1'); + await demos.takeDown('p1'); + const calls = mock.mock.calls.map( + ([url, init]) => `${init?.method} ${decodeURIComponent(url as string)}`, + ); + expect(calls).toEqual([ + 'GET /api?r=demos', + `GET /api?r=demos/${TOKEN}`, + 'POST /api?r=projects/p1/demo', + 'DELETE /api?r=projects/p1/demo', + ]); + }); + + it('offering a file is only there for an administrator, and sends the switch', async () => { + expect(new HttpFileStore('/api', 'textures').setDemo).toBeUndefined(); + const mock = stubFetch(null, { status: 204 }); + await new HttpFileStore('/api', 'textures', true).setDemo!(FILE, true); + const [url, init] = mock.mock.calls[0]!; + expect(decodeURIComponent(url as string)).toBe(`/api?r=textures/${FILE}`); + expect(init?.method).toBe('PATCH'); + expect(JSON.parse(init?.body as string)).toEqual({ demo: true }); + }); + + it('is not made of a stand that was not saved', () => { + expect(snapshotBlocker('error', 'demo')).toBe( + 'The project could not be saved, so there is nothing current to make a demo copy of', + ); + expect(snapshotBlocker('saved', 'demo')).toBeNull(); + }); +}); diff --git a/tests/php/demos.test.php b/tests/php/demos.test.php new file mode 100644 index 00000000..3418580d --- /dev/null +++ b/tests/php/demos.test.php @@ -0,0 +1,97 @@ +of('textures') === [] && !$files->has('textures', $tree)); +check('offering a file says it changed something', $files->set('textures', $tree, true) === true); +check('and it is offered', $files->has('textures', $tree) && $files->of('textures') === [$tree]); +check('offering it again changes nothing', $files->set('textures', $tree, true) === false && $files->of('textures') === [$tree]); +$files->set('sounds', $song, true); +check('each kind has its own list', $files->of('sounds') === [$song] && !$files->has('sounds', $tree)); +check('a new reader finds the same', (new DemoFiles("{$data}/demo-files.json"))->of('textures') === [$tree]); +check('the list is not for the web server', (fileperms("{$data}/demo-files.json") & 0o007) === 0); +check('taking the offer back', $files->set('textures', $tree, false) === true && $files->of('textures') === []); +check('leaves the other kinds', $files->of('sounds') === [$song]); +$threw = false; +try { + $files->set('pixels', $tree, true); +} catch (InvalidArgumentException) { + $threw = true; +} +check('a kind that cannot be offered is refused', $threw); + +// --- Demo copies ----------------------------------------------------------------------------------- + +$demos = new ReleaseStore("{$data}/demos"); +check('without demo copies the list is empty', $demos->list() === []); +$store = new Store("{$data}/projects", 'admin'); +$platformer = $store->create('Platformer'); +$empty = $store->create('Empty project'); +$saved = $store->save($platformer['id'], 1, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => [['id' => 'a', 'file' => $song]]]); +$first = $demos->publish($saved); +$demos->publish($empty); +$list = $demos->list(); +check('the list names every copy, by name', array_column($list, 'name') === ['Empty project', 'Platformer'], $list); +check('with its token and when it was made', ReleaseStore::isValidToken($list[1]['token']) && $list[1]['publishedAt'] !== '', $list[1]); +check('and nothing of the project itself', !array_key_exists('project', $list[0]) && !array_key_exists('projectId', $list[0])); +check('the token loads the frozen copy', $demos->load($first['token'])['project']['assets'][0]['file'] === $song); + +$store->save($platformer['id'], 2, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => []]); +check('changing the project leaves the copy as it was', $demos->load($first['token'])['project']['assets'] !== []); +$again = $demos->publish($store->load($platformer['id'])); +check('updating it keeps the token and takes the new stand', $again['token'] === $first['token'] && $demos->load($first['token'])['project']['assets'] === []); + +// --- A demo copy uses its files -------------------------------------------------------------------- + +$store->save($platformer['id'], 3, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => [['id' => 'a', 'file' => $song]]]); +$demos->publish($store->load($platformer['id'])); +$store->save($platformer['id'], 4, ['id' => $platformer['id'], 'name' => 'Platformer', 'assets' => []]); +$uses = (new FileUsage($data))->of($song); +check('a file only the demo copy still names counts as used', count($uses) === 1 && $uses[0]['id'] === $platformer['id'], $uses); +check('and the refusal says so', str_contains(FileUsage::refusal($uses, 'admin', true), 'demo copies')); +$demos->takeDown($platformer['id']); +check('once the copy is gone, so is the use', (new FileUsage($data))->of($song) === []); + +exec('rm -rf ' . escapeshellarg($data)); + +echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/php/files.test.php b/tests/php/files.test.php index 4bbacac8..a9529099 100644 --- a/tests/php/files.test.php +++ b/tests/php/files.test.php @@ -127,17 +127,17 @@ $uses = [ ]; check( 'the refusal names the asker\'s projects only', - FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots and releases', + FileUsage::refusal($uses, 'anna', false) === 'Still used by “Garden” (2 projects) and a project of another account, counting snapshots, releases and demo copies', FileUsage::refusal($uses, 'anna', false), ); check( 'an administrator learns whose the others are', - FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots and releases', + FileUsage::refusal($uses, 'anna', true) === 'Still used by “Garden” (2 projects) and “Mine” of bob, counting snapshots, releases and demo copies', FileUsage::refusal($uses, 'anna', true), ); check( 'somebody with none of them learns only how many', - FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots and releases', + FileUsage::refusal($uses, 'carl', false) === 'Still used by 3 projects of other accounts, counting snapshots, releases and demo copies', FileUsage::refusal($uses, 'carl', false), ); diff --git a/tests/php/rights.test.php b/tests/php/rights.test.php index 9cce3cd2..0c35aab2 100644 --- a/tests/php/rights.test.php +++ b/tests/php/rights.test.php @@ -149,10 +149,13 @@ $saved = call('PUT', "projects/{$project['id']}", $alice, [ ]); $snapshot = call('POST', "projects/{$project['id']}/snapshots", $alice, ['name' => 'One'])['body']; $release = call('POST', "projects/{$project['id']}/release", $alice)['body']; +// An administrator makes a demo copy of it and offers her image to everybody (#428). +$demo = call('POST', "projects/{$project['id']}/demo", $admin)['body']; +$offered = call('PATCH', "textures/{$texture}", $admin, ['demo' => true])['status']; check( - 'alice has her project, image, sound, snapshot and release', - $saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token']), - [$saved['status'], $texture, $sound, $snapshot, $release], + 'alice has her project, image, sound, snapshot, release and a demo copy, her image is offered', + $saved['status'] === 200 && $texture !== '' && $sound !== '' && isset($snapshot['id'], $release['token'], $demo['token']) && $offered === 204, + [$saved['status'], $texture, $sound, $snapshot, $release, $demo, $offered], ); $folder = explode('/', $texture)[0]; $p = $project['id']; @@ -190,23 +193,32 @@ $matrix = [ ['POST', "projects/{$p}/release", null, 'POST projects/{}/release', [401, 200, 404, 200]], ['DELETE', "projects/{$p}/release", null, 'DELETE projects/{}/release', [401, null, 404, null]], ['GET', "releases/{$release['token']}", null, 'GET releases/{}', [200, 200, 200, 200]], + ['GET', 'demos', null, 'GET demos', [200, 200, 200, 200]], + ['GET', "demos/{$demo['token']}", null, 'GET demos/{}', [200, 200, 200, 200]], + ['GET', "projects/{$p}/demo", null, 'GET projects/{}/demo', [401, 403, 403, 200]], + ['POST', "projects/{$p}/demo", null, 'POST projects/{}/demo', [401, 403, 403, 200]], + ['DELETE', "projects/{$p}/demo", null, 'DELETE projects/{}/demo', [401, 403, 403, null]], - ['GET', 'textures', null, 'GET textures', [401, 200, 200, 200]], + ['GET', 'textures', null, 'GET textures', [200, 200, 200, 200]], ['POST', 'textures&name=b.png', $png, 'POST textures', [401, 201, 201, 201]], ['DELETE', "textures/{$texture}", null, 'DELETE textures/{}', [401, 409, 403, 409]], - ['GET', 'fonts', null, 'GET fonts', [401, 200, 200, 200]], + ['PATCH', "textures/{$texture}", ['demo' => true], 'PATCH textures/{}', [401, 403, 403, 204]], + ['GET', 'fonts', null, 'GET fonts', [200, 200, 200, 200]], ['POST', 'fonts&name=x.ttf', 'not a font at all', 'POST fonts', [401, 422, 422, 422]], ['DELETE', "fonts/{$missing}.ttf", null, 'DELETE fonts/{}', [401, 404, 403, 404]], - ['GET', 'sounds', null, 'GET sounds', [401, 200, 200, 200]], + ['PATCH', "fonts/{$missing}.ttf", ['demo' => true], 'PATCH fonts/{}', [401, 403, 403, 404]], + ['GET', 'sounds', null, 'GET sounds', [200, 200, 200, 200]], ['POST', 'sounds&name=b.wav', $wav(32, 'b'), 'POST sounds', [401, 201, 201, 201]], ['DELETE', "sounds/{$sound}", null, 'DELETE sounds/{}', [401, null, 403, null]], - ['GET', 'videos', null, 'GET videos', [401, 200, 200, 200]], + ['PATCH', "sounds/{$sound}", ['demo' => 'yes'], 'PATCH sounds/{}', [401, 403, 403, 422]], + ['GET', 'videos', null, 'GET videos', [200, 200, 200, 200]], ['POST', 'videos&name=x.mov&convert=0', 'not a video at all', 'POST videos', [401, 422, 422, 422]], ['POST', "videos/{$missing}.mp4/convert", null, 'POST videos/{}/convert', [401, 404, 403, 404]], ['DELETE', "videos/{$missing}.mp4", null, 'DELETE videos/{}', [401, 404, 403, 404]], + ['PATCH', "videos/{$missing}.mp4", ['demo' => true], 'PATCH videos/{}', [401, 403, 403, 404]], ['POST', 'pixels', 'not a cel', 'POST pixels', [401, 422, 422, 422]], - ['GET', 'plugins', null, 'GET plugins', [401, 200, 200, 200]], - ['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [401, 404, 404, 404]], + ['GET', 'plugins', null, 'GET plugins', [200, 200, 200, 200]], + ['GET', 'plugins/nothing.js', null, 'GET plugins/{}', [404, 404, 404, 404]], ]; $actors = ['signed out' => null, 'alice' => $alice, 'bob' => $bob, 'an administrator' => $admin]; @@ -238,6 +250,41 @@ $unasked = array_values(array_diff($documentedRoutes, $asked)); check('every route of the API is in the matrix', count($documentedRoutes) > 30 && $unasked === [], $unasked ?: count($documentedRoutes)); check('and the matrix asks about no route the API does not name', array_values(array_diff($asked, $documentedRoutes)) === [], array_values(array_diff($asked, $documentedRoutes))); +// --- Demo copies and demo material (#428) --------------------------------------------------------- + +$signedOut = call('GET', 'textures')['body']; +check( + 'signed out, the image list is the offered image alone, without who uploaded it', + count($signedOut) === 1 && $signedOut[0]['file'] === $texture && $signedOut[0]['demo'] === true + && $signedOut[0]['owner'] === null && $signedOut[0]['deletable'] === false, + $signedOut, +); +$bobs = call('GET', 'textures', $bob)['body']; +$hers = array_values(array_filter($bobs, static fn(array $item): bool => $item['file'] === $texture)); +check( + 'another account lists it beside its own, offered and not for it to delete', + count($hers) === 1 && $hers[0]['demo'] === true && $hers[0]['deletable'] === false && $hers[0]['owner'] === 'alice', + $hers, +); +check( + 'its owner sees it marked as well', + in_array(true, array_column(call('GET', 'textures', $alice)['body'], 'demo'), true), +); +check('the signed-out sound list is empty while nothing is offered', call('GET', 'sounds')['body'] === []); +call('PATCH', "sounds/{$sound}", $admin, ['demo' => true]); +$kept = call('DELETE', "sounds/{$sound}", $alice); +check( + 'its owner cannot delete a file while everybody is offered it', + $kept['status'] === 409 && str_contains((string) ($kept['body']['error'] ?? ''), 'demo material'), + $kept, +); +$demos = call('GET', 'demos')['body']; +check('the demo list names the copy, signed out', array_column($demos, 'name') === ['Alice'] && $demos[0]['token'] === $demo['token'], $demos); +$opened = call('GET', "demos/{$demo['token']}")['body']; +check('a demo copy goes out under its token, not the project id', ($opened['project']['id'] ?? null) === $demo['token'], $opened); +check('an unknown demo is not found', call('GET', 'demos/' . str_repeat('0', 32))['status'] === 404); +check('a demo cannot be changed through its open route', call('POST', "demos/{$demo['token']}", $admin)['status'] === 404); + // --- Paths, depth and unknown routes -------------------------------------------------------------- check('an unknown route is 401 signed out', call('GET', 'nothing')['status'] === 401); @@ -278,6 +325,11 @@ $big = ['id' => $p, 'name' => 'Alice', 'assets' => [], 'pad' => str_repeat('x', check('so is a save that would grow a project past it', call('PUT', "projects/{$p}", $alice, ['revision' => 2, 'project' => $big])['status'] === 413); check('a save that keeps it small goes through', call('PUT', "projects/{$p}", $alice, ['revision' => 2, 'project' => ['name' => 'Alice']])['status'] === 200); +// --- Deleting the project takes its copies along --------------------------------------------------- + +check('deleting the project', call('DELETE', "projects/{$p}", $alice)['status'] === 204); +check('takes its demo copy along', call('GET', "demos/{$demo['token']}")['status'] === 404 && call('GET', 'demos')['body'] === []); + // --- Sign-ins: the brake ------------------------------------------------------------------------- $gone = signIn('carol', 'password-carol'); diff --git a/tools/e2e/70-guest.mjs b/tools/e2e/70-guest.mjs new file mode 100644 index 00000000..6f349ac9 --- /dev/null +++ b/tools/e2e/70-guest.mjs @@ -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(); + } +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 4e0cfccf..feece9d5 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -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, ];