Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/web/e2e/error-retry.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
24 changes: 24 additions & 0 deletions apps/web/e2e/expand-tray.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
46 changes: 46 additions & 0 deletions apps/web/e2e/share-continue.spec.ts
Original file line number Diff line number Diff line change
@@ -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/<id>
// load is the static share VIEWER; /play?continue=<session> 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);
});
39 changes: 39 additions & 0 deletions apps/web/e2e/wander.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
31 changes: 31 additions & 0 deletions apps/web/e2e/world-enter.spec.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
26 changes: 26 additions & 0 deletions apps/web/e2e/world-submap.spec.ts
Original file line number Diff line number Diff line change
@@ -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"');
});
36 changes: 36 additions & 0 deletions apps/web/e2e/zoom-here.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading