diff --git a/apps/web/e2e/error-retry.spec.ts b/apps/web/e2e/error-retry.spec.ts new file mode 100644 index 0000000..1ed4151 --- /dev/null +++ b/apps/web/e2e/error-retry.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; + +// Friendly error banner + ↻ retry (#153). MOCK_ERROR in the query makes the +// mock LLM raise inside the REAL pipeline, so the SSE error frame, the banner, +// and the retry's fresh Idempotency-Key are all product code under test. +// Mock-only: real providers don't fail on demand. +test.skip(!process.env.E2E_MOCK, "mock-only: needs the MOCK_ERROR lever"); + +test("forced failure shows the banner; retry sends a FRESH idempotency key", async ({ + page, +}) => { + const keys: string[] = []; + const statuses: number[] = []; + page.on("response", (r) => { + if (r.url().includes("/api/generate-page") && r.request().method() === "POST") { + keys.push(r.request().headers()["idempotency-key"] ?? ""); + statuses.push(r.status()); + } + }); + + await page.goto("/play?q=" + encodeURIComponent("MOCK_ERROR any town")); + + const retry = page.getByRole("button", { name: /Try again/ }); + await retry.waitFor({ state: "visible", timeout: 60_000 }); + + await retry.click(); + // The retry fails again (the token is still in the body) — the assertions + // are about the RETRY MECHANICS, not recovery: a second request went out, + // with a DIFFERENT key, and streamed (200) instead of being refused (409). + await expect + .poll(() => keys.length, { timeout: 30_000 }) + .toBeGreaterThanOrEqual(2); + expect(keys[0]).toBeTruthy(); + expect(keys[1]).toBeTruthy(); + expect(keys[1]).not.toBe(keys[0]); + expect(statuses[1]).toBe(200); +}); diff --git a/apps/web/e2e/expand-tray.spec.ts b/apps/web/e2e/expand-tray.spec.ts new file mode 100644 index 0000000..5a03f7a --- /dev/null +++ b/apps/web/e2e/expand-tray.spec.ts @@ -0,0 +1,24 @@ +import { expect, test } from "@playwright/test"; + +import { waitForStableImage } from "./helpers"; + +// Around → the neighbour tray (#154 wire; mock neighbors route). Four tiles +// arrive from one bloom; clicking one navigates. The failed-count branch +// stays unit-tested (NeighbourTray.test.tsx) — forcing exactly-one-failure +// through the mock isn't worth the relay. +test("Around blooms four neighbours; clicking one navigates", async ({ page }) => { + await page.goto("/play?q=" + encodeURIComponent("a lively riverside town")); + await waitForStableImage(page); + + await page.getByRole("button", { name: "Around", exact: true }).click(); + + const tray = page.getByLabel("Neighbours around this page"); + await tray.waitFor({ state: "visible", timeout: 60_000 }); + const tiles = page.getByRole("button", { name: /^Explore / }); + await expect.poll(async () => tiles.count(), { timeout: 90_000 }).toBe(4); + + const beforeUrl = page.url(); + await tiles.first().click(); + await expect.poll(() => page.url(), { timeout: 60_000 }).not.toBe(beforeUrl); + await waitForStableImage(page); +}); diff --git a/apps/web/e2e/share-continue.spec.ts b/apps/web/e2e/share-continue.spec.ts new file mode 100644 index 0000000..8d901c1 --- /dev/null +++ b/apps/web/e2e/share-continue.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test"; + +import { waitForStableImage } from "./helpers"; + +// The two reopen surfaces (by design, verified 2026-07-14): a hard /n/ +// load is the static share VIEWER; /play?continue= is the +// interactive rehydration — and hydration must not generate anything. +test("share viewer renders; continue rehydrates without generating", async ({ page }) => { + let sessionId = ""; + page.on("request", (req) => { + if (req.url().includes("/api/generate-page") && req.method() === "POST") { + const body = JSON.parse(req.postData() ?? "{}"); + if (body.session_id) sessionId = body.session_id; + } + }); + const persistPromise = page.waitForResponse( + (r) => r.url().includes("/api/nodes") && r.request().method() === "POST", + { timeout: 90_000 }, + ); + + await page.goto("/play?q=" + encodeURIComponent("a quiet mountain monastery")); + await waitForStableImage(page); + + const persisted = (await (await persistPromise).json()) as { id?: string }; + expect(persisted.id).toBeTruthy(); + expect(sessionId).toBeTruthy(); + + // 1) The share viewer: server-rendered from Mongo + Minio (both live in + // the mock stack). Static on purpose — just prove the node renders. + await page.goto(`/n/${persisted.id}`); + await expect(page.locator("img").first()).toBeVisible({ timeout: 30_000 }); + + // 2) The interactive reopen: hydration only — ZERO new generates. + let generatesAfterContinue = 0; + page.on("request", (req) => { + if (req.url().includes("/api/generate-page") && req.method() === "POST") { + generatesAfterContinue += 1; + } + }); + await page.goto(`/play?continue=${encodeURIComponent(sessionId)}`); + await expect( + page.locator('img[alt^="Generated illustration"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(2000); // settle window: any stray generate would fire here + expect(generatesAfterContinue).toBe(0); +}); diff --git a/apps/web/e2e/wander.spec.ts b/apps/web/e2e/wander.spec.ts new file mode 100644 index 0000000..33963b8 --- /dev/null +++ b/apps/web/e2e/wander.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from "@playwright/test"; + +import { waitForStableImage } from "./helpers"; + +// Wander's spend seatbelt (#155): exactly WANDER_MAX_PAGES auto-taps, then +// the stop pill and NOT ONE more. Only possible under mock at all since +// #176 fixed the candidates route (it was swallowed by the click catch-all +// and wander died instantly with "no-candidates"). ~60-90s wall: 8 cycles of +// 2.6s linger + a mock generation each — accepted; ?wanderLingerMs= is the +// flagged upgrade path if this ever dominates the suite. +test("wander explores exactly 8 pages then stops itself", async ({ page }) => { + test.setTimeout(180_000); + + let tapCount = 0; + page.on("request", (req) => { + if ( + req.url().includes("/api/generate-page") && + req.method() === "POST" && + (req.postData() ?? "").includes('"mode":"tap"') + ) { + tapCount += 1; + } + }); + + await page.goto("/play?q=" + encodeURIComponent("a sprawling old harbor town")); + await waitForStableImage(page); + + await page.getByRole("button", { name: /Wander/ }).click(); + + await page + .getByText(/explored 8 pages/) + .waitFor({ state: "visible", timeout: 150_000 }); + + expect(tapCount).toBe(8); + // The toggle flipped itself back off — no ninth tap is even armed. + await expect(page.getByRole("button", { name: /Wander/ })).toBeVisible(); + await page.waitForTimeout(4000); // longer than a linger — a 9th tap would land here + expect(tapCount).toBe(8); +}); diff --git a/apps/web/e2e/world-enter.spec.ts b/apps/web/e2e/world-enter.spec.ts new file mode 100644 index 0000000..b27d7d0 --- /dev/null +++ b/apps/web/e2e/world-enter.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from "@playwright/test"; + +import { clickAtImageFraction, waitForStableImage } from "./helpers"; + +// Tap = ENTER, and buildings go INSIDE (#167 + #161/#166). The mock classifier +// steers on the query's trigger words: "tower" → enter_as scene + place_form +// interior, so the tap rides the REAL interior-enter path (instruction, judge +// swap, scene_view stamp) with only the model replies canned. The SSE stream +// is the wire contract those PRs shipped — asserting it beats any DOM hook. +test("world tap on a tower ENTERS and arrives INDOORS (one hop)", async ({ page }) => { + await page.goto("/play?q=" + encodeURIComponent("an old stone tower")); + await waitForStableImage(page); + + const respPromise = page.waitForResponse( + (r) => + r.url().includes("/api/generate-page") && + r.request().method() === "POST" && + (r.request().postData() ?? "").includes('"mode":"tap"'), + { timeout: 60_000 }, + ); + + await clickAtImageFraction(page, 0.5, 0.5); + + const resp = await respPromise; + expect(resp.status()).toBe(200); + // text() resolves when the SSE stream closes; _sse emits with ": " separators. + const stream = await resp.text(); + expect(stream).toContain('"image_op": "enter_scene"'); + expect(stream).toContain('"place_form": "interior"'); + expect(stream).toContain('"scale_tier": "room"'); +}); diff --git a/apps/web/e2e/world-submap.spec.ts b/apps/web/e2e/world-submap.spec.ts new file mode 100644 index 0000000..7ec0074 --- /dev/null +++ b/apps/web/e2e/world-submap.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from "@playwright/test"; + +import { clickAtImageFraction, waitForStableImage } from "./helpers"; + +// Map zooms are REDRAWN, not crop-upscaled (#164/#173). "district" steers the +// mock classifier to enter_as submap, so the tap rides the real SUBMAP_REDRAW +// path and the final reports its honest op on the wire. +test("world tap on a district rides the map REDRAW", async ({ page }) => { + await page.goto("/play?q=" + encodeURIComponent("a market district")); + await waitForStableImage(page); + + const respPromise = page.waitForResponse( + (r) => + r.url().includes("/api/generate-page") && + r.request().method() === "POST" && + (r.request().postData() ?? "").includes('"mode":"tap"'), + { timeout: 60_000 }, + ); + + await clickAtImageFraction(page, 0.5, 0.5); + + const resp = await respPromise; + expect(resp.status()).toBe(200); + const stream = await resp.text(); + expect(stream).toContain('"image_op": "map_redraw"'); +}); diff --git a/apps/web/e2e/zoom-here.spec.ts b/apps/web/e2e/zoom-here.spec.ts new file mode 100644 index 0000000..7dbe4f3 --- /dev/null +++ b/apps/web/e2e/zoom-here.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +import { waitForStableImage } from "./helpers"; + +// The optical zoom lives on right-click (#162/#169): taps enter, "closer +// without entering" is an explicit menu action that pins render_mode. The +// pinned mode rides the request body — that's the whole contract. +test("right-click → Zoom in here pins an optical-zoom render_mode", async ({ page }) => { + await page.goto("/play?q=" + encodeURIComponent("a walled coastal city")); + await waitForStableImage(page); + + const img = page.locator('img[alt^="Generated illustration"]').first(); + const box = await img.boundingBox(); + expect(box).not.toBeNull(); + await page.mouse.click(box!.x + box!.width * 0.5, box!.y + box!.height * 0.5, { + button: "right", + }); + + const item = page.getByText("Zoom in here", { exact: false }).first(); + await item.waitFor({ state: "visible", timeout: 10_000 }); + + const reqPromise = page.waitForRequest( + (req) => + req.url().includes("/api/generate-page") && + req.method() === "POST" && + (req.postData() ?? "").includes('"mode":"tap"'), + { timeout: 60_000 }, + ); + + await item.click(); + + const body = JSON.parse((await reqPromise).postData() ?? "{}"); + // Root world map → submap cut; entered pages → closeup. Either is a valid + // optical zoom; what matters is the EXPLICIT pin reached the wire. + expect(["place_submap", "place_closeup"]).toContain(body.render_mode); +});