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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
57a97ba94f
commit
c17b2b5426
17 files changed
+994
-129
No files matched your search
@@ -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');
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export function publishPlugins(root: string, options?: { force?: boolean }): void;
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user