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:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}$`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user