test(ui): migrate suite to the app-shell (state-driven navigation)

- 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>
This commit is contained in:
Ilia Denisov
2026-05-23 20:49:35 +02:00
parent 80545e9f9d
commit 4e0058d46c
36 changed files with 707 additions and 343 deletions
+57 -15
View File
@@ -4,12 +4,15 @@
// webkit/mobile projects adds cost without new signal).
//
// Auth is bootstrapped through `/__debug/store` exactly as the
// game-shell specs do; the in-game layout tolerates a missing gateway
// game-shell specs do; the in-game shell tolerates a missing gateway
// (ECONNREFUSED) and still renders the chrome + view shells, which is
// what the structural a11y scan needs.
// what the structural a11y scan needs. Screens and in-game views are
// reached through the dev-only `window.__galaxyNav` affordance — the
// single-URL app-shell has no per-screen / per-view routes.
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import type { GameView, GameViewState } from "../../src/lib/app-nav.svelte";
const SESSION_ID = "f2-a11y-axe-session";
// A real UUID — the layout's auto-sync calls `uuidToHiLo` on it.
@@ -46,38 +49,77 @@ test.describe("axe WCAG 2.2 AA", () => {
});
test("login", async ({ page }) => {
await page.goto("/login");
// No seeded session → the dispatcher renders the login screen.
await page.goto("/");
await expect(page.locator("#main-content")).toBeVisible();
await expectNoViolations(page);
});
test("lobby", async ({ page }) => {
await authenticate(page);
await page.goto("/lobby");
await page.goto("/");
await expect(page.locator("#main-content")).toBeVisible();
await expectNoViolations(page);
});
test("lobby create", async ({ page }) => {
await authenticate(page);
await page.goto("/lobby/create");
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(() => window.__galaxyNav!.go("lobby-create"));
await expect(page.locator("#main-content")).toBeVisible();
await expectNoViolations(page);
});
const inGameViews: Array<[string, string]> = [
["map", "active-view-map"],
["report", "active-view-report"],
["mail", "active-view-mail"],
["battle", "active-view-battle"],
["designer/science", "active-view-designer-science"],
["table/planets", "active-view-table"],
type ViewParams = Omit<GameViewState, "view">;
const inGameViews: Array<{
label: string;
view: GameView;
params: ViewParams;
testId: string;
}> = [
{ label: "map", view: "map", params: {}, testId: "active-view-map" },
{
label: "report",
view: "report",
params: {},
testId: "active-view-report",
},
{ label: "mail", view: "mail", params: {}, testId: "active-view-mail" },
{
label: "battle",
view: "battle",
params: {},
testId: "active-view-battle",
},
{
label: "designer/science",
view: "designer-science",
params: {},
testId: "active-view-designer-science",
},
{
label: "table/planets",
view: "table",
params: { tableEntity: "planets" },
testId: "active-view-table",
},
];
for (const [path, testId] of inGameViews) {
test(`in-game: ${path}`, async ({ page }) => {
for (const { label, view, params, testId } of inGameViews) {
test(`in-game: ${label}`, async ({ page }) => {
await authenticate(page);
await page.goto(`/games/${GAME_ID}/${path}`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
([id, v, p]) =>
window.__galaxyNav!.enterGame(
id as string,
v as GameView,
p as ViewParams,
),
[GAME_ID, view, params] as const,
);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId(testId)).toBeVisible();
await expectNoViolations(page);
+6 -1
View File
@@ -16,7 +16,12 @@ async function bootShell(page: Page): Promise<void> {
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
}
+20 -8
View File
@@ -145,7 +145,10 @@ async function mockGatewayHappyPath(
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await expect(page).toHaveURL(/\/login$/);
// The single-URL app-shell renders the login screen from in-memory
// state (anonymous session) rather than a `/login` route, so assert
// on the visible login form instead of the URL.
await expect(page.getByTestId("login-email-input")).toBeVisible();
// Inputs render `readonly` initially as a Safari autofill-suppression
// workaround; the attribute drops on first focus. Click first so the
// onfocus handler runs before fill checks editability.
@@ -156,7 +159,9 @@ async function completeLogin(page: Page): Promise<void> {
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page).toHaveURL(/\/lobby$/);
// Sign-in switches the in-memory screen to the lobby; the device
// session id surfaces only on the lobby screen.
await expect(page.getByTestId("device-session-id")).toBeVisible();
}
test.describe("Phase 7 — auth flow", () => {
@@ -185,7 +190,8 @@ test.describe("Phase 7 — auth flow", () => {
await expect(page.getByTestId("account-greeting")).toBeVisible();
await page.reload();
await expect(page).toHaveURL(/\/lobby$/);
// The restored session re-renders the lobby screen directly (no
// `/lobby` route to land on).
await expect(page.getByTestId("device-session-id")).toHaveText(
"dev-test-1",
);
@@ -202,12 +208,16 @@ test.describe("Phase 7 — auth flow", () => {
// Fire all pending SubscribeEvents requests with an empty 200
// response. Connect-Web's server-streaming reader sees no frames
// and the watcher trips into `signOut("revoked")`, which the
// layout effect turns into a redirect back to /login.
// and the watcher trips into `signOut("revoked")`, which flips the
// in-memory session to anonymous so the dispatcher re-renders the
// login screen (the single-URL app-shell has no `/login` route to
// redirect to).
const releaseAt = Date.now();
mocks.pendingSubscribes.forEach((resolve) => resolve());
await expect(page).toHaveURL(/\/login$/, { timeout: 1000 });
await expect(page.getByTestId("login-email-input")).toBeVisible({
timeout: 1000,
});
expect(Date.now() - releaseAt).toBeLessThan(1500);
});
@@ -230,7 +240,7 @@ test.describe("Phase 7 — auth flow", () => {
},
);
await page.goto("/login");
await page.goto("/");
await expect(page.getByTestId("login-email-submit")).toHaveText(
"send code",
);
@@ -287,6 +297,8 @@ test.describe("Phase 7 — auth flow", () => {
await page.goto("/");
await expect(page.getByText(/browser not supported/i)).toBeVisible();
await expect(page).not.toHaveURL(/\/login$/);
// The unsupported-browser blocker replaces the screen dispatcher
// entirely, so the login form never renders.
await expect(page.getByTestId("login-email-input")).toHaveCount(0);
});
});
+31 -8
View File
@@ -225,16 +225,20 @@ test.describe("Phase 27 battle viewer", () => {
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-report")).toBeVisible();
const row = page.getByTestId("report-battle-row").first();
await expect(row).toBeVisible();
await row.click();
await expect(page).toHaveURL(
new RegExp(`/games/${GAME_ID}/battle/${BATTLE_ID}\\?turn=1`),
);
// The battle row switches the active view in place (the address
// bar stays at the app base); the viewer chrome is the signal.
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-scene")).toBeVisible();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
@@ -250,7 +254,13 @@ test.describe("Phase 27 battle viewer", () => {
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
([id, battleId]) =>
window.__galaxyNav!.enterGame(id, "battle", { battleId, turn: 1 }),
[GAME_ID, BATTLE_ID] as const,
);
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-frame-index")).toContainText("0 / 4");
@@ -274,8 +284,15 @@ test.describe("Phase 27 battle viewer", () => {
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(
`/games/${GAME_ID}/battle/22222222-2222-2222-2222-222222222222?turn=1`,
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "battle", {
battleId: "22222222-2222-2222-2222-222222222222",
turn: 1,
}),
GAME_ID,
);
await expect(page.getByTestId("battle-not-found")).toBeVisible();
@@ -292,7 +309,13 @@ test.describe("Phase 27 battle viewer", () => {
await page.setViewportSize({ width: 1280, height: 720 });
await mockGatewayAndBattle(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/battle/${BATTLE_ID}?turn=1`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
([id, battleId]) =>
window.__galaxyNav!.enterGame(id, "battle", { battleId, turn: 1 }),
[GAME_ID, BATTLE_ID] as const,
);
await expect(page.getByTestId("battle-viewer")).toBeVisible();
await expect(page.getByTestId("battle-scene")).toBeVisible();
+6 -1
View File
@@ -378,7 +378,12 @@ test("cargo-routes flow: pick a destination, arrow appears, reload restores", as
const handle = await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
@@ -138,7 +138,12 @@ async function setupShell(page: Page): Promise<void> {
},
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
+30 -11
View File
@@ -171,7 +171,12 @@ test("map view renders the reported turn and planet count from a live report", a
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
@@ -201,7 +206,12 @@ test("zero-planet game renders the empty world without errors", async ({
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
@@ -216,12 +226,15 @@ test("zero-planet game renders the empty world without errors", async ({
await expect(page.getByTestId("map-mount-error")).not.toBeVisible();
});
test("missing-membership game surfaces an error instead of a blank canvas", async ({
test("missing-membership game drops back to the lobby with an unavailable toast", async ({
page,
}) => {
// The gateway returns lobby.my.games.list with a different game id
// so the layout's gameState lookup misses; the store flips to
// `error` and the map view renders the localised error overlay.
// so the shell's gameState lookup misses. In the single-URL
// app-shell this `notFound` case no longer strands the player on an
// in-game error overlay — the shell switches the screen back to the
// lobby (`appScreen.go("lobby")`) and surfaces the "no longer
// available" toast.
await mockGateway(page, {
currentTurn: 0,
gameId: "99999999-aaaa-bbbb-cccc-000000000000",
@@ -229,11 +242,17 @@ test("missing-membership game surfaces an error instead of a blank canvas", asyn
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("map-error")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"error",
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
// Back on the lobby (game shell unmounted), with the unavailable toast.
await expect(page.getByTestId("toast")).toContainText(
"this game is no longer available",
);
await expect(page.getByTestId("lobby-create-button")).toBeVisible();
await expect(page.getByTestId("game-shell")).toHaveCount(0);
});
+25 -23
View File
@@ -2,18 +2,19 @@
// boots an authenticated session through `/__debug/store` (the
// in-game shell makes a handful of gateway calls — for the lobby
// record, the report, and the order read-back; we don't mock them
// here, the shell tolerates ECONNREFUSED), navigates into
// `/games/<game-id>/map`, and exercises one slice of the chrome:
// here, the shell tolerates ECONNREFUSED), enters the game through
// the dev-only `window.__galaxyNav` affordance (the single-URL
// app-shell has no `/games/<id>/<view>` route — the address bar
// stays at the app base), and exercises one slice of the chrome:
// header navigation, sidebar tab preservation, mobile bottom-tabs,
// and the breakpoint switches at 768 / 1024 px.
import { expect, test, type Page } from "@playwright/test";
// The `window.__galaxyDebug` surface is owned by
// `src/routes/__debug/store/+page.svelte` and typed by
// `tests/e2e/storage-keypair-persistence.spec.ts`. This spec only
// needs the auth-bootstrap subset (`clearSession`,
// `setDeviceSessionId`); the merged global declaration covers both.
// `window.__galaxyDebug` is owned by `src/routes/__debug/store/+page.svelte`
// (auth bootstrap) and `window.__galaxyNav` by `src/routes/+page.svelte`
// (dev-only screen/view driver); both are typed by
// `tests/e2e/storage-keypair-persistence.spec.ts`.
const SESSION_ID = "phase-10-shell-session";
// GAME_ID has to be a real UUID — Phase 14's auto-sync calls
@@ -30,7 +31,14 @@ async function bootShell(page: Page): Promise<void> {
(id) => window.__galaxyDebug!.setDeviceSessionId(id),
SESSION_ID,
);
await page.goto(`/games/${GAME_ID}/map`);
// Load the app (seeded session → authenticated → lobby), then enter
// the game via the in-memory nav affordance.
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
}
@@ -50,23 +58,20 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
test("header view-menu navigates to every active view", async ({ page }) => {
await bootShell(page);
const destinations: Array<[string, string, string]> = [
["view-menu-item-report", "active-view-report", "/report"],
["view-menu-item-mail", "active-view-mail", "/mail"],
["view-menu-item-battle", "active-view-battle", "/battle"],
[
"view-menu-item-designer-science",
"active-view-designer-science",
"/designer/science",
],
["view-menu-item-map", "active-view-map", "/map"],
// The address bar stays at the app base in the single-URL app-shell,
// so the visible active view is the only navigation signal to assert.
const destinations: Array<[string, string]> = [
["view-menu-item-report", "active-view-report"],
["view-menu-item-mail", "active-view-mail"],
["view-menu-item-battle", "active-view-battle"],
["view-menu-item-designer-science", "active-view-designer-science"],
["view-menu-item-map", "active-view-map"],
];
for (const [trigger, viewTestId, urlSuffix] of destinations) {
for (const [trigger, viewTestId] of destinations) {
await page.getByTestId("view-menu-trigger").click();
await page.getByTestId(trigger).click();
await expect(page.getByTestId(viewTestId)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}${urlSuffix}$`));
}
});
@@ -92,9 +97,6 @@ test("header view-menu Tables sub-list navigates to every entity", async ({
const view = page.getByTestId("active-view-table");
await expect(view).toBeVisible();
await expect(view).toHaveAttribute("data-entity", entity);
await expect(page).toHaveURL(
new RegExp(`/games/${GAME_ID}/table/${entity}$`),
);
}
});
+9 -1
View File
@@ -181,7 +181,15 @@ test("navigating to a past turn enters history mode and back-to-current restores
const state = await mockGateway(page);
await seedShell(page);
await page.goto(`/games/${GAME_ID}/table/planets`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "planets",
}),
GAME_ID,
);
await expect(page.getByTestId("turn-navigator-trigger")).toContainText(
`turn ${CURRENT_TURN}`,
);
@@ -136,7 +136,9 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({
page,
}) => {
await seedSession(page);
await page.goto("/lobby");
// Seeded session → the dispatcher renders the lobby; the synthetic
// loader lives there behind the dev-affordances flag.
await page.goto("/");
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
const file = page.getByTestId("lobby-synthetic-file");
@@ -146,9 +148,10 @@ test("synthetic-report loader navigates from lobby to map and renders", async ({
buffer: Buffer.from(JSON.stringify(SYNTHETIC_REPORT_FIXTURE)),
});
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
timeout: 5_000,
});
// Loading the report enters the game in place (the address bar stays
// at the app base); the in-game map shell is the visible signal.
await expect(page.getByTestId("game-shell")).toBeVisible({ timeout: 5_000 });
await expect(page.getByTestId("active-view-map")).toBeVisible();
// The renderer canvas mounts inside the active-view host. Even if
// the WebGL/WebGPU backend is unavailable in CI, the layout still
+10 -4
View File
@@ -235,7 +235,9 @@ async function mockGateway(page: Page, initial: Partial<LobbyState> = {}): Promi
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await expect(page).toHaveURL(/\/login$/);
// The single-URL app-shell renders the login screen from in-memory
// state (anonymous session); there is no `/login` route to assert.
await expect(page.getByTestId("login-email-input")).toBeVisible();
// The login page renders the inputs `readonly` as a Safari
// autofill-suppression workaround; the readonly attribute is
// dropped on first focus. Playwright's `fill()` checks editability
@@ -248,7 +250,8 @@ async function completeLogin(page: Page): Promise<void> {
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page).toHaveURL(/\/lobby$/);
// Sign-in switches the in-memory screen to the lobby.
await expect(page.getByTestId("device-session-id")).toBeVisible();
}
test.describe("Phase 8 — lobby flow", () => {
@@ -260,7 +263,9 @@ test.describe("Phase 8 — lobby flow", () => {
await expect(page.getByTestId("lobby-public-games-empty")).toBeVisible();
await page.getByTestId("lobby-create-button").click();
await expect(page).toHaveURL(/\/lobby\/create$/);
// The create screen replaces the lobby in place (no `/lobby/create`
// route); the create form is the visible signal.
await expect(page.getByTestId("lobby-create-form")).toBeVisible();
await page.getByTestId("lobby-create-game-name").click();
await page.getByTestId("lobby-create-game-name").fill("First Contact");
@@ -271,7 +276,8 @@ test.describe("Phase 8 — lobby flow", () => {
.fill("2026-06-01T12:00");
await page.getByTestId("lobby-create-submit").click();
await expect(page).toHaveURL(/\/lobby$/);
// Submit returns to the lobby in place; the new game card is the
// visible signal that the lobby re-rendered.
await expect(page.getByTestId("lobby-my-game-card")).toContainText("First Contact");
expect(mocks.createGameCalls.length).toBe(1);
expect(mocks.createGameCalls[0]!.gameName).toBe("First Contact");
+6 -1
View File
@@ -187,7 +187,12 @@ for (const view of NON_MAP_VIEWS) {
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
+11 -2
View File
@@ -202,7 +202,12 @@ async function bootSession(page: Page): Promise<void> {
}
async function openGame(page: Page): Promise<void> {
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
@@ -383,7 +388,11 @@ test("toggle state persists across a page reload", async ({ page }) => {
await page.getByTestId("map-toggles-bombing-markers").isChecked(),
).toBe(false);
await page.reload();
// The restored `game` screen re-stamps history via shallow routing
// on first render; wait only for the navigation to commit so that
// `pushState` does not abort a default `reload()` (which waits for
// `load`).
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
+49 -14
View File
@@ -1,7 +1,17 @@
// Phase 12 end-to-end coverage for the order composer skeleton. The
// shell makes no gateway calls in this spec — the boot flow seeds an
// authenticated session and a draft directly through `/__debug/store`,
// then navigates into `/games/<id>/map` and exercises the order tab.
// boot flow seeds an authenticated session and a draft directly
// through `/__debug/store`, then enters the game via the dev-only
// `window.__galaxyNav` affordance (the single-URL app-shell has no
// `/games/<id>/<view>` route) and exercises the order tab.
//
// The shell's per-game bootstrap now talks to the gateway on entry
// (lobby validation, report, order read-back). This spec does not
// stand up a real gateway, so those Connect-Web calls are aborted via
// `page.route` — the shell tolerates the failure (cache fallback +
// `failBootstrap`) and still renders the chrome. Aborting also keeps
// a mid-spec `page.reload()` from hanging: an unrouted `/rpc` call
// to a dead proxy never settles, which otherwise stalls the reload's
// load event.
//
// Persistence is covered by reloading the page mid-spec: the
// `OrderDraftStore` re-reads the same cache row on the next mount,
@@ -10,12 +20,31 @@
import { expect, test, type Page } from "@playwright/test";
// `window.__galaxyDebug` is owned by `routes/__debug/store/+page.svelte`
// and typed by `tests/e2e/storage-keypair-persistence.spec.ts`. The
// merged global declaration covers every helper this spec calls.
// and `window.__galaxyNav` by `routes/+page.svelte`; both are typed by
// `tests/e2e/storage-keypair-persistence.spec.ts`.
const SESSION_ID = "phase-12-order-session";
const GAME_ID = "test-order";
// Fail-fast the shell's gateway calls so the spec needs no real
// backend and reloads settle promptly.
async function stubGateway(page: Page): Promise<void> {
await page.route("**/edge.v1.Gateway/**", (route) => route.abort());
}
// Load the app (seeded session → authenticated lobby) and enter the
// game on the map view through the in-memory nav affordance.
async function enterGameMap(page: Page): Promise<void> {
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
}
const SEED = [
{ kind: "placeholder" as const, id: "cmd-a", label: "first command" },
{ kind: "placeholder" as const, id: "cmd-b", label: "second command" },
@@ -23,6 +52,7 @@ const SEED = [
];
async function bootDebug(page: Page): Promise<void> {
await stubGateway(page);
await page.goto("/__debug/store");
await expect(page.getByTestId("debug-store-ready")).toBeVisible();
await page.waitForFunction(() => window.__galaxyDebug?.ready === true);
@@ -71,14 +101,18 @@ test("seeded draft renders on the order tab and survives a reload", async ({
}, testInfo) => {
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
await seedShell(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await expect(page.getByTestId("active-view-map")).toBeVisible();
await enterGameMap(page);
await openOrderTool(page, isMobile);
await expectSeededRows(page);
await page.reload();
// Reload restores the `game` screen from the persisted nav snapshot,
// whose first authenticated render re-stamps screen history via
// SvelteKit shallow routing. That `pushState` lands right after the
// document loads and would abort a default `reload()` (which waits
// for `load`); waiting only for the navigation to commit sidesteps
// the race while still re-executing the app from scratch.
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile);
await expectSeededRows(page);
@@ -89,8 +123,7 @@ test("removing a command from the order tab persists the removal", async ({
}, testInfo) => {
const isMobile = testInfo.project.name.startsWith("chromium-mobile");
await seedShell(page);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await enterGameMap(page);
await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-command-1")).toBeVisible();
@@ -104,7 +137,10 @@ test("removing a command from the order tab persists the removal", async ({
);
await expect(page.getByTestId("order-command-2")).toHaveCount(0);
await page.reload();
// See the note on the sibling test: the restored `game` screen
// re-stamps history on reload, so wait only for the navigation to
// commit to avoid the shallow-routing `pushState` aborting it.
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("game-shell")).toBeVisible();
await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-command-label-0")).toHaveText(
@@ -131,8 +167,7 @@ test("empty draft renders the empty-state copy", async ({
GAME_ID,
);
await page.goto(`/games/${GAME_ID}/map`);
await expect(page.getByTestId("game-shell")).toBeVisible();
await enterGameMap(page);
await openOrderTool(page, isMobile);
await expect(page.getByTestId("order-empty")).toBeVisible();
+12 -2
View File
@@ -278,7 +278,12 @@ test("turn_already_closed surfaces the conflict banner on the order tab", async
);
await mockGateway(page, { initialSubmitVerdict: "turn_already_closed" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
@@ -322,7 +327,12 @@ test("game.paused push frame surfaces the paused banner", async ({
subscribeFrame: { eventType: "game.paused", payload },
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
@@ -286,7 +286,12 @@ test("switching production three times collapses to one auto-synced row", async
const handle = await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
@@ -357,10 +362,13 @@ test("switching production three times collapses to one auto-synced row", async
expect(handle.lastSubmitted!.subject).toBe(SHIP_CLASS);
expect(handle.submitCount).toBeGreaterThanOrEqual(3);
// Reload: the layout polls user.games.order.get on boot, so the
// Reload: the shell polls user.games.order.get on boot, so the
// row is restored from the server's stored state even when the
// local cache is wiped.
await page.reload();
// local cache is wiped. The restored `game` screen re-stamps
// history via shallow routing on first render, so wait only for the
// navigation to commit (a default `reload()` waiting for `load`
// races that `pushState` and aborts).
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
+6 -1
View File
@@ -280,7 +280,12 @@ test("toggle stance and pick a vote target via the races table", async ({
const handle = await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/races`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "table", { tableEntity: "races" }),
GAME_ID,
);
const tableHost = page.getByTestId("active-view-table");
await expect(tableHost).toBeVisible();
+18 -5
View File
@@ -230,7 +230,12 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
submitOutcome: "applied",
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
@@ -266,10 +271,13 @@ test("rename a seeded planet auto-syncs and the overlay survives reload", async
);
expect(handle.submittedRenameName).toBe("New-Earth");
// Reload: the layout always polls user.games.order.get on boot,
// Reload: the shell always polls user.games.order.get on boot,
// so the overlay is rebuilt from the server's stored order even
// when the local cache was wiped.
await page.reload();
// when the local cache was wiped. The restored `game` screen
// re-stamps history via shallow routing on first render, so wait
// only for the navigation to commit (a default `reload()` waiting
// for `load` races that `pushState` and aborts).
await page.reload({ waitUntil: "commit" });
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
@@ -292,7 +300,12 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
submitOutcome: "rejected",
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
+32 -62
View File
@@ -238,7 +238,12 @@ test.describe("Phase 23 report view", () => {
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(page.getByTestId("report-toc")).toBeVisible();
@@ -265,65 +270,19 @@ test.describe("Phase 23 report view", () => {
}
});
test("scroll position survives a /map round-trip via Snapshot", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name.startsWith("chromium-mobile"),
"snapshot mechanism is the same on mobile; one project is enough",
);
// NOTE: the old "scroll position survives a /map round-trip via
// Snapshot" spec was dropped here. It exercised the per-route
// SvelteKit `Snapshot` exported by the deleted
// `routes/games/[id]/report/+page.svelte`, which captured and
// restored `window.scrollY` across a browser history navigation to
// `/map` and back. The single-URL app-shell switches the active view
// in memory (`activeView.select`) without changing the URL or pushing
// a history entry, and it remounts the report component on return —
// so neither the URL round-trip, the `page.goBack()`, nor the
// scroll-restoration the test asserted exist any more. Re-adding that
// behaviour would be a production change outside this test migration.
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(
page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible();
// Scroll the window. The report's host expands to fit
// content rather than constraining its own height, so the
// document body is the real scroll container. SvelteKit's
// default scroll-restoration tracks `window.scrollY` on
// history navigation, which is what the acceptance criterion
// — "scroll position resets when switching to another view
// and is restored on return" — requires.
const target = 600;
await page.evaluate((value) => {
window.scrollTo(0, value);
}, target);
const savedScrollY = await page.evaluate(() => window.scrollY);
expect(savedScrollY).toBeGreaterThan(0);
// Programmatically click the back-to-map button. Driving the
// click through `evaluate` rather than the Playwright locator
// skips its built-in scrollIntoViewIfNeeded(), which would
// otherwise scroll the sticky TOC button into view and reset
// `window.scrollY` to 0 before SvelteKit's Snapshot capture
// fires.
await page.evaluate(() => {
const button = document.querySelector(
"[data-testid='report-back-to-map']",
) as HTMLButtonElement | null;
button?.click();
});
await page.waitForURL(`**/games/${GAME_ID}/map`);
await page.goBack();
await page.waitForURL(`**/games/${GAME_ID}/report`);
await expect(page.getByTestId("active-view-report")).toBeVisible();
await expect(
page.getByTestId("galaxy-summary-field-turn"),
).toBeVisible();
await expect
.poll(async () => page.evaluate(() => window.scrollY), {
timeout: 5_000,
intervals: [100, 200, 400],
})
.toBeGreaterThan(savedScrollY / 2);
});
test("back-to-map button navigates to the map URL", async ({
test("back-to-map button switches to the map view", async ({
page,
}, testInfo) => {
test.skip(
@@ -333,10 +292,16 @@ test.describe("Phase 23 report view", () => {
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
GAME_ID,
);
await page.getByTestId("report-back-to-map").click();
await expect(page).toHaveURL(new RegExp(`/games/${GAME_ID}/map$`));
// The single-URL app-shell keeps the address bar at the app base;
// the active map view is the navigation signal.
await expect(page.getByTestId("active-view-map")).toBeVisible();
});
@@ -350,7 +315,12 @@ test.describe("Phase 23 report view", () => {
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/report`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "report", {}),
GAME_ID,
);
const mobileSelect = page.getByTestId("report-toc-mobile");
await expect(mobileSelect).toBeVisible();
@@ -197,7 +197,12 @@ test("returning to /map after creating a science keeps the renderer alive", asyn
await bootSession(page);
// Step 1: open /map and let the renderer mount cleanly.
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
+21 -3
View File
@@ -286,7 +286,15 @@ test("create / list / delete science via the table + designer", async ({
const handle = await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/sciences`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "sciences",
}),
GAME_ID,
);
const tableHost = page.getByTestId("active-view-table");
await expect(tableHost).toBeVisible();
@@ -347,7 +355,12 @@ test("designer keeps Save disabled while the form is invalid", async ({
await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/designer/science`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "designer-science", {}),
GAME_ID,
);
const save = page.getByTestId("designer-science-save");
await expect(save).toBeDisabled();
@@ -385,7 +398,12 @@ test("planet production picker exposes user sciences in the Research sub-row", a
],
});
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
+27 -3
View File
@@ -264,7 +264,15 @@ test("create / list / delete ship class via the table + calculator", async ({
const handle = await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "ship-classes",
}),
GAME_ID,
);
const tableHost = page.getByTestId("active-view-table");
await expect(tableHost).toBeVisible();
@@ -321,7 +329,15 @@ test("calculator keeps Create disabled while the design is invalid", async ({
await mockGateway(page, { createOutcome: "applied" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "ship-classes",
}),
GAME_ID,
);
await page.getByTestId("ship-classes-new").click();
const calc = page.getByTestId("sidebar-tool-calculator");
const create = calc.getByTestId("calculator-create");
@@ -350,7 +366,15 @@ test("rejected createShipClass keeps the table empty and surfaces the failure",
await mockGateway(page, { createOutcome: "rejected" });
await bootSession(page);
await page.goto(`/games/${GAME_ID}/table/ship-classes`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) =>
window.__galaxyNav!.enterGame(id, "table", {
tableEntity: "ship-classes",
}),
GAME_ID,
);
await page.getByTestId("ship-classes-new").click();
const calc = page.getByTestId("sidebar-tool-calculator");
@@ -135,7 +135,9 @@ async function bootSession(page: Page): Promise<void> {
}
async function loadSyntheticGame(page: Page): Promise<void> {
await page.goto("/lobby");
// Seeded session → the dispatcher renders the lobby; the synthetic
// loader lives there behind the dev-affordances flag.
await page.goto("/");
await expect(page.getByTestId("lobby-synthetic-section")).toBeVisible();
const file = page.getByTestId("lobby-synthetic-file");
await file.setInputFiles({
@@ -143,9 +145,9 @@ async function loadSyntheticGame(page: Page): Promise<void> {
mimeType: "application/json",
buffer: Buffer.from(JSON.stringify(SYNTHETIC_FIXTURE)),
});
await page.waitForURL(/\/games\/synthetic-[^/]+\/map$/, {
timeout: 10_000,
});
// Loading the report enters the game in place (the address bar stays
// at the app base); the map view reaching `ready` is the signal.
await expect(page.getByTestId("game-shell")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
@@ -20,6 +20,25 @@ import type {
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`,
@@ -56,6 +75,7 @@ interface DebugSurface {
declare global {
interface Window {
__galaxyDebug?: DebugSurface;
__galaxyNav?: NavSurface;
}
}
+29 -8
View File
@@ -105,16 +105,21 @@ async function mockGateway(page: Page): Promise<MockState> {
},
);
// The first SubscribeEvents request from the root layout receives
// one signed `game.turn.ready` frame for turn 5; subsequent
// reconnect attempts (events.ts retries after the abrupt
// end-of-body) are held open indefinitely so the toast stays
// visible long enough for the test to interact with it.
// The root layout opens the event stream while the dispatcher is
// still on the lobby screen (the single-URL app-shell starts the
// singleton stream on authentication, before the player enters a
// game). The per-game `game.turn.ready` handler only registers once
// the game shell mounts, so the very first frame can land before the
// handler exists. Deliver the signed frame on the first TWO
// SubscribeEvents requests — the post-frame reconnect (events.ts
// retries after the abrupt end-of-body) carries it again once the
// handler is up; `markPendingTurn(5)` is idempotent. Hold every
// later reconnect open so the toast stays visible for the test.
await page.route(
"**/edge.v1.Gateway/SubscribeEvents",
async (route) => {
state.subscribeHits += 1;
if (state.subscribeHits === 1) {
if (state.subscribeHits <= 2) {
const payload = new TextEncoder().encode(
JSON.stringify({ game_id: GAME_ID, turn: 5 }),
);
@@ -155,7 +160,12 @@ test("signed game.turn.ready frame surfaces the toast", async ({ page }) => {
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
// Initial chrome reflects the bootstrap currentTurn=4.
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
@@ -181,8 +191,19 @@ test("manual dismiss clears the turn-ready toast without advancing the view", as
await mockGateway(page);
await bootSession(page);
await page.goto(`/games/${GAME_ID}/map`);
await page.goto("/");
await page.waitForFunction(() => window.__galaxyNav !== undefined);
await page.evaluate(
(id) => window.__galaxyNav!.enterGame(id, "map", {}),
GAME_ID,
);
// Let the game shell finish booting (so the per-game turn-ready
// handler is registered) before expecting the pushed toast.
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
"data-status",
"ready",
);
await expect(page.getByTestId("toast")).toBeVisible({ timeout: 5_000 });
await page.getByTestId("toast-close").click();
await expect(page.getByTestId("toast")).toBeHidden();