Files
feuerwerk/tools/e2e/38-plugins.mjs
T
adminandClaude Opus 5.5 ceaa44e5b8
game-maker / gate (push) Successful in 2m50s
game-maker / suite (push) Successful in 8m57s
Add roles and a user management tab for admins (#180, #179)
Until now every account could do the same, and managing accounts
meant running make-user.php on the server. Accounts now carry a role,
and admins get a tab in the editor to create, rename, re-role, reset
and delete accounts. Both issues share the same server files, so they
land in one commit.

#180 Roles:
- Every account is admin or user. A missing or unknown role counts as
  user, on the server and in the client.
- auth/session and auth/login return {user, role}.
- Auth::requireAdmin() answers everyone else with 403.
- make-user.php takes the role as a third argument. Without it an
  existing account keeps its role, so a password reset from the CLI
  never demotes an admin.
- Admins see and save other users' projects like their own. Saving
  keeps the original owner.

#179 User management:
- The account menu gets "User management" (admins only) and "Plugins"
  (everyone). Both open the same closable tab, accounts on top and
  plugins below. The fixed Plugins tab is gone.
- Per account: Rename, Role, Password (no old password needed) and
  Delete with a confirmation. "New user..." creates one.
- Each account lists its projects with an Open button.
- Delete is refused while the account still owns projects.
- Renaming moves the projects along and ends every session of the
  account. transfer takes the same lock as save.
- On their own row an admin can only set the password. The last admin
  cannot be demoted or deleted. The server enforces both.
- The account rules live in AccountRules.php and accountRules.ts as
  pure functions, so they are testable without a server.

Findings:
- At 1280 px the Delete button reached past the column edge and was
  not clickable. The name column is flexible now, and module 49 checks
  the layout.
- The server lets an admin delete other users' projects, but the
  editor has no button for it yet. Left open in the issue.

Tests:
- ./check.sh: 148 files, 2214 tests, exit 0 (was 147 / 2204).
- New tests/php/accounts.test.php, 51 checks, plain PHP. CI runs it
  as its own step in the gate job, the host has no PHP.
- New e2e module 49 with 18 checks. CI creates the e2e-admin account
  in serve.sh. Modules 14, 38, 46 and the scaffold follow the new tab.
- e2e 1-16,37,38,45,46,49: all passed, 581 checks.
- Counter-checks: owner on save, admin view, default role and self
  delete in PHP fail 7, 5, 5 and 1 checks. Unchecked requireAdmin,
  a wider column and the caller as owner each fail exactly their
  e2e check.
- Check sum 1314 -> 1333 (+1 in 38, +18 in 49), computed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:59:04 +02:00

274 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { openTab, pickAssetType } from './lib/scaffold.mjs';
/**
* 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 } =
t;
await press('Escape');
await evaluate('__gm.state.select(null)');
// 38a. Loading runs after the project has booted, so on a fresh page it may still be under way.
let records = [];
for (let i = 0; i < 50 && records.length === 0; i++) {
records = await json('__gm.plugins.records()');
if (records.length === 0) await sleep(100);
}
const demo = records.find((r) => r.file === 'demo-plugin.js');
check(
'the demo plugin is loaded from data/plugins and active',
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'),
JSON.stringify(records.filter((r) => r.status !== 'active')),
);
// 38b. Its tab sits after the fixed ones and ahead of the open documents (#171) – in the row or
// behind », depending on the width.
const { order } = await json('__gm.tabRow()');
const at = order.indexOf('plugin:assets');
check('the plugin tab is in the tab row', at >= 0, JSON.stringify(order));
check(
'and it comes after the fixed tabs, ahead of any document',
JSON.stringify(order.slice(0, 2)) === JSON.stringify(['scene', 'game']) &&
order.slice(at + 1).every((tab) => tab === 'list' || tab.startsWith('asset:')),
JSON.stringify(order),
);
// 38c. One node per asset, titled by its name, in the order of the asset list.
await openTab(t, 'plugin:assets');
await settle();
const names = () => json('__gm.state.project.assets.map((a) => a.name)');
const board = () => json("__gm.plugins.board('assets')");
const titles = (b) => b.nodes.map((n) => n.title);
let shown = await board();
check(
'clicking the tab opens the node board',
(await json('__gm.tab()')) === 'plugin:assets' && shown?.visible === true,
JSON.stringify({ tab: await json('__gm.tab()'), visible: shown?.visible }),
);
const expected = await names();
check(
'the board shows every asset as a node titled with its name',
JSON.stringify(titles(shown)) === JSON.stringify(expected),
JSON.stringify({ nodes: titles(shown), assets: expected }),
);
// 38d. Pan, zoom and Fit. The framing to come back to is taken from Fit itself, not from the first
// show: in a chained run step 14 opened this tab in a narrowed column, and the board framed
// its nodes for that width then.
await t.click(shown.fit.x, shown.fit.y);
await settle();
shown = await board();
const fitted = { zoom: shown.zoom, pan: shown.pan };
// Any hit inside the board: the same name is also in the asset panel and the scene picker.
const inside = (p) =>
p.x > shown.rect.x &&
p.x < shown.rect.x + shown.rect.w &&
p.y > shown.rect.y &&
p.y < shown.rect.y + shown.rect.h;
const drawn = await json(`__gm.locateAll(${JSON.stringify(expected[0])})`);
check(
'after Fit the node titles are drawn inside the board',
drawn.some(inside),
JSON.stringify({ drawn, rect: shown.rect }),
);
await shot('38-plugin-tab');
const centre = { x: shown.rect.x + shown.rect.w / 2, y: shown.rect.y + shown.rect.h - 40 };
await drag(centre.x, centre.y, centre.x + 80, centre.y - 30);
shown = await board();
check(
'dragging on the board pans it',
Math.round(shown.pan.x - fitted.pan.x) === 80 && Math.round(shown.pan.y - fitted.pan.y) === -30,
JSON.stringify({ fitted, now: shown.pan }),
);
await wheel(centre.x, centre.y, -300);
await settle();
shown = await board();
check(
'the wheel zooms it',
shown.zoom > fitted.zoom,
JSON.stringify({ fitted: fitted.zoom, now: shown.zoom }),
);
await t.click(shown.fit.x, shown.fit.y);
await settle();
shown = await board();
check(
'Fit frames the nodes as before the pan and zoom',
Math.abs(shown.zoom - fitted.zoom) < 1e-6 &&
Math.abs(shown.pan.x - fitted.pan.x) < 1e-6 &&
Math.abs(shown.pan.y - fitted.pan.y) < 1e-6,
JSON.stringify({ fitted, now: { zoom: shown.zoom, pan: shown.pan } }),
);
// 38e. The board follows the asset list: a sprite created through the + menu, while the tab
// stays open, is a node at once.
// Through the menu's own entry: in a chained run an asset named "Sprite" is in the list already.
await clickText('+');
await pickAssetType(t, 'Sprite');
await settle();
const withNew = await names();
shown = await board();
check(
'creating an asset adds its node',
withNew.length === expected.length + 1 && JSON.stringify(titles(shown)) === JSON.stringify(withNew),
JSON.stringify({ nodes: titles(shown), assets: withNew }),
);
check('and the tab stays open', (await json('__gm.tab()')) === 'plugin:assets', await json('__gm.tab()'));
// 38f. Renaming shows under the new name.
const created = await json('(({ id, name }) => ({ id, name }))(__gm.state.project.assets.at(-1))');
const newId = created.id;
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(newId)}).name = 'Renamed by e2e'`,
);
shown = await board();
check(
'renaming an asset renames its node',
shown.nodes.find((n) => n.id === newId)?.title === 'Renamed by e2e',
JSON.stringify(shown.nodes.at(-1)),
);
await mutate(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(newId)}).name = ${JSON.stringify(created.name)}`,
);
// 38g. Undo takes the created asset away again, and its node with it.
await undo();
await settle();
shown = await board();
check(
'deleting the asset (undo of the create) removes its node',
(await names()).length === expected.length &&
JSON.stringify(titles(shown)) === JSON.stringify(expected) &&
!shown.nodes.some((n) => n.id === newId),
JSON.stringify({ nodes: titles(shown), assets: await names() }),
);
// 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 (#114, #119): the switch belongs to the project, and it works while the
// editor runs. Since #179 they are no fixed tab but reached through the menu, in a tab that
// closes like a document.
await t.clickToolbar('menu');
const entry = await t.locate('Plugins', true);
await t.click(entry.x, entry.y);
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(
'Menu → Plugins opens the list, with the demo plugin active, 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 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();
return settled(rows);
};
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 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 file = JSON.parse(listed).find((e) => e.file === 'demo-plugin.js');
check(
'the project says it is off, while the server still lists the file itself',
JSON.stringify(await json('__gm.state.project.plugins')) === '[]' &&
file !== undefined &&
file.active === undefined,
JSON.stringify({ plugins: await json('__gm.state.project.plugins'), file }),
);
// 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();
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 evaluate("__gm.closeDocument('management')");
await settle();
check(
'the tab closes like a document and leaves the row',
!(await json('__gm.tabRow()')).order.includes('management') &&
(await json('__gm.tab()')) !== 'management',
JSON.stringify({ tab: await json('__gm.tab()'), order: (await json('__gm.tabRow()')).order }),
);
// The switch is a change to the project; a step that navigates next would otherwise meet the
// autosave's "leave the page?" and wait on it.
await t.saved();
}