Files
galaxy-game/ui/frontend/tests/e2e/storage-keypair-persistence.spec.ts
T
Ilia Denisov 51902b995f
Tests · UI / test (push) Successful in 1m55s
Tests · UI / test (pull_request) Successful in 2m4s
fix(ui-map): render-on-demand + drop pan inertia to stop the Safari fog freeze
The Phase 29 visibility fog ("visible hyperspace") froze the whole UI on
large reports in Safari while staying smooth in Firefox. Root cause: the
fog is a layered overpaint (torus mode = 9 world-sized rects + 9xN
near-world-sized opaque circles, ~270 fills for KNNTS041) and Pixi's
continuous auto-render loop re-rasterised all of it every frame, even
while idle. Safari's WebGPU backend cannot sustain that fillrate, so the
main thread/compositor starved and the entire UI froze.

Stage 1 (vector-preserving, no rasterisation):

- Stop Pixi's auto-render loop (app.stop()) and paint on demand via a
  single Ticker.shared flush gated on viewport.dirty (camera) plus an
  internal requestRender() from every content mutation (fog / hide-set /
  extras / wrap mode / resize / pick overlay). An idle map now does zero
  GPU work per frame; plain hover paints nothing.
- Remove the decelerate (drag-inertia) plugin: a released drag stops
  instantly (owner request) and the viewport goes idle immediately.
- Expose RendererHandle.getRenderCount() / getMapRenderCount for
  deterministic e2e assertions.

Tests: new map-toggles e2e specs (idle map does not repaint; released
drag does not coast) green on all four Playwright projects incl. WebKit.
Docs: renderer.md (render-on-demand section; fog section corrected to the
current single-fogLayer model; FPS note) and PLAN.md Phase 29 decision 8.

If Safari pan is still heavy after this, stage 2 will cut the overpaint
itself with an inverse stencil mask of the circle union (kept vector).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:28:18 +02:00

158 lines
5.1 KiB
TypeScript

// Verifies the Phase 6 storage layer end-to-end in real browser
// engines: a freshly generated device keypair persists across a page
// reload, signs deterministically with the same private key after the
// reload, and is wiped by `clearDeviceSession` so the next load
// generates a different keypair. The live-gateway round-trip is
// covered by Phase 7's e2e once the email-code login flow lands;
// this spec deliberately stops at the storage boundary.
import { expect, test, type Page } from "@playwright/test";
interface DebugSnapshot {
publicKey: number[];
deviceSessionId: string | null;
}
import type {
MapCameraSnapshot,
MapFogSnapshot,
MapPickStateSnapshot,
MapPrimitiveSnapshot,
} from "../../src/lib/debug-surface.svelte";
import type { WrapMode } from "../../src/map/world";
// Mirrors the surface mounted by `routes/__debug/store/+page.svelte`.
// Other Playwright specs (`game-shell.spec.ts`, `order-composer.spec.ts`,
// `cargo-routes.spec.ts`) reuse the global declaration below, so this
// interface lists every helper any spec calls — not only those
// exercised by this file.
interface DebugSurface {
ready: true;
loadSession(): Promise<DebugSnapshot>;
clearSession(): Promise<void>;
signWithDevice(message: number[]): Promise<number[]>;
setDeviceSessionId(id: string): Promise<void>;
verifyWithStoredPublicKey(
message: number[],
signature: number[],
): Promise<boolean>;
seedOrderDraft(
gameId: string,
commands: ReadonlyArray<{
kind: "placeholder";
id: string;
label: string;
}>,
): Promise<void>;
clearOrderDraft(gameId: string): Promise<void>;
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
getMapPickState(): MapPickStateSnapshot;
getMapCamera(): MapCameraSnapshot | null;
getMapFog(): MapFogSnapshot;
getMapMode(): WrapMode | null;
getMapRenderCount(): number;
}
declare global {
interface Window {
__galaxyDebug?: DebugSurface;
}
}
const CANONICAL = Array.from(new TextEncoder().encode("phase-6-canonical"));
async function bootDebugPage(page: Page): Promise<void> {
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
}
test("device keypair survives reload and produces verifiable signatures", async ({
page,
}) => {
await bootDebugPage(page);
// Wipe any leftover state from a previous Playwright run that
// shared the same browser profile.
await page.evaluate(() => window.__galaxyDebug!.clearSession());
const first = await page.evaluate(async (canonical) => {
const sess = await window.__galaxyDebug!.loadSession();
const signature = await window.__galaxyDebug!.signWithDevice(canonical);
return { publicKey: sess.publicKey, signature };
}, CANONICAL);
expect(first.publicKey.length).toBe(32);
expect(first.signature.length).toBe(64);
await page.reload();
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
const second = await page.evaluate(
async ({ canonical, firstSig }) => {
const sess = await window.__galaxyDebug!.loadSession();
const fresh = await window.__galaxyDebug!.signWithDevice(canonical);
// Signatures produced before the reload must verify under the
// post-reload public key. A pre-reload signature only verifies
// when the persisted private key is identical to the original.
const prevVerifies =
await window.__galaxyDebug!.verifyWithStoredPublicKey(
canonical,
firstSig,
);
const freshVerifies =
await window.__galaxyDebug!.verifyWithStoredPublicKey(
canonical,
fresh,
);
return {
publicKey: sess.publicKey,
signature: fresh,
prevVerifies,
freshVerifies,
};
},
{ canonical: CANONICAL, firstSig: first.signature },
);
expect(second.publicKey).toEqual(first.publicKey);
expect(second.prevVerifies).toBe(true);
expect(second.freshVerifies).toBe(true);
});
test("clearDeviceSession forces a fresh keypair on next load", async ({
page,
}) => {
await bootDebugPage(page);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
const before = await page.evaluate(async () => {
const sess = await window.__galaxyDebug!.loadSession();
return sess.publicKey;
});
await page.evaluate(() => window.__galaxyDebug!.clearSession());
const after = await page.evaluate(async () => {
const sess = await window.__galaxyDebug!.loadSession();
return sess.publicKey;
});
expect(after).not.toEqual(before);
});
test("setDeviceSessionId is observable through loadDeviceSession", async ({
page,
}) => {
await bootDebugPage(page);
await page.evaluate(() => window.__galaxyDebug!.clearSession());
const before = await page.evaluate(async () => {
const sess = await window.__galaxyDebug!.loadSession();
return sess.deviceSessionId;
});
expect(before).toBeNull();
await page.evaluate(() => window.__galaxyDebug!.setDeviceSessionId("dev-1"));
const after = await page.evaluate(async () => {
const sess = await window.__galaxyDebug!.loadSession();
return sess.deviceSessionId;
});
expect(after).toBe("dev-1");
});