Laying out a graph by hand mostly means lining nodes up, so the magnet was switched on in nearly every session. Every graph tab now starts with it lit: Behavior, State machine, Shader, Particles, Sound FX, Instrument, the song's Routing and the Routing tab. - SnapToggle takes the state it starts in and lights its button to match. GRAPH_SNAPS_AT_FIRST sets it for the toggles and for GraphCanvas alike, so button and canvas cannot disagree. - Curve and Vector Art are no graphs and still start without it. - New hook __gm.graphSnapping(on?) reads every graph tab's magnet, or "mixed" where button and canvas differ, and switches them all. - The e2e harness reads the magnets after each navigate() and then switches them off, as it already sets the zoom transition to 0. Most checks drag a node to an exact spot. e2e 45 does the same itself, because the editor comes up there through the sign-in. - e2e 06 checks that all eight graph tabs started with the magnet on. The checks in 06, 26 and 65 that read it off now say they read what the harness left. Tests: - Unit snap-toggle: 4 tests, 1 new (lit at first, nothing asked of the target before a click). - e2e 06 (melektaus-atx): 150 checks, 1 new. e2e 34: 134 checks. - Gate (melektaus-atx, together with #390): 287 files, 4397 tests. - Pictures looked at: none, the state comes from the hook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
import { e2ePassword, E2E_USER } from '../lib/auth.mjs';
|
||
|
||
/**
|
||
* Signing in and out (#177), driven through the real screen: the rest of the suite gets its
|
||
* session handed to the browser as a cookie, so this is the only step that sees the sign-in
|
||
* screen at all.
|
||
*
|
||
* It runs last and hands the editor back the way it found it: signed in, with the fixture open.
|
||
*/
|
||
export async function login(t) {
|
||
const {
|
||
check,
|
||
cdp,
|
||
sleep,
|
||
evaluate,
|
||
json,
|
||
locate,
|
||
find,
|
||
press,
|
||
typeText,
|
||
mouse,
|
||
clickText,
|
||
shot,
|
||
baseUrl,
|
||
projectId,
|
||
} = t;
|
||
|
||
const password = await e2ePassword();
|
||
/**
|
||
* What the password is set to in between (#182). It is set back before the step ends; should a
|
||
* run die between the two, the account is repaired with
|
||
* `make-user.php e2e <the password in data/e2e-password.txt>`.
|
||
*/
|
||
const tempPassword = `${password}-tmp`;
|
||
|
||
/** Loads the page without handing the browser a session first. */
|
||
const openSignedOut = async () => {
|
||
await cdp.send('Network.clearBrowserCookies');
|
||
await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` });
|
||
await sleep(3000);
|
||
};
|
||
/**
|
||
* Waits for the page to be showing one of the two screens. Signing in and out each mean a round
|
||
* trip to the server – deadlines guessed for that are how a check becomes a coin toss.
|
||
*/
|
||
const showing =
|
||
// The editor counts as up once the project is in it, not when its hook appears: the hook is
|
||
// installed before the project is fetched, and in a chained run that fetch is long enough for
|
||
// a check in between to read the empty project the editor starts out with.
|
||
{
|
||
editor: `__gm?.state?.project?.id === ${JSON.stringify(projectId)}`,
|
||
login: 'Boolean(window.__gm?.login)',
|
||
};
|
||
const waitForScreen = async (which) => {
|
||
for (let waited = 0; waited < 15000; waited += 250) {
|
||
try {
|
||
if (await evaluate(`Boolean(${showing[which]})`)) return true;
|
||
} catch {
|
||
// "Inspected target navigated or closed": only `openSignedOut` navigates here, and asking
|
||
// across it is not a reason to give up.
|
||
}
|
||
await sleep(250);
|
||
}
|
||
return false;
|
||
};
|
||
|
||
await openSignedOut();
|
||
const screenHook = await json(
|
||
'({ login: Boolean(window.__gm?.login), editor: Boolean(window.__gm?.state) })',
|
||
);
|
||
check(
|
||
'without a session the sign-in screen stands where the editor would',
|
||
screenHook.login && !screenHook.editor && Boolean(await find('Sign in')),
|
||
screenHook,
|
||
);
|
||
|
||
// The user name alone: refused by the form itself, before anything is sent.
|
||
await typeText(E2E_USER);
|
||
await press('Enter');
|
||
await press('Enter');
|
||
check(
|
||
'an empty password is refused without asking the server',
|
||
(await json('__gm.login()')).message === 'Enter your password',
|
||
await json('__gm.login()'),
|
||
);
|
||
|
||
await typeText('not-the-password');
|
||
await press('Enter');
|
||
await sleep(600);
|
||
// The wording is checked here again since the firewall stopped turning every non-2xx into its own
|
||
// 418 page (measured 2026-09-20: `nockal.com` hands out the API's 401 and its body unchanged).
|
||
// Should something sit in between again, this is the check that says so.
|
||
check(
|
||
'a wrong password is refused and leaves the screen standing',
|
||
(await json('__gm.login()')).message === 'Wrong user name or password' &&
|
||
Boolean(await find('Sign in')) &&
|
||
!(await evaluate('Boolean(window.__gm?.state)')),
|
||
await json('__gm.login()'),
|
||
);
|
||
|
||
// Select-all first: the failed attempt left its text in the field.
|
||
await press('a', { ctrl: true });
|
||
await typeText(password);
|
||
const bullets = '•'.repeat(password.length);
|
||
check(
|
||
'the password is drawn as bullets, never as itself',
|
||
Boolean(await find(bullets)) && (await find(password)) === null,
|
||
{ bullets: bullets.length },
|
||
);
|
||
await shot('45-login');
|
||
|
||
await press('Enter');
|
||
await waitForScreen('editor');
|
||
const loaded = await json(
|
||
// The project's *name* is no check: locally the file carries the seeder's "E2E fixture", in CI
|
||
// the shell's key "fixture" (tools/ci/project-shells.mjs, and the seeder keeps the name it
|
||
// finds). The id and the seeded scene say the same thing and say it in both places.
|
||
`({ editor: Boolean(window.__gm?.state), project: __gm?.state?.project?.id ?? null, scene: __gm?.state?.project?.assets?.find((a) => a.id === 'scene1')?.name ?? null, title: document.title })`,
|
||
);
|
||
check(
|
||
'the right pair opens the editor, with the account in the window title',
|
||
loaded.editor &&
|
||
loaded.project === projectId &&
|
||
loaded.scene === 'Level 1' &&
|
||
loaded.title.endsWith(E2E_USER),
|
||
loaded,
|
||
);
|
||
|
||
// A mark on the window and on the canvas element: both survive a teardown and neither survives a
|
||
// reload, which is what signing out must not do (#177).
|
||
await evaluate("window.__stayedUp = 'mark'; document.querySelector('canvas').dataset.stayedUp = 'mark'");
|
||
|
||
// Pressed by hand, not with `clickText`: that one waits for a drawn frame afterwards, and the
|
||
// screen Logout brings up is built before that frame is due.
|
||
const pressAt = async (spot) => {
|
||
await mouse('mouseMoved', spot.x, spot.y);
|
||
await mouse('mousePressed', spot.x, spot.y);
|
||
await mouse('mouseReleased', spot.x, spot.y);
|
||
};
|
||
/** The account menu at the far right of the toolbar (#182), which holds Logout since. */
|
||
const openAccountMenu = async () => {
|
||
await pressAt(await locate('Account'));
|
||
await sleep(300);
|
||
};
|
||
|
||
await openAccountMenu();
|
||
const accountMenu = await json('({ rows: __gm.menuRows(), enabled: __gm.menuEnabled() })');
|
||
check(
|
||
'the account menu says who is signed in, as an entry that cannot be picked',
|
||
accountMenu.rows[0] === `Signed in as ${E2E_USER}` &&
|
||
accountMenu.enabled[0] === false &&
|
||
accountMenu.rows.includes('Change password…') &&
|
||
accountMenu.rows.includes('Logout'),
|
||
accountMenu,
|
||
);
|
||
|
||
await pressAt(await locate('Logout'));
|
||
await waitForScreen('login');
|
||
check(
|
||
'Logout ends the session and comes back to the sign-in screen',
|
||
Boolean(await find('Sign in')) && !(await evaluate('Boolean(window.__gm?.state)')),
|
||
await json('({ login: Boolean(window.__gm?.login), editor: Boolean(window.__gm?.state) })'),
|
||
);
|
||
|
||
const stayedUp = await json(
|
||
"({ window: window.__stayedUp ?? null, canvas: document.querySelector('canvas').dataset.stayedUp ?? null })",
|
||
);
|
||
check(
|
||
'signing out does not reload the page: the same canvas stays, only what it draws changes',
|
||
stayedUp.window === 'mark' && stayedUp.canvas === 'mark',
|
||
stayedUp,
|
||
);
|
||
|
||
// Back in, so the suite ends on a signed-in editor like every other step leaves it.
|
||
await locate('Sign in');
|
||
await typeText(E2E_USER);
|
||
await press('Enter');
|
||
await typeText(password);
|
||
await press('Enter');
|
||
await waitForScreen('editor');
|
||
const again = await json(
|
||
`({ project: __gm?.state?.project?.id ?? null, scene: __gm?.state?.project?.assets?.find((a) => a.id === 'scene1')?.name ?? null })`,
|
||
);
|
||
check(
|
||
'signing in again brings the editor back, with the project in it',
|
||
again.project === projectId && again.scene === 'Level 1',
|
||
again,
|
||
);
|
||
// The rebuilt editor has to be laid out, not sitting unlaid-out in the corner: `resize` lays out
|
||
// only when the size changed, and the sign-in screen has set `root` to the size of the window
|
||
// already (see `App.ts`). The account button is the far right of the toolbar, so it says so.
|
||
const placed = await json('({ account: __gm.locate("Account")?.x ?? null, width: window.innerWidth })');
|
||
check(
|
||
'the editor that came back is laid out across the window',
|
||
placed.account !== null && placed.account > placed.width / 2,
|
||
placed,
|
||
);
|
||
|
||
// Changing the password (#182), through the real dialog. It runs here, at the very end, because
|
||
// it takes the account's password away from `data/e2e-password.txt` for a moment.
|
||
const openPasswordDialog = async () => {
|
||
await openAccountMenu();
|
||
await clickText('Change password…');
|
||
await sleep(300);
|
||
};
|
||
/**
|
||
* Fills the three fields of a freshly opened dialog and submits. Freshly opened is the point: a
|
||
* refused attempt puts the caret into the field it is about, so typing on would fill the wrong
|
||
* one – every attempt here starts from Escape and a new dialog.
|
||
*/
|
||
const fillPasswords = async (current, next, repeat) => {
|
||
await typeText(current);
|
||
await press('Enter');
|
||
await typeText(next);
|
||
await press('Enter');
|
||
await typeText(repeat);
|
||
await press('Enter');
|
||
await sleep(800);
|
||
};
|
||
|
||
await openPasswordDialog();
|
||
check('the account menu opens the Change password dialog', (await json('__gm.passwordDialog()')) !== null, {
|
||
dialog: await json('__gm.passwordDialog()'),
|
||
});
|
||
|
||
await fillPasswords(password, 'short', 'short');
|
||
check(
|
||
'a new password below the minimum is refused without asking the server',
|
||
(await json('__gm.passwordDialog()'))?.message === 'The new password needs at least 8 characters',
|
||
await json('__gm.passwordDialog()'),
|
||
);
|
||
|
||
await press('Escape');
|
||
await openPasswordDialog();
|
||
await fillPasswords('not-the-password', tempPassword, tempPassword);
|
||
check(
|
||
'a wrong current password is refused and leaves the dialog standing',
|
||
(await json('__gm.passwordDialog()'))?.message === 'Wrong current password',
|
||
await json('__gm.passwordDialog()'),
|
||
);
|
||
await shot('45-password');
|
||
|
||
await press('Escape');
|
||
await openPasswordDialog();
|
||
await fillPasswords(password, tempPassword, tempPassword);
|
||
const changed = await json('({ dialog: __gm.passwordDialog(), notice: __gm.state.notice })');
|
||
check(
|
||
'the right current password changes it: the dialog closes and the toolbar says so',
|
||
changed.dialog === null && changed.notice.startsWith('Password changed'),
|
||
changed,
|
||
);
|
||
|
||
// The proof that the new password is the one that counts now: out through the menu, in with it.
|
||
await openAccountMenu();
|
||
await pressAt(await locate('Logout'));
|
||
await waitForScreen('login');
|
||
await typeText(E2E_USER);
|
||
await press('Enter');
|
||
await typeText(password);
|
||
await press('Enter');
|
||
await sleep(800);
|
||
check(
|
||
'the password that was replaced does not sign in any more',
|
||
(await json('__gm.login()')).message === 'Wrong user name or password' &&
|
||
!(await evaluate('Boolean(window.__gm?.state)')),
|
||
await json('__gm.login()'),
|
||
);
|
||
|
||
await press('a', { ctrl: true });
|
||
await typeText(tempPassword);
|
||
await press('Enter');
|
||
await waitForScreen('editor');
|
||
check(
|
||
'the new password signs in',
|
||
await evaluate(`__gm?.state?.project?.id === ${JSON.stringify(projectId)}`),
|
||
await json('({ project: __gm?.state?.project?.id ?? null })'),
|
||
);
|
||
|
||
// Back to the password the repo's tools know, so the next run starts where this one did.
|
||
await openPasswordDialog();
|
||
await fillPasswords(tempPassword, password, password);
|
||
const restored = await json('({ dialog: __gm.passwordDialog(), notice: __gm.state.notice })');
|
||
check(
|
||
'the account is handed back with the password the check tools carry',
|
||
restored.dialog === null && restored.notice.startsWith('Password changed'),
|
||
restored,
|
||
);
|
||
// The editor came up through the sign-in screen, not `navigate()`: its graphs snap (#391), and
|
||
// the steps after this one drag nodes to exact spots.
|
||
await evaluate('__gm.graphSnapping(false)');
|
||
}
|