User management: signing in is required before anything is loaded, the sign-in screen is drawn in the same canvas (user name, password as bullets, Enter moves on and submits) and the editor is built only after it; the API gained auth/session, auth/login and auth/logout while every other route answers 401 without a session, projects carry an owner and a foreign one comes back as 404, accounts live in data/users.json with a password hash and sessions as files in data/sessions with a sliding 30 days, so a build under the open tab does not sign anybody out; Logout sits at the far right of the toolbar and, as asked in the issue, does not reload the page: Editor.dispose() takes the editor down while the canvas stays, which is why the autosave, the focus handlers, the window listeners of the tilemap panel, the shortcuts and the plugins all got a way back; the tools sign in as e2e (tools/lib/auth.mjs, password from GAME_MAKER_E2E_PASSWORD or the git-ignored data/e2e-password.txt) and the browser gets the same session by cookie, new e2e module 45 covers the screen, the wrong pair, the bullets, logout without a reload and signing in again, new unit tests for HttpAuthStore and for what a session leaves behind (#177)
game-maker / gate (push) Successful in 2m5s
game-maker / suite (push) Failing after 6m22s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 12:50:52 +02:00
1 parent 60ea56a2ce
commit 8e4bb6e1f9
38 files changed
+1530 -139

No files matched your search

+77
View File
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WrongCredentialsError } from '../../src/core/store/AuthStore';
import { HttpAuthStore } from '../../src/core/store/HttpAuthStore';
import { loginError, loginProblem } from '../../src/editor/auth/loginRules';
/** What the firewall in front of the live site answers instead of any non-2xx (measured for #177). */
const FIREWALL_418 = '<html><head><title>Status Code 418</title></head><body></body></html>';
function stubFetch(body: BodyInit | null, init: ResponseInit) {
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
vi.stubGlobal('fetch', mock);
return mock;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('HttpAuthStore', () => {
it('reads who is signed in, null included', async () => {
stubFetch(JSON.stringify({ user: 'e2e' }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toBe('e2e');
stubFetch(JSON.stringify({ user: null }), { status: 200 });
await expect(new HttpAuthStore('/api').session()).resolves.toBeNull();
});
it('sends the pair as JSON and answers with the name the server confirms', async () => {
const mock = stubFetch(JSON.stringify({ user: 'user' }), { status: 200 });
await expect(new HttpAuthStore('/api').logIn('user', 'secret')).resolves.toBe('user');
const [url, init] = mock.mock.calls[0]!;
expect(url as string).toContain('r=auth%2Flogin');
expect(init?.method).toBe('POST');
expect(JSON.parse(init?.body as string)).toEqual({ username: 'user', password: 'secret' });
});
it('tells a wrong pair apart from a server that is not there', async () => {
stubFetch(JSON.stringify({ error: 'Wrong user name or password' }), { status: 401 });
await expect(new HttpAuthStore('/api').logIn('user', 'nope')).rejects.toBeInstanceOf(
WrongCredentialsError,
);
stubFetch(JSON.stringify({ error: 'boom' }), { status: 500 });
const failure = new HttpAuthStore('/api').logIn('user', 'secret');
await expect(failure).rejects.not.toBeInstanceOf(WrongCredentialsError);
await expect(failure).rejects.toThrow(/boom \(500\)/);
});
it('does not mistake the firewall page for a wrong password', async () => {
stubFetch(FIREWALL_418, { status: 418 });
const failure = new HttpAuthStore('/api').logIn('user', 'secret');
await expect(failure).rejects.not.toBeInstanceOf(WrongCredentialsError);
await expect(failure).rejects.toThrow(/418/);
});
it('signs out without a body', async () => {
const mock = stubFetch(null, { status: 204 });
await expect(new HttpAuthStore('/api').logOut()).resolves.toBeUndefined();
expect(mock.mock.calls[0]?.[1]?.method).toBe('POST');
});
});
describe('the rules of the sign-in form', () => {
it('asks for what is missing, user name first', () => {
expect(loginProblem('', '')).toBe('Enter your user name');
expect(loginProblem(' ', 'secret')).toBe('Enter your user name');
expect(loginProblem('user', '')).toBe('Enter your password');
expect(loginProblem('user', 'secret')).toBeNull();
});
it('says a wrong pair plainly and names the cause of anything else', () => {
expect(loginError(new WrongCredentialsError(), true)).toBe('Wrong user name or password');
expect(loginError(new Error('Server unavailable (503)'), false)).toBe(
'Could not sign in: Server unavailable (503)',
);
});
});
+19
View File
@@ -349,6 +349,25 @@ describe('switching a plugin on and off', () => {
expect(manager.records[0]).toMatchObject({ status: 'active', meta: { name: 'Plugin demo' } });
});
it('signing out stops every plugin without touching the switches on the server', async () => {
const log: string[] = [];
const { manager, start, state, tabs, stored } = switchable({
'a.js': () => tabPlugin('a', log),
'b.js': () => tabPlugin('b', log),
'off.js': () => tabPlugin('off', log),
});
await start();
expect(tabs.size).toBe(3);
await manager.stopAll();
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
expect(tabs.size).toBe(0);
// The switches are the account's, not this editor's: nothing was written (#177).
expect(stored).toEqual([]);
state.execute(new CreateAssetCommand('sprite', 'Crate'));
expect(log).toEqual(['a: deactivate', 'b: deactivate', 'off: deactivate']);
});
it('switching off runs deactivate and takes the tab and the listeners with it', async () => {
const log: string[] = [];
const { manager, start, state, tabs, stored } = switchable({ 'demo.js': () => tabPlugin('demo', log) });
+147
View File
@@ -0,0 +1,147 @@
/**
* What has to let go when a session ends (#177): signing out takes the editor down while the page
* stays up, so everything it registered outside itself needs a way back – the autosave's timer and
* its `beforeunload` listener, and the handlers panels push into the focus manager. A handler left
* behind would go on typing into an editor nobody can see.
*
* The editor as a whole is torn down in the browser (e2e step 45); the pieces are here.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { createEmptyProject, type Project } from '../../src/core/model/Project';
import type { ProjectStore } from '../../src/core/store/ProjectStore';
import { attachAutosave } from '../../src/editor/autosave';
import { EditorState } from '../../src/editor/state';
/** Just enough `window` for the two modules under test; vitest runs these in node. */
function fakeWindow() {
let next = 1;
const timers = new Map<number, () => void>();
const listeners: [string, unknown][] = [];
return {
listeners,
/** Runs every timer that is still scheduled, newest first, as the browser eventually would. */
fire: () => {
for (const run of [...timers.values()]) run();
timers.clear();
},
setTimeout: (fn: () => void) => {
const id = next++;
timers.set(id, fn);
return id;
},
clearTimeout: (id: number) => void timers.delete(id),
addEventListener: (type: string, fn: unknown) => void listeners.push([type, fn]),
removeEventListener: (type: string, fn: unknown) => {
const index = listeners.findIndex(([t, f]) => t === type && f === fn);
if (index >= 0) listeners.splice(index, 1);
},
};
}
function savingStore(saves: Project[]): ProjectStore {
return {
list: () => Promise.resolve([]),
load: () => Promise.reject(new Error('not used')),
save: (project, revision) => {
saves.push(project);
return Promise.resolve(revision + 1);
},
create: () => Promise.reject(new Error('not used')),
delete: () => Promise.resolve(),
};
}
describe('autosave', () => {
let win: ReturnType<typeof fakeWindow>;
beforeEach(() => {
win = fakeWindow();
vi.stubGlobal('window', win);
});
afterEach(() => vi.unstubAllGlobals());
const dirtyState = () => {
const state = new EditorState();
state.loadProject(createEmptyProject('p1', 'Teardown'), 1);
state.execute(new CreateAssetCommand('sprite', 'Crate'));
return state;
};
it('writes what is pending when it is flushed', async () => {
const saves: Project[] = [];
const state = dirtyState();
const autosave = attachAutosave(state, savingStore(saves));
expect(state.saveStatus).toBe('dirty');
await autosave.flush();
expect(saves).toHaveLength(1);
expect(state.saveStatus).toBe('saved');
expect(state.revision).toBe(2);
});
it('stops for good: no pending save fires, and the page listener is gone', async () => {
const saves: Project[] = [];
const state = dirtyState();
const autosave = attachAutosave(state, savingStore(saves));
expect(win.listeners.map(([type]) => type)).toEqual(['beforeunload']);
autosave.stop();
win.fire();
await Promise.resolve();
expect(saves).toEqual([]);
expect(win.listeners).toEqual([]);
});
it('a change after the stop schedules nothing any more', async () => {
const saves: Project[] = [];
const state = dirtyState();
const autosave = attachAutosave(state, savingStore(saves));
await autosave.flush();
autosave.stop();
state.execute(new CreateAssetCommand('sprite', 'Box'));
win.fire();
await Promise.resolve();
expect(saves).toHaveLength(1);
});
});
describe('focus manager handlers', () => {
beforeEach(() => vi.stubGlobal('window', fakeWindow()));
afterEach(() => vi.unstubAllGlobals());
it('hands back the call that removes a global key handler', async () => {
const { FocusManager } = await import('../../src/ui/FocusManager');
const { Container } = await import('pixi.js');
const focus = new FocusManager(new Container());
const seen: string[] = [];
const remove = focus.onGlobalKey((event) => {
seen.push(event.key);
return false;
});
expect(focus.globalHandlers).toHaveLength(1);
remove();
expect(focus.globalHandlers).toEqual([]);
// A second call is harmless – a widget may be destroyed after the editor already let go.
remove();
expect(seen).toEqual([]);
});
it('hands back the call that removes a focus-change handler', async () => {
const { FocusManager } = await import('../../src/ui/FocusManager');
const { Container } = await import('pixi.js');
const focus = new FocusManager(new Container());
let runs = 0;
const remove = focus.onFocusChange(() => runs++);
const other = focus.onFocusChange(() => runs++);
expect(focus.changeHandlers).toHaveLength(2);
remove();
expect(focus.changeHandlers).toHaveLength(1);
other();
expect(focus.changeHandlers).toEqual([]);
expect(runs).toBe(0);
});
});