Files
feuerwerk/tools/e2e/02-inspector.mjs
T
admin 7e5003b590
game-maker / suite (push) Successful in 6m48s
A test that waits for a deadline somebody guessed is waiting for the wrong thing, so it waits for the editor (#93)
The suite spent 88% of its step time asleep: 564 s of steps, 496 s of
them in sleep(n) - fixed spans estimated by hand and then copied from
step to step. That is why it was slow, and why it flickered.

The signal is afterFrames(count, timeout) in ui/render.ts, exposed as
__gm.afterFrames(). It wakes the loop itself, because an editor that
draws nothing would never redeem a promise of a frame, and it gives up
after a timeout instead of hanging the run. settle() in the harness
waits two drawn frames (~32 ms) instead of 150-300 ms and falls back to
sleep(150) when the hook is missing (an old build): silently not
waiting would make every later check a coin toss.

Every action goes through it now - click, doubleClick, wheel, drag,
dragAsset, press, typeText, select, mutate, undo, redo - and with that
about 550 sleep() calls behind those actions became redundant, helpers
that are nothing but a mutate in a coat included. What a frame cannot
answer got its own condition instead: waitUntil(expr), play()/stop()
waiting on __gm.state.mode rather than on 300 ms, saved() for the
autosave (the two hand-written polling loops sampled at 300 ms for
something that arrives after a second), and showTab() as a real tool -
47 places called evaluate("__gm.showTab(...)") raw, and 24 of them
waited for nothing at all and read the old panel.

564 s -> 243 s of step time, the waiting share 88% -> 76%. Three kinds
of wait were kept, and each one bit when it was taken away: loading
bytes (textures, fonts), game time that a repaint cannot stand in for
(time scale, shader and particle clocks, the live values a running
graph carries), and the inertia the scroll step measures on purpose.

Two things the new speed uncovered, both real: 25-animation read the
frame menu unfiltered and had frozen an incomplete list (the CI failure
on c990dbc), and 33-render-on-change measured idleness while the
autosave of the step before was still painting - it waits for
saveStatus to leave dirty/saving now, so it measures that nothing
happens any more instead of that it waited long enough.
2026-09-16 08:08:10 +02:00

180 lines
7.5 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.
/**
* Selecting, renaming and scrubbing in the inspector, dragging in the viewport, autosave to
* the server, and undo/redo of all of it.
*/
export async function inspector(t) {
const {
check,
sleep,
json,
locate,
shot,
click,
doubleClick,
clickText,
drag,
press,
typeText,
mutate,
undo,
redo,
state,
object,
api,
sceneOf,
projectId,
} = t;
// 2a. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = await state();
check(
'hierarchy click selects object',
s.selection?.kind === 'object' && s.selection.objectId === 'o2',
JSON.stringify(s.selection),
);
await shot('02-object-selected');
// 2b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
// crate overrides width and color, the camera and the red crate's subtree nothing.
const GOLD = '#f2c84b';
const PLAIN = '#e6e8ee';
const rowColor = (id) => json(`__gm.hierarchyRowColor('${id}')`);
check('an overridden object is marked gold in the hierarchy', (await rowColor('o2')) === GOLD);
check('an untouched object is not', (await rowColor('o3')) === PLAIN, await rowColor('o3'));
check('nor is a parent whose subtree is untouched', (await rowColor('o1')) === PLAIN);
check(
'the overridden field is gold, an inherited one is not',
(await json(`__gm.textColor('Width')`)) === GOLD &&
(await json(`__gm.textColor('Height')`)) === '#8a90a0',
JSON.stringify(await json(`[__gm.textColor('Width'), __gm.textColor('Height')]`)),
);
// The mark also travels up: with the crate's own overrides taken away the row must stay gold
// for the sake of its collider child, which keeps one. Put back right after, byte for byte.
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = {}`);
check('a parent carries the mark of a deviating child', (await rowColor('o2')) === GOLD);
await mutate(
`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = { width: 140, color: 0x4488ff }`,
);
// 2c. Rename via inspector: the name field sits right of the "Name" label.
const nameLabel = await locate('Name');
await click(nameLabel.x + 160, nameLabel.y);
await press('a', { ctrl: true });
await typeText('Renamed crate');
await shot('03-typing');
await press('Enter');
check(
'typing + Enter renamed the object',
(await object('o2')).name === 'Renamed crate',
JSON.stringify((await object('o2')).name),
);
await shot('04-renamed');
// 2d. A double-click into a text field selects its content, so the next keystroke replaces the
// whole name instead of inserting into it. Renamed back, since later checks go by the name.
await doubleClick(nameLabel.x + 160, nameLabel.y);
await typeText('Twice');
await press('Enter');
check(
'double-clicking a text field selects all – typing replaces the name',
(await object('o2')).name === 'Twice',
JSON.stringify((await object('o2')).name),
);
// Undone rather than typed back: check 2h further down counts its undos, so this must not
// leave a step on the stack.
await undo();
check(
'undo takes that rename back',
(await object('o2')).name === 'Renamed crate',
JSON.stringify((await object('o2')).name),
);
// 2e. Scrub the X field (right of the "X" label) by 100 px.
const xLabel = await locate('X');
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
const xNow = (await object('o2')).position.x;
check('scrubbing X moved the object', xNow > 120, `x=${xNow}`);
await shot('05-scrubbed');
// 2f. Type into X, then click straight into the empty viewport: the typed value must be
// committed (not reverted) even though the click deselects and rebuilds the inspector.
const center = await json('__gm.viewportCenter()');
// Searched, not guessed: with the console panel below, a fixed offset from the centre can
// land on an object instead of empty space.
const emptySpot = await json('__gm.emptySpot()');
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
await typeText('300');
await click(emptySpot.x, emptySpot.y);
const xTyped = (await object('o2')).position.x;
s = await state();
check(
'typed X survives clicking into the viewport',
xTyped === 300 && s.selection === null,
`x=${xTyped} selection=${JSON.stringify(s.selection)}`,
);
// 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// on the origin, so compute its screen position from the viewport centre.
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
const o1Now = (await object('o1')).position;
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
await shot('06-dragged');
await sleep(1800);
const saved = await api(`projects/${projectId}`);
const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2');
check('rename persisted', renamed.name === 'Renamed crate', renamed.name);
check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position));
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
check('save status is saved', (await state()).save === 'saved');
// 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
await click(emptySpot.x, emptySpot.y); // click empty viewport → deselect, drop focus
for (let i = 0; i < 4; i++) await undo();
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
check(
'undo reverted drag, typed X, scrub and rename',
afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate',
JSON.stringify(afterUndo),
);
await redo();
check('redo restored the rename', (await object('o2')).name === 'Renamed crate');
await shot('07-undo-redo');
// 2i. A change to the *structure* of what is shown normally rebuilds the inspector, which
// destroys the widget being typed in. While a field in here has focus the rebuild has to
// wait – otherwise a field whose value decides the structure cannot be typed into at all.
await clickText('Renamed crate');
const xRow = await locate('X');
await click(xRow.x + 160, xRow.y);
// The value it already has: Escape cancels the widget but not the preview it wrote, so
// typing anything else here would leave that value behind for the later steps.
await typeText('120');
const before = await json('__gm.inspectorInfo()');
// The parent is part of the object's structure signature, so this asks for a rebuild.
await mutate("__gm.state.activeScene.objects.find(o => o.id === 'o2').parentId = 'o1'");
const during = await json('__gm.inspectorInfo()');
const stillTyping = await json(
'({ tag: document.activeElement?.tagName, value: document.activeElement?.value })',
);
check(
'a structure change while typing does not rebuild the inspector',
during.rebuilds === before.rebuilds && during.postponed,
JSON.stringify(during),
);
check(
'the field being typed in survives it',
stillTyping.tag === 'INPUT' && stillTyping.value === '120',
JSON.stringify(stillTyping),
);
await press('Escape');
const after = await json('__gm.inspectorInfo()');
check(
'leaving the field runs the postponed rebuild',
after.rebuilds === before.rebuilds + 1 && !after.postponed,
JSON.stringify(after),
);
await mutate("__gm.state.activeScene.objects.find(o => o.id === 'o2').parentId = ''");
}