Plugins are switched on and off in a Plugins tab, the server keeps the state in data/plugins/plugins.json, and switched-off plugins are never delivered (#114)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m43s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 20:15:51 +02:00
1 parent 57a97ba94f
commit c17b2b5426
17 files changed
+994 -129

No files matched your search

+77 -5
View File
@@ -1,11 +1,12 @@
import { openTab, pickAssetType } from './lib/scaffold.mjs';
/**
* Plugins (#113). The demo plugin in `plugins/` is copied to `data/plugins/` by the build and the
* seeder; 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, and 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.
* 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.
*/
export async function plugins(t) {
const { check, sleep, json, evaluate, mutate, clickText, drag, wheel, press, shot, showTab, settle, undo } =
@@ -154,4 +155,75 @@ export async function plugins(t) {
// 38h. Another tab hides the board; the plugin's pane is a tab like any other.
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.
await openTab(t, 'plugins');
await settle();
const pane = () => json('__gm.plugins.pane()');
let rows = await pane();
const rowOf = (shown) => shown.rows.find((r) => r.file === 'demo-plugin.js');
check(
'the Plugins tab lists the demo plugin as active, with its name and its switch on',
rows.visible === true &&
rowOf(rows)?.status === 'active' &&
rowOf(rows)?.name === 'Demo plugin' &&
rowOf(rows)?.checked === true,
JSON.stringify(rows),
);
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();
};
rows = await toggle();
const rowAfterOff = await json('__gm.tabRow()');
check(
'clicking the switch off stops the plugin',
rowOf(rows)?.status === 'inactive' && rowOf(rows)?.checked === false,
JSON.stringify(rows),
);
check(
'and its tab is gone from the row, with nothing left of its board',
![...rowAfterOff.shown.map((p) => p.tab), ...rowAfterOff.hidden].includes('plugin:assets') &&
(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.)
const listed = await evaluate(
`fetch('/game-maker/api/index.php?r=plugins').then(async (r) => JSON.stringify(await r.json()))`,
);
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,
);
rows = await toggle();
await settle();
const rowAfterOn = await json('__gm.tabRow()');
shown = await board();
check(
'switching it on again activates it there and then',
rowOf(rows)?.status === 'active' &&
[...rowAfterOn.shown.map((p) => p.tab), ...rowAfterOn.hidden].includes('plugin:assets'),
JSON.stringify({ row: rowOf(rows), tabs: rowAfterOn }),
);
check(
'and its board is back with a node per asset',
JSON.stringify(titles(shown)) === JSON.stringify(await names()),
JSON.stringify({ nodes: titles(shown), assets: await names() }),
);
await shot('39-plugins-tab');
await showTab('scene');
}
+1
View File
@@ -112,6 +112,7 @@ export const TAB_LABELS = {
container: 'Container',
list: 'List',
particles: 'Particles',
plugins: 'Plugins',
// Added at start-up by the demo plugin (plugins/demo-plugin.js), after every built-in tab.
'plugin:assets': 'Assets',
};
+1
View File
@@ -0,0 +1 @@
export function publishPlugins(root: string, options?: { force?: boolean }): void;
+66
View File
@@ -0,0 +1,66 @@
import {
chownSync,
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from 'node:fs';
import { resolve } from 'node:path';
/**
* 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.
*
* `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.
*
* The API writes `plugins.json` 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 } = {}) {
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) {
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);
}
}
+5 -7
View File
@@ -6,24 +6,22 @@
*
* Usage: node tools/seed-e2e.mjs [projectId] [baseUrl]
*/
import { copyFileSync, mkdirSync, readdirSync } from 'node:fs';
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 { 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 – the build does the
* same, this makes a run after a plain checkout independent of it. The server is this checkout
* 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.
*/
const ROOT = fileURLToPath(new URL('..', import.meta.url));
mkdirSync(`${ROOT}data/plugins`, { recursive: true });
for (const file of readdirSync(`${ROOT}plugins`).filter((f) => f.endsWith('.js')))
copyFileSync(`${ROOT}plugins/${file}`, `${ROOT}data/plugins/${file}`);
publishPlugins(fileURLToPath(new URL('..', import.meta.url)), { force: true });
/**
* Two typefaces for the font step to pick and to drop. They are put on the server but not made