4e0058d46c
- Unit: repoint moved screen imports (lib/screens, lib/game), mock $lib/app-nav (appScreen/activeView) instead of $app/navigation, drop the removed gameId props, assert screen/view selection. - e2e: add a dev-only window.__galaxyNav affordance; specs enter a game via enterGame(...) instead of a /games/:id URL; URL assertions become content assertions (the URL stays /game/); reload uses waitUntil:"commit" (shallow routing) and mocks /rpc on game entry. - Remove the obsolete report scroll-restore test (it relied on a SvelteKit route Snapshot that no longer exists); update the missing-membership test to the new lobby-redirect+toast behaviour. Fix a stale report.svelte docstring. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
178 lines
5.8 KiB
TypeScript
178 lines
5.8 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";
|
|
import type {
|
|
AppScreen,
|
|
GameView,
|
|
GameViewState,
|
|
} from "../../src/lib/app-nav.svelte";
|
|
|
|
// View sub-parameters accepted by the dev-only nav affordance — the
|
|
// `GameViewState` fields minus the discriminating `view`.
|
|
type NavViewParams = Omit<GameViewState, "view">;
|
|
|
|
// Mirrors the dev-only surface mounted by `routes/+page.svelte`. The
|
|
// single-URL app-shell has no per-screen / per-view routes, so the
|
|
// Playwright suite drives the in-memory screen and view through this
|
|
// global instead of `page.goto("/games/:id/:view")`.
|
|
interface NavSurface {
|
|
enterGame(gameId: string, view?: GameView, params?: NavViewParams): void;
|
|
select(view: GameView, params?: NavViewParams): void;
|
|
go(screen: AppScreen, opts?: { gameId?: string }): void;
|
|
}
|
|
|
|
// 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;
|
|
__galaxyNav?: NavSurface;
|
|
}
|
|
}
|
|
|
|
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");
|
|
});
|