From ca59fff0eb1817dffe991906b91c3eb76c1e0033 Mon Sep 17 00:00:00 2001 From: eren23 Date: Sun, 19 Jul 2026 11:45:37 +0300 Subject: [PATCH] =?UTF-8?q?test(e2e):=20seven=20flow=20specs=20=E2=80=94?= =?UTF-8?q?=20the=20interactive=20suite=20covers=20the=20real=20product?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The required-gate suite grows from 4 to 11 specs, all signal-driven (no sleeps), all deterministic under the mock stack, 11/11 green locally in 1.4 minutes: - world-enter: tap a tower → ONE hop, arrival INDOORS — asserts the SSE wire contract (#167 image_op enter_scene + #161/#166 scene_view stamp {room, interior}). - world-submap: tap a district → the map REDRAW op (#164/#173). This spec caught TWO real mock bugs before it ever went green (title trigger-leak, then the multiline-title template miss) — the suite bites. - zoom-here: right-click "Zoom in here" pins an optical-zoom render_mode on the wire (#162/#169). - error-retry (mock-only): MOCK_ERROR → friendly banner → ↻ sends a FRESH Idempotency-Key and streams 200, not 409 (#153). - share-continue: /n/ renders the static viewer; /play?continue= rehydrates with ZERO new generates (the by-design split). - expand-tray: Around → four neighbour tiles → clicking one navigates (#154 wire; failed-count branch stays unit-tested). - wander: exactly 8 auto-taps, the stop pill, and NOT ONE more (#155) — possible under mock at all only since #176 fixed the swallowed candidates route. --- apps/web/e2e/error-retry.spec.ts | 37 +++++++++++++++++++++++ apps/web/e2e/expand-tray.spec.ts | 24 +++++++++++++++ apps/web/e2e/share-continue.spec.ts | 46 +++++++++++++++++++++++++++++ apps/web/e2e/wander.spec.ts | 39 ++++++++++++++++++++++++ apps/web/e2e/world-enter.spec.ts | 31 +++++++++++++++++++ apps/web/e2e/world-submap.spec.ts | 26 ++++++++++++++++ apps/web/e2e/zoom-here.spec.ts | 36 ++++++++++++++++++++++ 7 files changed, 239 insertions(+) create mode 100644 apps/web/e2e/error-retry.spec.ts create mode 100644 apps/web/e2e/expand-tray.spec.ts create mode 100644 apps/web/e2e/share-continue.spec.ts create mode 100644 apps/web/e2e/wander.spec.ts create mode 100644 apps/web/e2e/world-enter.spec.ts create mode 100644 apps/web/e2e/world-submap.spec.ts create mode 100644 apps/web/e2e/zoom-here.spec.ts 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); +});