From 5ba796a2b61415151f79099351a0fd3a6e718e30 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 20 Sep 2026 14:24:27 +0200 Subject: [PATCH] Plugins belong to the project, and they load before it: the project file gained the field plugins: ["demo-plugin.js"] (default empty, no formatVersion bump), the Plugins tab's switch writes into it as an ordinary undo step (SetPluginActiveCommand) instead of into a server-wide plugins.json, and the start order is turned around - startPlugins only fetches the list of files, then editor.boot() runs and ProjectStore.load(id, prepare) hands the raw JSON through before it is deserialized, where pluginsOf(raw) reads the list and PluginManager.syncTo() activates exactly those; that is the point of the issue, because what a plugin registers has to stand there before normalizeGraph would throw it away as unknown (#126, #127), a project switch first stops what the new project does not name and then starts what it does, a file the server does not have is passed over silently, and after signing out the manager is dead so a late event cannot start anything in a torn-down session; on the server plugins.json, PluginStore::setActive, isActive, the route PUT ?r=plugins/{file} and the 403 for inactive modules are gone and GET ?r=plugins only answers {file,bytes,updatedAt}, which makes every plugin file fetchable - the consequence accepted in #117, written down in the wiki for #115 and #116; publishPlugins(root) only copies files now, the seeder switches the demo plugin on in the fixture project, and the Plugins tab carries a puzzle piece as its icon while only a plugin's own tab still goes without one (the knob and the socket are cubic curves, not arcs, which Pixi's parser draws as a spike after an L); 3 new e2e checks in module 38 and 5 new unit tests cover the project as the owner of the switch, its undo, the order, pluginsOf against crude input and an unknown file name (#119) Co-Authored-By: Claude Opus 5 --- README.md | 2 +- api/index.php | 22 +---- api/lib/PluginStore.php | 105 +++-------------------- src/core/commands/assetCommands.ts | 26 ++++++ src/core/model/Project.ts | 7 ++ src/core/store/HttpProjectStore.ts | 3 +- src/core/store/ProjectStore.ts | 7 +- src/core/store/projectPlugins.ts | 15 ++++ src/core/store/serialize.ts | 4 + src/editor/App.ts | 4 +- src/editor/Editor.ts | 19 +++-- src/editor/ProjectSession.ts | 8 +- src/editor/panels/assetIcons.ts | 4 + src/editor/panels/icons/plugins.svg | 11 +++ src/editor/plugins/PluginsPane.ts | 10 +-- src/editor/plugins/loader.ts | 88 ++++++++++++++----- src/editor/plugins/startPlugins.ts | 20 +++-- tests/core/holders.test.ts | 1 + tests/core/http-store.test.ts | 22 +++++ tests/core/serialize.test.ts | 16 ++++ tests/editor/plugins.test.ts | 126 +++++++++++++++++++++------- tests/ui/svg-icons.test.ts | 18 ++-- tools/e2e/14-overflow.mjs | 8 +- tools/e2e/38-plugins.mjs | 69 ++++++++++----- tools/lib/plugins.d.mts | 3 +- tools/lib/plugins.mjs | 57 +++---------- tools/seed-e2e.mjs | 15 ++-- vite.config.ts | 4 +- 28 files changed, 425 insertions(+), 269 deletions(-) create mode 100644 src/core/store/projectPlugins.ts create mode 100644 src/editor/panels/icons/plugins.svg diff --git a/README.md b/README.md index e52a0ae4..25dbc764 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1126 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1129 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/api/index.php b/api/index.php index 0eb30905..7c7bb0e6 100644 --- a/api/index.php +++ b/api/index.php @@ -35,9 +35,8 @@ declare(strict_types=1); * POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes} * DELETE ?r=sounds/{file} → 204 * - * GET ?r=plugins → [{file,bytes,updatedAt,active}], sorted by file name - * GET ?r=plugins/{file} → the module source as text/javascript | 403 while switched off - * PUT ?r=plugins/{file} {active} → {file,bytes,updatedAt,active} + * GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name + * GET ?r=plugins/{file} → the module source as text/javascript * * `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}`. @@ -299,17 +298,6 @@ function routePlugins(array $parts, string $method): never Response::json($store->list()); } - if ($method === 'PUT') { - $active = Response::body()['active'] ?? null; - if (!is_bool($active)) { - Response::error('active must be true or false', 422); - } - $entry = $store->setActive($parts[1], $active); - if ($entry === null) { - Response::error('Plugin not found', 404); - } - Response::json($entry); - } if ($method !== 'GET') { Response::error('Method not allowed', 405); } @@ -318,10 +306,8 @@ function routePlugins(array $parts, string $method): never if ($source === null) { Response::error('Plugin not found', 404); } - // Switched off means not run: the editor never asks for such a module, and nobody else may. - if (!$store->isActive($parts[1])) { - Response::error('Plugin not active', 403); - } + // Every plugin file is handed out: since #119 the project says which of them the editor runs, + // and the server has no way of knowing which project the asking tab has open. header('Content-Type: text/javascript; charset=utf-8'); header('X-Content-Type-Options: nosniff'); // A replaced file has to be the one the next editor start runs. diff --git a/api/lib/PluginStore.php b/api/lib/PluginStore.php index 43252687..17b4cdaa 100644 --- a/api/lib/PluginStore.php +++ b/api/lib/PluginStore.php @@ -2,16 +2,13 @@ declare(strict_types=1); /** - * The editor plugins in `data/plugins/`: one ES module per `.js` file. Whether one is switched on - * is kept beside them in `plugins.json` (`{"demo-plugin.js": {"active": true}}`), server-wide; a - * file the state does not name is off, so a plugin put there runs only once someone switched it - * on (#114). The folder is not served statically: a module is only ever handed out by its bare - * file name, and only while it is active, so no request can reach past it. + * The editor plugins in `data/plugins/`: one ES module per `.js` file. Whether one runs is written + * in the project that is open, not here (#119) – the server hands out every plugin it has and knows + * nothing about switches. The folder is still not served statically: a module is only ever handed + * out by its bare file name, so no request can reach past it. */ final class PluginStore { - public const STATE_FILE = 'plugins.json'; - public function __construct(private readonly string $dir) { } @@ -23,30 +20,28 @@ final class PluginStore } /** - * Sorted by file name, which is also the order the editor activates them in. Every plugin, - * active or not – the editor's Plugins tab is where one is switched on. + * Sorted by file name, which is also the order the editor activates them in. * - * @return list + * @return list */ public function list(): array { $files = is_dir($this->dir) ? scandir($this->dir) : []; - $state = $this->readState() ?? []; $out = []; foreach ($files === false ? [] : $files as $file) { if ($this->exists($file)) { - $out[] = $this->entry($file, $state); + $path = "{$this->dir}/{$file}"; + $out[] = [ + 'file' => $file, + 'bytes' => (int) filesize($path), + 'updatedAt' => (int) filemtime($path), + ]; } } usort($out, static fn (array $a, array $b): int => strcmp($a['file'], $b['file'])); return $out; } - public function isActive(string $file): bool - { - return $this->exists($file) && self::activeIn($this->readState() ?? [], $file); - } - /** The module's source, or null when there is no such plugin. */ public function read(string $file): ?string { @@ -57,84 +52,8 @@ final class PluginStore return $source === false ? null : $source; } - /** - * Switches a plugin on or off and returns its entry as `list` has it; null when there is no - * such plugin. The state file is rewritten whole under a lock, so two switches at once cannot - * lose each other's change. A state file that is not readable JSON is not overwritten: that - * would silently switch off everything it named. - * - * @return array{file: string, bytes: int, updatedAt: int, active: bool}|null - */ - public function setActive(string $file, bool $active): ?array - { - if (!$this->exists($file)) { - return null; - } - $lock = fopen("{$this->dir}/." . self::STATE_FILE . '.lock', 'c'); - if ($lock === false || !flock($lock, LOCK_EX)) { - throw new RuntimeException('Could not lock the plugin state'); - } - try { - $state = $this->readState(); - if ($state === null) { - throw new RuntimeException(self::STATE_FILE . ' is not readable JSON – left as it is'); - } - $state[$file] = ['active' => $active]; - ksort($state); - $path = "{$this->dir}/" . self::STATE_FILE; - $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; - $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; - if (file_put_contents($tmp, $json) === false || !rename($tmp, $path)) { - @unlink($tmp); - throw new RuntimeException('Could not write ' . self::STATE_FILE); - } - return $this->entry($file, $state); - } finally { - flock($lock, LOCK_UN); - fclose($lock); - } - } - private function exists(string $file): bool { return self::isValidName($file) && is_file("{$this->dir}/{$file}"); } - - /** - * The state as stored; an empty one when there is no file yet, null when there is one that - * cannot be read. - * - * @return array|null - */ - private function readState(): ?array - { - $path = "{$this->dir}/" . self::STATE_FILE; - if (!is_file($path)) { - return []; - } - $raw = file_get_contents($path); - $state = $raw === false ? null : json_decode($raw, true); - return is_array($state) ? $state : null; - } - - /** @param array $state */ - private static function activeIn(array $state, string $file): bool - { - return is_array($state[$file] ?? null) && ($state[$file]['active'] ?? false) === true; - } - - /** - * @param array $state - * @return array{file: string, bytes: int, updatedAt: int, active: bool} - */ - private function entry(string $file, array $state): array - { - $path = "{$this->dir}/{$file}"; - return [ - 'file' => $file, - 'bytes' => (int) filesize($path), - 'updatedAt' => (int) filemtime($path), - 'active' => self::activeIn($state, $file), - ]; - } } diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 3254b1d5..6457f010 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -354,6 +354,32 @@ export class SetStartSceneCommand implements Command { } } +/** + * Switches an editor plugin on or off for this project (#119). It is an undo step like any other + * change to the project: the switch changes the project, and the editor follows the project. + */ +export class SetPluginActiveCommand implements Command { + readonly label: string; + private previous: string[] = []; + + constructor( + private readonly file: string, + private readonly active: boolean, + ) { + this.label = `Switch plugin ${active ? 'on' : 'off'}`; + } + + execute(project: Project): void { + this.previous = [...project.plugins]; + const rest = this.previous.filter((f) => f !== this.file); + project.plugins = this.active ? [...rest, this.file].sort() : rest; + } + + undo(project: Project): void { + project.plugins = [...this.previous]; + } +} + export class SetActiveSceneCommand implements Command { readonly label = 'Switch scene'; private previous = ''; diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index e3ceedcb..fab4f02b 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -23,6 +23,12 @@ export interface Project { * the active scene; no format bump needed for that, since an older project reads as exactly that. */ startSceneId: string; + /** + * The plugin files (`data/plugins/*.js`) this project switches on (#119). The editor loads them + * before the project itself, so what they register is there before anything is read. A project + * written before there was such a field names none, which is also what a new one does. + */ + plugins: string[]; } export function createEmptyProject(id: string, name: string): Project { @@ -33,6 +39,7 @@ export function createEmptyProject(id: string, name: string): Project { assets: [], activeSceneId: '', startSceneId: '', + plugins: [], }; } diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts index 0ce9efc9..625b838d 100644 --- a/src/core/store/HttpProjectStore.ts +++ b/src/core/store/HttpProjectStore.ts @@ -15,11 +15,12 @@ export class HttpProjectStore implements ProjectStore { return this.request('GET', 'projects'); } - async load(id: string): Promise { + async load(id: string, prepare?: (raw: unknown) => Promise): Promise { const body = await this.request<{ revision: number; project: unknown }>( 'GET', `projects/${encodeURIComponent(id)}`, ); + await prepare?.(body.project); return { project: deserializeProject(body.project, id), revision: body.revision }; } diff --git a/src/core/store/ProjectStore.ts b/src/core/store/ProjectStore.ts index 692d4289..5b604217 100644 --- a/src/core/store/ProjectStore.ts +++ b/src/core/store/ProjectStore.ts @@ -22,7 +22,12 @@ export class StaleRevisionError extends Error { export interface ProjectStore { list(): Promise; - load(id: string): Promise; + /** + * `prepare` sees the untrusted JSON as it came, and is awaited before it is turned into a + * Project. That is where the editor plugins of this project are loaded (#119): what they register + * has to exist before the deserializer reads the file, or it would drop it as unknown. + */ + load(id: string, prepare?: (raw: unknown) => Promise): Promise; /** Returns the new revision; rejects with `StaleRevisionError` on conflict. */ save(project: Project, revision: number): Promise; create(name: string): Promise; diff --git a/src/core/store/projectPlugins.ts b/src/core/store/projectPlugins.ts new file mode 100644 index 00000000..66e8521c --- /dev/null +++ b/src/core/store/projectPlugins.ts @@ -0,0 +1,15 @@ +/** + * Which plugins a project switches on (#119), read straight off the untrusted JSON of the project + * file. This is deliberately its own module rather than a part of `deserializeProject`: the editor + * has to know the list *before* it deserializes anything, because a plugin may add node types and + * asset kinds that the deserializer would otherwise throw away as unknown. + */ +export function pluginsOf(raw: unknown): string[] { + const list = (raw as { plugins?: unknown } | null)?.plugins; + const files = (Array.isArray(list) ? list : []).filter( + (file): file is string => typeof file === 'string' && file !== '', + ); + // Sorted and without repeats: that is also the order the loader activates them in, so a project + // file written by hand cannot make one plugin come before another by listing it first. + return [...new Set(files)].sort(); +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index d43a8ffa..9ca451e8 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -28,6 +28,7 @@ import type { TilemapLayer, } from '../model/Asset'; import { parseCellKey } from '../model/tilemap'; +import { pluginsOf } from './projectPlugins'; import { decodeCells, encodeCells } from './tilemapCells'; import { isAssetType } from '../model/AssetTypes'; import { isFontFile } from '../model/font'; @@ -218,6 +219,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { assets, activeSceneId, startSceneId, + // The same reading the editor did before it loaded the plugins (#119), so the model says what + // is actually running. + plugins: pluginsOf(r), }; return project; } diff --git a/src/editor/App.ts b/src/editor/App.ts index 2a42be5f..10291fba 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -101,9 +101,11 @@ export async function startEditor(app: Application, root: VStack): Promise }, ); 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); await editor.boot(); loadPhysics().catch((error: unknown) => console.error('[physics] Rapier could not be loaded:', error)); - await startPlugins(API_ENDPOINT, state, editor); }; /** diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 30451580..2cf4c82e 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -2,6 +2,7 @@ import type { Application, Ticker } from 'pixi.js'; import type { Asset } from '../core/model/Asset'; import { findAsset, findObjectHolder } from '../core/model/Project'; import type { ProjectStore } from '../core/store/ProjectStore'; +import { pluginsOf } from '../core/store/projectPlugins'; import type { FontStore } from '../core/store/FontStore'; import type { SoundStore } from '../core/store/SoundStore'; import type { TextureStore } from '../core/store/TextureStore'; @@ -39,7 +40,7 @@ import { resolveList } from './list/slot'; import { AnimationEditor } from './animation/AnimationEditor'; import { TileEditor } from './tile/TileEditor'; import { AssetPanel } from './panels/AssetPanel'; -import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG } from './panels/assetIcons'; +import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG, PLUGINS_ICON_SVG } from './panels/assetIcons'; import { HierarchyPanel } from './panels/HierarchyPanel'; import { TilemapPanel } from './panels/TilemapPanel'; import { Toolbar } from './panels/Toolbar'; @@ -125,10 +126,11 @@ const TAB_LABELS: Record = { plugins: 'Plugins', }; -/** Icons of the fixed tabs (#176); the Plugins tab has none, as plugins have no picture of their own. */ +/** Icons of the fixed tabs (#176); a plugin's own tab has none, it brings no picture of its own. */ const FIXED_TAB_ICONS: Partial> = { scene: ASSET_ICON_SVG.scene, game: GAME_ICON_SVG, + plugins: PLUGINS_ICON_SVG, }; function isDocument(tab: StripTab): tab is DocumentId { @@ -247,9 +249,16 @@ export class Editor implements PlayHost { this.viewport.setEditorWorld(world); }, }); - this.projects = new ProjectSession(state, store, () => { - if (this.play.isPlaying) this.play.stop(); - }); + this.projects = new ProjectSession( + state, + store, + () => { + if (this.play.isPlaying) this.play.stop(); + }, + // 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.graphEditor.canvas.traceProvider = (assetId) => this.play.traceFor(assetId); this.graphEditor.canvas.timeProvider = () => this.play.time; diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 8545dc78..82d440de 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -16,6 +16,12 @@ export class ProjectSession { private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void, + /** + * 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 + * before the deserializer would drop it as unknown. + */ + private readonly prepare: (raw: unknown) => Promise = () => Promise.resolve(), ) {} /** Opens the project named in the URL, else the first one on the server, else a new one. */ @@ -37,7 +43,7 @@ export class ProjectSession { private async open(id: string): Promise { this.beforeSwitch(); - const loaded = await this.store.load(id); + const loaded = await this.store.load(id, this.prepare); this.state.loadProject(loaded.project, loaded.revision); const url = new URL(location.href); url.searchParams.set('project', id); diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index cfcbbb4d..a787cedc 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -12,6 +12,7 @@ import font from './icons/font.svg?raw'; import game from './icons/game.svg?raw'; import list from './icons/list.svg?raw'; import particles from './icons/particles.svg?raw'; +import plugins from './icons/plugins.svg?raw'; import rigidbody from './icons/rigidbody.svg?raw'; import scene from './icons/scene.svg?raw'; import shader from './icons/shader.svg?raw'; @@ -50,3 +51,6 @@ export const LIST_ICON_SVG = list; /** The Game tab's: it runs the project, so neither is it an asset. The Scene tab takes `scene`. */ export const GAME_ICON_SVG = game; + +/** The Plugins tab's: a puzzle piece, as a plugin is a piece added to the editor (#119). */ +export const PLUGINS_ICON_SVG = plugins; diff --git a/src/editor/panels/icons/plugins.svg b/src/editor/panels/icons/plugins.svg new file mode 100644 index 00000000..6b2f8d4b --- /dev/null +++ b/src/editor/panels/icons/plugins.svg @@ -0,0 +1,11 @@ + + + + diff --git a/src/editor/plugins/PluginsPane.ts b/src/editor/plugins/PluginsPane.ts index 8d105ea0..5bcd84ba 100644 --- a/src/editor/plugins/PluginsPane.ts +++ b/src/editor/plugins/PluginsPane.ts @@ -57,9 +57,9 @@ class PluginRow extends HStack { } /** - * The Plugins tab (#114): every file in `data/plugins/` with a switch. Switching one on loads and - * activates it there and then, switching it off takes its tabs and listeners away again – the - * state is the server's, so it holds for everyone who opens this editor. + * The Plugins tab (#114, #119): every file in `data/plugins/` with a switch. Switching one on loads + * and activates it there and then, switching it off takes its tabs and listeners away again – the + * switch is part of the open project, so it travels with it and undo takes it back. * * A plugin that is off is never loaded, which is why its row has no name until it has run once: * reading a name out of it would mean running it. @@ -144,9 +144,7 @@ export class PluginsPane extends VStack { this.empty.visible = nothing; if (nothing && this.manager) this.empty.setText('No plugins in data/plugins'); this.hint.setText( - nothing - ? '' - : 'Loaded from data/plugins, for everyone using this server. A new file stays off until switched on here.', + nothing ? '' : 'Loaded from data/plugins. The switch belongs to this project and is an undo step.', ); this.scroll.relayoutContent(); this.relayout(); diff --git a/src/editor/plugins/loader.ts b/src/editor/plugins/loader.ts index 9b0158ec..7821d8f7 100644 --- a/src/editor/plugins/loader.ts +++ b/src/editor/plugins/loader.ts @@ -20,11 +20,10 @@ export interface PluginRecord { error?: string; } -/** A file as the server lists it. */ +/** A file as the server lists it. Whether it runs is the project's business, not the server's (#119). */ export interface PluginEntry { file: string; updatedAt: number; - active: boolean; } /** @@ -58,8 +57,11 @@ export interface PluginLoaderDeps { load(file: string, updatedAt: number): Promise; /** A fresh API for a plugin that passed the checks; what it registers goes into `scope`. */ api(meta: PluginMeta, scope: PluginScope): unknown; - /** Stores the switch on the server; throws when it was not stored. */ - store(file: string, active: boolean): Promise; + /** + * Writes the switch into the project – an undo step (#119). Throws when it was not written, and + * then nothing is switched. + */ + store(file: string, active: boolean): void; /** One line for the user about a plugin that could not be switched. */ report(message: string): void; } @@ -73,6 +75,11 @@ function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** Both lists are sorted and without repeats when this is asked. */ +function sameFiles(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((file, i) => file === b[i]); +} + /** The module's `meta` as the loader needs it, or why it cannot be used. */ export function readMeta(module: unknown): PluginMeta | string { const exports = (typeof module === 'object' && module !== null ? module : {}) as Record; @@ -94,9 +101,11 @@ export function readMeta(module: unknown): PluginMeta | string { } /** - * The plugins of this editor and their switches (#113, #114). At start every switched-on plugin is - * activated, one after the other in file-name order, so that what an earlier one registers (a tab - * id, say) is settled before the next runs; one switched on later comes after all of them. + * The plugins of this editor and their switches (#113, #114, #119). Which ones run is the open + * project's business: `syncTo` takes the list out of the project file and makes the editor match + * it, before the project itself is read. Plugins are activated one after the other in file-name + * order, so that what an earlier one registers (a tab id, say) is settled before the next runs; + * one switched on later comes after all of them. * * Nothing a plugin does wrong stops the others or the editor: a file that does not load, a module * that is not a plugin, an API version this editor does not offer, a second plugin of an id that @@ -110,8 +119,12 @@ export class PluginManager { private readonly entries = new Map(); private readonly listeners = new Set<() => void>(); private queue: Promise = Promise.resolve(); + /** The files the project last asked for, whether or not each of them made it to `active`. */ + private wanted: string[] = []; /** False until `start` has been through the list – the tab says "Loading…" until then. */ started = false; + /** Set by `stopAll`: this editor is going away, and nothing may start a plugin in it again. */ + private stopped = false; constructor(private readonly deps: PluginLoaderDeps) {} @@ -125,14 +138,41 @@ export class PluginManager { return () => this.listeners.delete(listener); } + /** + * Takes in the files the server has, all of them switched off. Nothing is loaded yet: what runs + * is decided by the project, which is read next (#119). + */ start(entries: readonly PluginEntry[]): Promise { return this.enqueue(async () => { const sorted = [...entries].sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)); for (const entry of sorted) this.entries.set(entry.file, entry); this.list = sorted.map((entry) => ({ file: entry.file, status: 'inactive', meta: null })); - for (const entry of sorted) if (entry.active) await this.activate(entry.file); this.started = true; this.changed(); + await Promise.resolve(); + }); + } + + /** + * Makes the running plugins match what a project asks for: those it does not name are switched + * off first, so a plugin of the project being left has let go before the next one's plugins + * register anything. A file the server does not have is passed over – it has no row either. + * + * Called for every project the editor opens, before the project is deserialized, and again for + * every change of the list afterwards (the switch in the Plugins tab, and undo). Asking for what + * is running already does nothing at all. + */ + syncTo(files: readonly string[]): Promise { + const wanted = [...new Set(files)].sort(); + if (this.stopped || (this.started && sameFiles(wanted, this.wanted))) return this.queue; + this.wanted = wanted; + return this.enqueue(async () => { + // A plugin that failed counts as switched off here: it is no longer wanted, so its row goes + // back to plain "Off", and naming it again is a fresh attempt. + for (const record of this.list) + if (!wanted.includes(record.file) && record.status !== 'inactive') this.deactivate(record.file); + for (const file of wanted) if (this.record(file)?.status === 'inactive') await this.activate(file); + this.changed(); }); } @@ -144,28 +184,32 @@ export class PluginManager { stopAll(): Promise { return this.enqueue(async () => { for (const file of [...this.loaded.keys()]) this.deactivate(file); + this.wanted = []; this.started = false; + this.stopped = true; this.changed(); await Promise.resolve(); }); } - /** Switches a plugin on or off: stored on the server first, and only then applied. */ + /** + * The switch in the Plugins tab: written into the project first, and only then applied – so what + * runs and what the project says can never come apart, and undo takes both back. + */ setActive(file: string, active: boolean): Promise { - return this.enqueue(async () => { - const record = this.record(file); - if (!record || (record.status !== 'inactive') === active) return; - try { - await this.deps.store(file, active); - } catch (error) { - this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`); - this.changed(); - return; - } - if (active) await this.activate(file); - else this.deactivate(file); + // Against the list asked for, not against the record: a second click while the first switch is + // still running has to count, and the record only catches up when it is done. + if (this.stopped || !this.record(file) || this.wanted.includes(file) === active) return this.queue; + const rest = this.wanted.filter((f) => f !== file); + const next = active ? [...rest, file].sort() : rest; + try { + this.deps.store(file, active); + } catch (error) { + this.deps.report(`Plugin ${file} not switched ${active ? 'on' : 'off'}: ${messageOf(error)}`); this.changed(); - }); + return this.queue; + } + return this.syncTo(next); } private async activate(file: string): Promise { diff --git a/src/editor/plugins/startPlugins.ts b/src/editor/plugins/startPlugins.ts index 686b7fdf..2924d648 100644 --- a/src/editor/plugins/startPlugins.ts +++ b/src/editor/plugins/startPlugins.ts @@ -1,3 +1,4 @@ +import { SetPluginActiveCommand } from '../../core/commands/assetCommands'; import { apiRequest } from '../../core/store/http'; import type { Editor } from '../Editor'; import type { EditorState } from '../state'; @@ -5,10 +6,14 @@ import { type PluginEntry, PluginManager } from './loader'; import { createPluginApi, type PluginHost, scopedHost } from './pluginApi'; /** - * Loads what is switched on in `data/plugins/` into the running editor (#113, #114). A plugin is an + * Puts the plugins of `data/plugins/` at the editor's disposal (#113, #114, #119). A plugin is an * ES module with no imports of its own, fetched through the API route rather than as a static file * – so the folder cannot be used to reach anything but a plugin. A failure is a console line and a * notice; the editor goes on without that plugin. + * + * Nothing is loaded here: which plugins run is the open project's business, and this runs before + * the first project is even fetched. `Editor.boot()` hands the manager the list out of the raw + * project file, and from then on the project is followed – including undo of the switch. */ export async function startPlugins(endpoint: string, state: EditorState, editor: Editor): Promise { const report = (message: string) => { @@ -37,15 +42,18 @@ export async function startPlugins(endpoint: string, state: EditorState, editor: load: (file, updatedAt) => import(/* @vite-ignore */ `${endpoint}?r=plugins/${encodeURIComponent(file)}&v=${updatedAt}`), api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)), - store: async (file, active) => { - await apiRequest(endpoint, 'PUT', `plugins/${file}`, { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ active }), - }); + // A refused command (a notice of its own) must not leave the plugin running against what the + // project says, so it counts as "not stored". + store: (file, active) => { + if (!state.execute(new SetPluginActiveCommand(file, active))) + throw new Error('the project did not take the switch'); }, report, }); editor.plugins = manager; editor.pluginsPane.setManager(manager); + // Undo, redo and a command of any other kind can move the list: the project decides, so the + // editor follows it rather than remembering what it switched itself. + state.on('project', () => void manager.syncTo(state.project.plugins)); await manager.start(entries); } diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 06a0e1a3..7ebfb3f6 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -149,6 +149,7 @@ describe('CommandStack', () => { assets: [], activeSceneId: '', startSceneId: '', + plugins: [], }; const stack = new CommandStack(project); let failing = false; diff --git a/tests/core/http-store.test.ts b/tests/core/http-store.test.ts index 79512b9f..3ea9f970 100644 --- a/tests/core/http-store.test.ts +++ b/tests/core/http-store.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { HttpProjectStore } from '../../src/core/store/HttpProjectStore'; import { HttpFileStore } from '../../src/core/store/HttpFileStore'; +import { pluginsOf } from '../../src/core/store/projectPlugins'; import { StaleRevisionError } from '../../src/core/store/ProjectStore'; import { makeProject } from '../helpers'; @@ -39,6 +40,27 @@ describe('HttpProjectStore', () => { await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/); }); + /** + * The editor loads the project's plugins in `prepare` (#119). What they register – node types, + * asset kinds – has to be there before the JSON is read, or the deserializer drops it as unknown. + */ + it('runs prepare on the raw JSON and waits for it before deserializing', async () => { + stubFetch(JSON.stringify({ revision: 4, project: { id: 'p', name: 'P', plugins: ['demo.js'] } }), { + status: 200, + }); + let seen: unknown; + const loaded = await new HttpProjectStore('/api').load('p', async (raw) => { + seen = raw; + await Promise.resolve(); + // Whatever prepare does has to be part of what is read: this stands for the asset kinds and + // node types a plugin registers while it is being activated. + (raw as { assets: unknown[] }).assets = [{ id: 's', type: 'scene', name: 'Level' }]; + }); + expect(pluginsOf(seen)).toEqual(['demo.js']); + expect(loaded.project.plugins).toEqual(['demo.js']); + expect(loaded.project.assets.map((a) => a.name)).toEqual(['Level']); + }); + it('turns a 409 into a StaleRevisionError', async () => { stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 }); const { project } = makeProject(); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index 0ea1d606..fd08acb3 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -6,6 +6,7 @@ import { } from '../../src/core/commands/assetCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { pluginsOf } from '../../src/core/store/projectPlugins'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; @@ -17,6 +18,21 @@ describe('serialize', () => { expect(roundTrip(f.project)).toEqual(f.project); }); + /** + * Which plugins the project switches on (#119). The editor reads them off the raw file before it + * deserializes anything, so `pluginsOf` is what both sides use and both have to agree. + */ + it('reads the plugins of a project the same way before and after deserializing', () => { + const raw = { id: 'p', name: 'P', plugins: ['b.js', 'a.js', 'a.js', '', 7] }; + expect(pluginsOf(raw)).toEqual(['a.js', 'b.js']); + expect(deserializeProject(raw).plugins).toEqual(['a.js', 'b.js']); + // A project written before there was such a field, and anything that is not a list of names. + expect(pluginsOf({ id: 'p' })).toEqual([]); + expect(pluginsOf({ plugins: 'demo.js' })).toEqual([]); + expect(pluginsOf(null)).toEqual([]); + expect(deserializeProject({ id: 'p' }).plugins).toEqual([]); + }); + /** A behavior's own rows are not the common ones: coercing against those alone dropped the value. */ it('keeps a behavior running in the editor across a save', () => { const f = makeSpriteScene(); diff --git a/tests/editor/plugins.test.ts b/tests/editor/plugins.test.ts index 809c8165..d611122d 100644 --- a/tests/editor/plugins.test.ts +++ b/tests/editor/plugins.test.ts @@ -9,6 +9,7 @@ import { CreateAssetCommand, RenameAssetCommand, SetAssetPropertyCommand, + SetPluginActiveCommand, } from '../../src/core/commands/assetCommands'; import { createEmptyProject } from '../../src/core/model/Project'; import { zoomedAbout } from '../../src/editor/graph/graphView'; @@ -25,8 +26,8 @@ const plugin = (id: string, activate: (api: unknown) => unknown = () => {}, api: }); /** - * A manager on made-up modules. Every file is switched on unless `off` names it, which is what - * the server's `plugins.json` says in the real thing. + * A manager on made-up modules. `start` hands it the files the server has and then the list the + * open project switches on – everything but `off`, which stands for what the project leaves out. */ function loaderDeps(modules: Record unknown>, off: string[] = []) { const reports: string[] = []; @@ -43,15 +44,14 @@ function loaderDeps(modules: Record unknown>, off: string[] = []) activated.push(meta.id); return { for: meta.id }; }, - store: (file: string, active: boolean) => { - stored.push([file, active]); - return Promise.resolve(); - }, + store: (file: string, active: boolean) => void stored.push([file, active]), report: (message: string) => reports.push(message), }; const manager = new PluginManager(deps); - const start = (files = Object.keys(modules)) => - manager.start(files.map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) }))); + const start = async (files = Object.keys(modules)) => { + await manager.start(files.map((file, i) => ({ file, updatedAt: i + 1 }))); + await manager.syncTo(files.filter((file) => !off.includes(file))); + }; return { reports, activated, stored, loads, deps, manager, start }; } @@ -277,12 +277,16 @@ describe('the plugin API', () => { }); /** - * Switching plugins on and off (#114). The switch is the server's, so it is stored before it is - * applied; applying it means loading and activating, or running `deactivate` and taking back + * Switching plugins on and off (#114, #119). The switch belongs to the open project: it is written + * there first and applied afterwards, and the editor follows the project – so undo takes the switch + * back as well. Applying it means loading and activating, or running `deactivate` and taking back * everything the plugin registered through its API. */ describe('switching a plugin on and off', () => { - /** A manager over a real API and host, so what a plugin registers is really taken away again. */ + /** + * A manager over a real API, host and project, wired the way `startPlugins` wires it: the switch + * runs through a command, and what runs follows the project's list. + */ function switchable(modules: Record unknown>, off: string[] = []) { const { state, host, tabs } = editorHost(); const reports: string[] = []; @@ -297,16 +301,19 @@ describe('switching a plugin on and off', () => { }, api: (meta, scope) => createPluginApi(meta.id, scopedHost(host, scope)), store: (file, active) => { - if (refuse) return Promise.reject(new Error('Server error')); + if (refuse) throw new Error('Project is read-only'); stored.push([file, active]); - return Promise.resolve(); + state.execute(new SetPluginActiveCommand(file, active)); }, report: (message) => reports.push(message), }); - const start = () => - manager.start( - Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1, active: !off.includes(file) })), - ); + state.on('project', () => void manager.syncTo(state.project.plugins)); + const on = Object.keys(modules).filter((file) => !off.includes(file)); + const start = async () => { + state.project.plugins = [...on]; + await manager.start(Object.keys(modules).map((file, i) => ({ file, updatedAt: i + 1 }))); + await manager.syncTo(state.project.plugins); + }; return { state, tabs, manager, start, reports, stored, loads, refuse: (no: boolean) => (refuse = no) }; } @@ -320,7 +327,7 @@ describe('switching a plugin on and off', () => { deactivate: () => log.push(`${id}: deactivate`), }); - it('loads only what is switched on – a plugin that is off is never even fetched', async () => { + it('loads only what the project asks for – a plugin it leaves out is never even fetched', async () => { const { manager, start, loads } = switchable( { 'on.js': () => plugin('on'), 'off.js': () => plugin('off') }, ['off.js'], @@ -333,22 +340,78 @@ describe('switching a plugin on and off', () => { ]); }); - it('stores the switch first, then loads and activates the plugin', async () => { + it('writes the switch into the project first, then loads and activates the plugin', async () => { const log: string[] = []; - const { manager, start, stored, loads, tabs } = switchable({ 'demo.js': () => tabPlugin('demo', log) }, [ - 'demo.js', - ]); + const { manager, start, state, stored, loads, tabs } = switchable( + { 'demo.js': () => tabPlugin('demo', log) }, + ['demo.js'], + ); await start(); expect(tabs.size).toBe(0); await manager.setActive('demo.js', true); expect(stored).toEqual([['demo.js', true]]); + expect(state.project.plugins).toEqual(['demo.js']); // The file's own time goes into the load, so a replaced file is not taken from the module cache. expect(loads).toEqual([['demo.js', 1]]); expect(tabs.has('demo')).toBe(true); expect(manager.records[0]).toMatchObject({ status: 'active', meta: { name: 'Plugin demo' } }); }); + it('is an undo step: undoing the switch stops the plugin again', async () => { + const log: string[] = []; + const { manager, start, state, tabs } = switchable({ 'demo.js': () => tabPlugin('demo', log) }); + await start(); + expect(tabs.has('demo')).toBe(true); + + await manager.setActive('demo.js', false); + expect(state.project.plugins).toEqual([]); + expect(tabs.size).toBe(0); + + state.undo(); + await manager.syncTo(state.project.plugins); + expect(state.project.plugins).toEqual(['demo.js']); + expect(tabs.has('demo')).toBe(true); + expect(manager.records[0]?.status).toBe('active'); + + state.redo(); + await manager.syncTo(state.project.plugins); + expect(tabs.size).toBe(0); + expect(manager.records[0]?.status).toBe('inactive'); + }); + + it('follows a project change: the one it leaves goes before the one it opens arrives', async () => { + const log: string[] = []; + const { manager, start, tabs } = switchable( + { 'a.js': () => tabPlugin('a', log), 'b.js': () => tabPlugin('b', log) }, + ['b.js'], + ); + await start(); + expect([...tabs.keys()]).toEqual(['a']); + + // What a different project switches on, as the raw file says it before it is deserialized. + await manager.syncTo(['b.js']); + expect(log).toEqual(['a: deactivate']); + expect([...tabs.keys()]).toEqual(['b']); + expect(manager.records.map((r) => [r.file, r.status])).toEqual([ + ['a.js', 'inactive'], + ['b.js', 'active'], + ]); + + // Asking for what is running already does nothing at all. + await manager.syncTo(['b.js']); + expect(log).toEqual(['a: deactivate']); + }); + + it('passes over a file the server does not have, and says nothing about it', async () => { + const { manager, start, reports, loads } = switchable({ 'a.js': () => plugin('a') }, ['a.js']); + await start(); + await manager.syncTo(['gone.js']); + expect(loads).toEqual([]); + expect(reports).toEqual([]); + expect(manager.records.map((r) => r.file)).toEqual(['a.js']); + }); + it('signing out stops every plugin without touching the switches on the server', async () => { const log: string[] = []; const { manager, start, state, tabs, stored } = switchable({ @@ -362,8 +425,9 @@ describe('switching a plugin on and off', () => { await manager.stopAll(); expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']); expect(tabs.size).toBe(0); - // The switches are the account's, not this editor's: nothing was written (#177). + // The project keeps its list: signing out stops the plugins, it does not switch them off (#177). expect(stored).toEqual([]); + expect(state.project.plugins).toEqual(['a.js', 'b.js', 'off.js']); state.execute(new CreateAssetCommand('sprite', 'Crate')); expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']); }); @@ -391,14 +455,17 @@ describe('switching a plugin on and off', () => { expect(tabs.has('demo')).toBe(true); }); - it('leaves the plugin as it was when the server does not store the switch', async () => { - const { manager, start, reports, tabs, refuse } = switchable({ 'demo.js': () => tabPlugin('demo') }); + it('leaves the plugin as it was when the project does not take the switch', async () => { + const { manager, start, state, reports, tabs, refuse } = switchable({ + 'demo.js': () => tabPlugin('demo'), + }); await start(); refuse(true); await manager.setActive('demo.js', false); expect(manager.records[0]?.status).toBe('active'); + expect(state.project.plugins).toEqual(['demo.js']); expect(tabs.has('demo')).toBe(true); - expect(reports).toEqual(['Plugin demo.js not switched off: Server error']); + expect(reports).toEqual(['Plugin demo.js not switched off: Project is read-only']); }); it('takes back what a plugin registered before its activate threw', async () => { @@ -467,13 +534,14 @@ describe('switching a plugin on and off', () => { const { manager, start } = switchable({ 'demo.js': () => tabPlugin('demo') }); const seen = vi.fn(); const off = manager.onChange(seen); + // The list of files and the first sync against the project are two changes. await start(); - expect(seen).toHaveBeenCalledTimes(1); - await manager.setActive('demo.js', false); expect(seen).toHaveBeenCalledTimes(2); + await manager.setActive('demo.js', false); + expect(seen).toHaveBeenCalledTimes(3); off(); await manager.setActive('demo.js', true); - expect(seen).toHaveBeenCalledTimes(2); + expect(seen).toHaveBeenCalledTimes(3); }); it('runs a scope’s cleanups newest first, all of them even when one throws', () => { diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index a994420b..04b597e9 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; import { ASSET_TYPES } from '../../src/core/model/AssetTypes'; -import { ASSET_ICON_SVG, GAME_ICON_SVG, LIST_ICON_SVG } from '../../src/editor/panels/assetIcons'; +import { + ASSET_ICON_SVG, + GAME_ICON_SVG, + LIST_ICON_SVG, + PLUGINS_ICON_SVG, +} from '../../src/editor/panels/assetIcons'; import { tintSvg } from '../../src/ui/svg'; import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; @@ -33,6 +38,7 @@ describe('icon files', () => { ...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]), ['list tab', LIST_ICON_SVG], ['game tab', GAME_ICON_SVG], + ['plugins tab', PLUGINS_ICON_SVG], ]; // Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only @@ -46,14 +52,14 @@ describe('icon files', () => { }); it('draws the asset icons in the 16 units a list row and a button reserve', () => { - for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG]) + for (const svg of [...Object.values(ASSET_ICON_SVG), LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG]) expect(tags(svg)[0]!.attrs.viewBox).toBe('-8 -8 16 16'); }); - it('gives the List and Game tabs a picture no asset type has (#176)', () => { - expect(Object.values(ASSET_ICON_SVG)).not.toContain(LIST_ICON_SVG); - expect(Object.values(ASSET_ICON_SVG)).not.toContain(GAME_ICON_SVG); - expect(GAME_ICON_SVG).not.toBe(LIST_ICON_SVG); + it('gives the List, Game and Plugins tabs a picture no asset type has (#176, #119)', () => { + const tabs = [LIST_ICON_SVG, GAME_ICON_SVG, PLUGINS_ICON_SVG]; + for (const svg of tabs) expect(Object.values(ASSET_ICON_SVG)).not.toContain(svg); + expect(new Set(tabs).size).toBe(tabs.length); }); it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => { diff --git a/tools/e2e/14-overflow.mjs b/tools/e2e/14-overflow.mjs index e4c4d86f..5a78722b 100644 --- a/tools/e2e/14-overflow.mjs +++ b/tools/e2e/14-overflow.mjs @@ -85,12 +85,12 @@ export async function overflow(t) { .filter((b) => ['scene', 'game', 'plugins'].includes(b.tab)) .map((b) => [b.tab, b.icon]); check( - 'Scene and Game show an icon before their name, Plugins none', + 'Scene, Game and Plugins show an icon before their name', JSON.stringify(fixedIcons) === JSON.stringify([ ['scene', true], ['game', true], - ['plugins', false], + ['plugins', true], ]) && sceneInk >= 20, JSON.stringify({ fixedIcons, sceneInk }), ); @@ -141,8 +141,8 @@ export async function overflow(t) { await clickText('»'); const offered = (await json('__gm.menuRows()')).filter(Boolean); const offeredIcons = (await json('__gm.menuIcons()')).filter((icon) => icon !== null); - // Every hidden tab brings its icon along; only Plugins and a plugin's own tab have none (#176). - const withIcon = (tab) => tab !== 'plugins' && !tab.startsWith('plugin:'); + // Every hidden tab brings its icon along; only a plugin's own tab has none (#176, #119). + const withIcon = (tab) => !tab.startsWith('plugin:'); check( 'the overflow button offers every tab that had to go, each with the icon its tab shows', offered.length === narrow.hidden.length && diff --git a/tools/e2e/38-plugins.mjs b/tools/e2e/38-plugins.mjs index 61168dec..fe4d93b4 100644 --- a/tools/e2e/38-plugins.mjs +++ b/tools/e2e/38-plugins.mjs @@ -1,12 +1,12 @@ import { openTab, pickAssetType } from './lib/scaffold.mjs'; /** - * Plugins (#113, #114). The demo plugin in `plugins/` is copied to `data/plugins/` by the build and - * the seeder, switched on; the editor loads it at start-up through the API. What only a running - * editor can show: that the module really loads and activates, that its tab joins the row, that its - * node board draws one node per asset and follows the asset list – created through the + menu, - * renamed, and taken away again by undo – and that the switch in the Plugins tab stops and starts - * it while the editor runs. + * Plugins (#113, #114, #119). The demo plugin in `plugins/` is copied to `data/plugins/` by the + * build and the seeder; the fixture project switches it on, and the editor loads it before the + * project itself. What only a running editor can show: that the module really loads and activates, + * that its tab joins the row, that its node board draws one node per asset and follows the asset + * list – created through the + menu, renamed, and taken away again by undo – and that the switch in + * the Plugins tab stops and starts it while the editor runs, as an undo step of the project. */ export async function plugins(t) { const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, showTab, settle, undo } = @@ -27,6 +27,11 @@ export async function plugins(t) { demo?.status === 'active' && demo.meta?.id === 'demo' && demo.meta.api === 1, JSON.stringify(records), ); + check( + 'and it runs because the project switches it on, not the server', + JSON.stringify(await json('__gm.state.project.plugins')) === JSON.stringify(['demo-plugin.js']), + JSON.stringify(await json('__gm.state.project.plugins')), + ); check( 'no plugin failed', records.every((r) => r.status === 'active'), @@ -158,7 +163,8 @@ export async function plugins(t) { await showTab('scene'); check('switching to another tab hides the board', (await board()).visible === false); - // 38i. The Plugins tab (#114): the switch is the server's, and it works while the editor runs. + // 38i. The Plugins tab (#114, #119): the switch belongs to the project, and it works while the + // editor runs. await openTab(t, 'plugins'); await settle(); const pane = () => json('__gm.plugins.pane()'); @@ -173,17 +179,20 @@ export async function plugins(t) { JSON.stringify(rows), ); + const settled = async (was) => { + // Loading a module is a fetch, so the switch is applied a moment after the click. + for (let i = 0; i < 50; i++) { + const now = await pane(); + if (rowOf(now)?.status !== rowOf(was)?.status) return now; + await sleep(100); + } + return pane(); + }; const toggle = async () => { const at = await json('__gm.plugins.switchAt("demo-plugin.js")'); await t.click(at.x, at.y); await settle(); - // Switching goes to the server and back before it is applied. - for (let i = 0; i < 50; i++) { - const now = await pane(); - if (rowOf(now)?.status !== rowOf(rows)?.status) return now; - await sleep(100); - } - return pane(); + return settled(rows); }; rows = await toggle(); @@ -199,16 +208,36 @@ export async function plugins(t) { (await board()) === null, JSON.stringify(rowAfterOff), ); - // The switch is the server's, not this browser's: the list says so for everyone. (That a - // switched-off module is not handed out either is the API's own check, not one for here – the - // refusal never reaches the page, the site's firewall answers it with a page of its own.) + // The switch is the project's (#119): it stands in the project file and travels with it. The + // server's own list knows nothing about it any more – it only says which files there are. const listed = await evaluate( `fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`, ); + const entry = JSON.parse(listed).find((e) => e.file === 'demo-plugin.js'); check( - 'the server stores the switch: its list says the plugin is off', - JSON.parse(listed).find((e) => e.file === 'demo-plugin.js')?.active === false, - listed, + 'the project says it is off, while the server still lists the file itself', + JSON.stringify(await json('__gm.state.project.plugins')) === '[]' && + entry !== undefined && + entry.active === undefined, + JSON.stringify({ plugins: await json('__gm.state.project.plugins'), entry }), + ); + + // And because it is a change to the project, undo takes it back – plugin, tab and all. + await undo(); + rows = await settled(rows); + const afterUndo = await json('__gm.tabRow()'); + check( + 'undo switches it on again, as a step of the project', + JSON.stringify(await json('__gm.state.project.plugins')) === JSON.stringify(['demo-plugin.js']) && + rowOf(rows)?.status === 'active' && + [...afterUndo.shown.map((p) => p.tab), ...afterUndo.hidden].includes('plugin:assets'), + JSON.stringify({ plugins: await json('__gm.state.project.plugins'), row: rowOf(rows) }), + ); + rows = await toggle(); + check( + 'and clicking the switch off again stops it once more', + rowOf(rows)?.status === 'inactive' && JSON.stringify(await json('__gm.state.project.plugins')) === '[]', + JSON.stringify({ row: rowOf(rows), plugins: await json('__gm.state.project.plugins') }), ); rows = await toggle(); diff --git a/tools/lib/plugins.d.mts b/tools/lib/plugins.d.mts index ead758a5..312ca75f 100644 --- a/tools/lib/plugins.d.mts +++ b/tools/lib/plugins.d.mts @@ -1 +1,2 @@ -export function publishPlugins(root: string, options?: { force?: boolean }): void; +export const DEMO_PLUGIN: string; +export function publishPlugins(root: string): void; diff --git a/tools/lib/plugins.mjs b/tools/lib/plugins.mjs index 14de6c70..0879598c 100644 --- a/tools/lib/plugins.mjs +++ b/tools/lib/plugins.mjs @@ -1,64 +1,29 @@ -import { - chownSync, - copyFileSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - statSync, - writeFileSync, -} from 'node:fs'; +import { chownSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'; import { resolve } from 'node:path'; +/** The demo plugin the seed projects switch on (#119); `DEMO_PLUGIN` is its file name. */ +export const DEMO_PLUGIN = 'demo-plugin.js'; + /** * Puts the plugins that ship with the repository (`plugins/*.js`) where the editor loads them from, - * `data/plugins/`, and switches them on in `plugins.json` (#114) – a plugin put there by hand stays - * off until someone switches it on in the Plugins tab. Used by the build and by the e2e seeder. + * `data/plugins/`. Used by the build and by the e2e seeder. * - * `force` is the seeder's: the checks need the demo plugin on, whatever a person did with the - * switch. The build only switches on what the state does not name yet, so a plugin switched off - * on the server stays off across deployments. + * Which of them run is not decided here any more (#119): a plugin is switched on by the project + * that wants it, so the seeders name it in the projects they write. What lies in `data/plugins/` + * is only what is available. * - * The API writes `plugins.json` as the web server's user. When this runs as root (the build on the + * The API reads the folder as the web server's user. When this runs as root (the build on the * host), the folder and what it wrote are handed to the owner of `data/`, which is that user. */ -export function publishPlugins(root, { force = false } = {}) { +export function publishPlugins(root) { const target = resolve(root, 'data/plugins'); mkdirSync(target, { recursive: true }); const written = [target]; - const shipped = readdirSync(resolve(root, 'plugins')).filter((f) => f.endsWith('.js')); - for (const file of shipped) { + for (const file of readdirSync(resolve(root, 'plugins')).filter((f) => f.endsWith('.js'))) { copyFileSync(resolve(root, 'plugins', file), resolve(target, file)); written.push(resolve(target, file)); } - const statePath = resolve(target, 'plugins.json'); - let state = {}; - if (existsSync(statePath)) { - try { - state = JSON.parse(readFileSync(statePath, 'utf8')); - } catch { - console.warn(`${statePath} is not readable JSON – left as it is, no plugin switched on`); - state = null; - } - } - if (state && typeof state === 'object') { - let changed = false; - for (const file of shipped) { - if (!force && Object.hasOwn(state, file)) continue; - if (state[file]?.active === true) continue; - state[file] = { active: true }; - changed = true; - } - if (changed) { - const sorted = Object.fromEntries( - Object.entries(state).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), - ); - writeFileSync(statePath, `${JSON.stringify(sorted, null, 4)}\n`); - } - written.push(statePath); - } - if (process.getuid?.() === 0) { const { uid, gid } = statSync(resolve(root, 'data')); for (const path of written) if (existsSync(path)) chownSync(path, uid, gid); diff --git a/tools/seed-e2e.mjs b/tools/seed-e2e.mjs index a1403756..064958f5 100644 --- a/tools/seed-e2e.mjs +++ b/tools/seed-e2e.mjs @@ -9,19 +9,19 @@ import { fileURLToPath } from 'node:url'; import { sineWav } from './lib/audio.mjs'; import { edge, node, port, sceneObject, variable } from './lib/builders.mjs'; -import { publishPlugins } from './lib/plugins.mjs'; +import { DEMO_PLUGIN, publishPlugins } from './lib/plugins.mjs'; import { PROJECTS, cliArgs, uploadFont, uploadSound, writeProject } from './lib/projects.mjs'; import { adjustColorsShader } from './lib/shaders.mjs'; const { projectId, baseUrl } = cliArgs('seed-e2e.mjs', PROJECTS.fixture); /** - * The plugins that ship with the repository, where the editor loads them from and switched on – - * the build does the same, this makes a run after a plain checkout independent of it, and puts the - * demo plugin back on after a step (or a person) switched it off. The server is this checkout - * (locally and in CI), so a file copy is enough. + * The plugins that ship with the repository, where the editor loads them from – the build does the + * same, this makes a run after a plain checkout independent of it. That the demo plugin is also + * switched on is part of the fixture project below (#119). The server is this checkout (locally + * and in CI), so a file copy is enough. */ -publishPlugins(fileURLToPath(new URL('..', import.meta.url)), { force: true }); +publishPlugins(fileURLToPath(new URL('..', import.meta.url))); /** * Two typefaces for the font step to pick and to drop. They are put on the server but not made @@ -84,6 +84,9 @@ const project = { id: projectId, name: 'E2E fixture', activeSceneId: 'scene1', + // The demo plugin is switched on by the project since #119, not by a state file on the server – + // this is what step 38 finds running. + plugins: [DEMO_PLUGIN], assets: [ { id: 'scene1', diff --git a/vite.config.ts b/vite.config.ts index 563e1f08..5ad33c0f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,8 +25,8 @@ function publishIndexHtml(): Plugin { /** * Editor plugins are loaded from `data/plugins/`, which is not in the repository; the ones that * ship with it live in `plugins/` and are put there on every build. A file of the same name is - * replaced, anything else in the folder is left alone. A shipped plugin is switched on unless the - * server's state says otherwise (see `tools/lib/plugins.mjs`). + * replaced, anything else in the folder is left alone. Whether one runs is the open project's + * business, not the build's (#119). */ function shipPlugins(): Plugin { return {