From 649a9c107023543b73b1507e3c099dadc2ebec1c Mon Sep 17 00:00:00 2001 From: AlexandreCamillo <43162926+AlexandreCamillo@users.noreply.github.com> Date: Thu, 28 May 2026 09:46:41 +0000 Subject: [PATCH 1/2] chore: remove GET /api/annotations/[id]/region endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The endpoint cropped a screenshot using pinCoords. After the comment-only annotation flow landed in PR #44, no new annotation row has either field set — the endpoint was unreachable for anything created after the cutover. Removing the route, the sharp-backed cropRegion helper, the unit test, sharp's pnpm "built-dependencies" entry, and every doc cross-reference (agent-loop endpoints + INDEX, api INDEX, feature catalog, stack, storage, routes, ci, frontend INDEX, task-rules, testing). Legacy annotation rows with screenshotPath + pinCoords populated will no longer be reachable through this URL — orchestrators relying on it should fetch the full screenshot via /api/annotations/[id]/screenshot. --- docs/INDEX.md | 2 +- docs/agent-loop/INDEX.md | 2 +- docs/agent-loop/endpoints.md | 27 ---------- docs/api/INDEX.md | 1 - docs/api/routes.md | 2 +- docs/api/storage.md | 7 +-- docs/ci.md | 2 +- docs/feature-catalog.md | 1 - docs/frontend/INDEX.md | 1 - docs/stack.md | 6 +-- docs/task-rules.md | 2 +- docs/testing.md | 10 +--- package.json | 4 +- pnpm-lock.yaml | 7 ++- src/app/api/annotations/[id]/region/route.ts | 55 -------------------- src/lib/region/crop.ts | 25 --------- tests/unit/lib/region/crop.test.ts | 47 ----------------- 17 files changed, 13 insertions(+), 188 deletions(-) delete mode 100644 src/app/api/annotations/[id]/region/route.ts delete mode 100644 src/lib/region/crop.ts delete mode 100644 tests/unit/lib/region/crop.test.ts diff --git a/docs/INDEX.md b/docs/INDEX.md index 7153ec1f..36a2799b 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -42,7 +42,7 @@ Start here to find which docs apply to your task. If multiple docs are relevant, - [Agent-loop INDEX](agent-loop/INDEX.md) — overview + endpoint map - [Overview](agent-loop/overview.md) — the user→agent→user cycle -- [Endpoints](agent-loop/endpoints.md) — `/intent`, `/context`, `/version-patch`, `/region`, `/diff` +- [Endpoints](agent-loop/endpoints.md) — `/intent`, `/context`, `/version-patch`, `/diff` - [Intent payload](agent-loop/intent-payload.md) — what `/intent` returns, sidecar caching, invalidation - [Patch format](agent-loop/patch-format.md) — unified-diff conventions for `/version-patch` - [Chips](agent-loop/chips.md) — G1 intent vocabulary (`visual` / `copy` / `behavior` / `other`) diff --git a/docs/agent-loop/INDEX.md b/docs/agent-loop/INDEX.md index b3f35d5d..bc0b223f 100644 --- a/docs/agent-loop/INDEX.md +++ b/docs/agent-loop/INDEX.md @@ -9,7 +9,7 @@ Silent drift in any of these endpoints breaks consumers. See the [agent-loop rul ## Read first - [Overview](overview.md) — the cycle end-to-end with byte costs -- [Endpoints](endpoints.md) — `/context`, `/version-patch`, `/region`, `/diff` +- [Endpoints](endpoints.md) — `/context`, `/version-patch`, `/diff` - [Uploads](uploads.md) — `POST /api/mockups`, `POST /api/mockups/[id]/version` (raw HTML + zip, size cap) - [Patch format](patch-format.md) — unified-diff conventions for `/version-patch` diff --git a/docs/agent-loop/endpoints.md b/docs/agent-loop/endpoints.md index 0cd07870..c0e3ca9d 100644 --- a/docs/agent-loop/endpoints.md +++ b/docs/agent-loop/endpoints.md @@ -185,33 +185,6 @@ Step 4 is orchestrator-decided. Most fix cycles do not close the mockup (more an **Rename caveat:** `name` changes the slug (the canonical URL). The owner-or-admin gate means agents can only rename mockups they themselves uploaded. If the slug changes, existing orchestrator bookmarks to `/projects//…` break. -## `GET /api/annotations/[id]/region` - -Bbox-cropped PNG of the annotation's screenshot. Sidecar-cached. - -**Auth:** cookie OR Bearer. - -**Response 200:** -- `Content-Type: image/png` -- `Cache-Control: private, max-age=300` -- Body: cropped PNG (typically 5–50 KB vs 200–700 KB for the full screenshot) - -**Errors:** - -| Status | `error` | When | -|---|---|---| -| 401 | `unauthorized` | No identity | -| 404 | `not_found` | Annotation row doesn't exist | -| 404 | `no_pin_coords` | Annotation has `pinCoords: null` (no drawn shapes) | -| 404 | `screenshot_missing` | Filesystem state corrupted | -| 500 | `invalid_pin_coords` | Stored `pinCoords` JSON is malformed | - -**Bbox source:** `Annotation.pinCoords.{bboxX, bboxY, bboxW, bboxH}`, with a fixed 20px padding around the bbox clamped at image edges. - -**No query params:** the bbox is fully derived from the stored pin coords. A future `?bbox=x,y,w,h` override would let agents request a different crop, but adding it would mean splitting the cache key — out of scope for v1.3. - -**Caching:** sidecar `region.png`. Regenerated when `screenshot.png`'s mtime is newer than `region.png`'s. Edits to `pinCoords` (none today; pinCoords are immutable per annotation) would need a cache-key extension. - ## `GET /api/mockups/[id]/diff` Text-mode unified diff between two versions of a mockup. diff --git a/docs/api/INDEX.md b/docs/api/INDEX.md index 0e9ae16c..c60ecd14 100644 --- a/docs/api/INDEX.md +++ b/docs/api/INDEX.md @@ -80,7 +80,6 @@ The Markup API is a set of Next.js App Router route handlers under `src/app/api/ | `POST` | `/api/mockups/[id]/annotations` | Create (JSON: body + anchors + colorIndex) | | `GET` | `/api/annotations/[id]` | Single annotation metadata | | `GET` | `/api/annotations/[id]/screenshot` | Full PNG screenshot | -| `GET` | `/api/annotations/[id]/region` | Bbox-cropped PNG (sidecar-cached) | | `GET` | `/api/annotations/[id]/detail` | Aggregator for `/annotations/[id]` — annotation + screenshot dims + thread + names + mockup blurb + viewerHref | ### Agent diff --git a/docs/api/routes.md b/docs/api/routes.md index 36fca7ee..774e56d7 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -233,7 +233,7 @@ Currently no route streams. When a route needs to stream a large payload (e.g. a Routes that produce expensive payloads use either: -- **Sidecar files** on disk (e.g. `intent.json`, `region.png`) keyed by `(input_mtime, current_version_id)` — see [Storage](storage.md) +- **Sidecar files** on disk (e.g. `intent.json`) keyed by `(input_mtime, current_version_id)` — see [Storage](storage.md) - **ETag headers** for in-memory aggregations (e.g. `/agent/context`) Don't add HTTP `Cache-Control: max-age=…` to mutable resources without thinking through the invalidation path; sidecars + ETag give the same effect with explicit invalidation hooks. diff --git a/docs/api/storage.md b/docs/api/storage.md index c15396cc..83324e20 100644 --- a/docs/api/storage.md +++ b/docs/api/storage.md @@ -17,8 +17,7 @@ ${DATA_DIR}/ │ └── annotations/ │ └── / │ ├── screenshot.png # base capture (immutable per annotation) -│ ├── intent.json # sidecar cache (regenerated on read) -│ └── region.png # bbox crop (regenerated on read) +│ └── intent.json # sidecar cache (regenerated on read) └── tmp/ └── version-.zip # short-lived patch composition staging ``` @@ -42,10 +41,6 @@ Routes and services compose paths via these helpers — never hardcode the layou Files derived from the primary blobs are stored as **sidecars** in the same directory. Conventions: -| Sidecar | Source | Cache key | Invalidator | -|---|---|---|---| -| `region.png` | `screenshot.png` + the annotation's `pinCoords` | `screenshot_mtime` (compared against `region.png`'s mtime) | regenerated when `screenshot.png` is newer than `region.png` | - The sidecar wrapping format for JSON caches is: ```json diff --git a/docs/ci.md b/docs/ci.md index 066be44e..199a8164 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -81,7 +81,7 @@ These rules are enforced by biome + tsc + the test suite. Violating them turns t ### Agent-loop endpoints 1. **Auth via `identify(req)`** — accepts cookie OR Bearer; returns `{kind: 'user', userId} | {kind: 'agent', tokenId}` or `null`. Never re-implement auth in a route. -2. **Sidecar files are atomic-write candidates.** Writes to `intent.json` and `region.png` go directly to disk; if a future change needs concurrency safety, write to `*.tmp` and rename. +2. **Sidecar files are atomic-write candidates.** Writes to `intent.json` go directly to disk; if a future change needs concurrency safety, write to `*.tmp` and rename. 3. **Cache invalidation runs BEFORE the new write.** When a route mutates a primary blob that has derived sidecars, it deletes the stale sidecars before writing the new blob so a concurrent reader never pairs a fresh primary with a stale sidecar. 4. **The `/context` aggregator delegates to `/intent`** by importing the GET handler directly — no HTTP loopback. This keeps tests deterministic and avoids depending on `APP_URL` being reachable from the server. diff --git a/docs/feature-catalog.md b/docs/feature-catalog.md index 3424d7cf..e6faa4a7 100644 --- a/docs/feature-catalog.md +++ b/docs/feature-catalog.md @@ -831,7 +831,6 @@ Surfaces that compose the agent automation cycle. These are API-driven but have | `agent-context-read` | Single-call context aggregator: annotation + thread + inline source + diff_since_creation + project + folder_path. ETag for short-circuit | N/A (agent-only) | `GET /api/agent/context/[annotationId]` | | `agent-version-patch` | Diff-based version update with `base_version_id`. Binary files reused by reference. 409 on conflict (stale base) | new version in `mockup-viewer-versions` | `PATCH /api/mockups/[id]/version-patch` | | `agent-mockup-patch` | Mockup-metadata mutation. All fields (`name`, `status`, `projectId`, `folderId`, `position`) are gated by `requireOwnerOrAdmin`: the caller must be the recorded `(createdBy, createdByType)` of the mockup OR an admin. Agents can rename/move/status-change mockups they uploaded; they receive 403 `forbidden_owner` on mockups created by others. Optional close-out step after the last thread on a mockup is resolved. | `mockup-status-pill`, `mockup-actions-menu` (existing UI surfaces) | `PATCH /api/mockups/[id]` | -| `agent-region-crop` | Bbox-cropped screenshot (sidecar-cached) | N/A (agent-only) | `GET /api/annotations/[id]/region` | | `agent-diff-text` | Text-mode unified or JSON diff between versions | used by `diff-viewer` | `GET /api/mockups/[id]/diff` | | `agent-thread-reply` | Agent replies in thread (`authorType: 'agent'`) | `thread-timeline-message` | `POST /api/threads/[id]/reply` | | `agent-thread-resolve` | Thread resolution | `thread-timeline-resolve-btn` | `POST /api/threads/[id]/resolve` | diff --git a/docs/frontend/INDEX.md b/docs/frontend/INDEX.md index f31445a9..7d550dbf 100644 --- a/docs/frontend/INDEX.md +++ b/docs/frontend/INDEX.md @@ -46,7 +46,6 @@ The route group `(app)` mounts `AppShell` once (via `(app)/layout.tsx`) so the s - Mockup card thumbnails are served from `/api/mockups/[id]/thumbnail`. The route serves the file when ≥ 64 bytes and a valid PNG; smaller / corrupt files trigger a 404 and the card falls back to a deterministic monogram (palette-cycled hue from a 6-entry list keyed off the mockup id) - Annotation screenshots come from `/api/annotations/[id]/screenshot` — full PNG, no transformation -- Bbox-cropped screenshots come from `/api/annotations/[id]/region` — see [`docs/agent-loop/endpoints.md`](../agent-loop/endpoints.md) ## State ownership diff --git a/docs/stack.md b/docs/stack.md index 7081fee6..70ef9fb2 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -30,7 +30,6 @@ Markup is a single-process Next.js application served from a Docker container. T ## Server-side image + DOM -- **`sharp`** for PNG cropping (`/api/annotations/[id]/region`) - **`puppeteer`** (with bundled chromium, ~150 MB) for server-side DOM resolution at the bbox the user drew (`/api/annotations/[id]/intent`) - **`diff`** + **`@types/diff`** for unified-diff apply/render (`/api/mockups/[id]/version-patch`, `/api/mockups/[id]/diff`) - **`jszip`** for in-memory zip composition when applying patches @@ -67,7 +66,7 @@ src/ app/ # Next.js App Router api/ # API routes (route.ts files) agent/context/[annotationId]/route.ts - annotations/[id]/{intent,region,screenshot,messages}/route.ts + annotations/[id]/{intent,screenshot,messages}/route.ts mockups/[id]/{version,version-patch,diff,thumbnail,annotations,versions/[vid]/{source,promote}}/route.ts threads/[id]/{reply,resolve,reopen}/route.ts auth/{login,logout,setup}/route.ts @@ -89,7 +88,6 @@ src/ diff/ # apply-unified, render-unified intent/ # parser, contrast, cache, puppeteer singleton mockup/ # service, storage, zip-extractor - region/crop.ts # sharp-based bbox crop boot.ts, env.ts, logger.ts, prisma.ts styles/tokens.css prisma/ @@ -98,7 +96,7 @@ prisma/ scripts/ # one-shot maintenance scripts (tsx-run) tests/ integration/{annotation,api,auth,lib,mockup}/*.test.ts - unit/lib/{intent,diff,region,…}/*.test.ts + unit/lib/{intent,diff,…}/*.test.ts fixtures/mockups/*.zip setup.ts docs/ # this directory diff --git a/docs/task-rules.md b/docs/task-rules.md index aac825fa..d070bc01 100644 --- a/docs/task-rules.md +++ b/docs/task-rules.md @@ -4,7 +4,7 @@ Read [`docs/INDEX.md`](INDEX.md) to find which docs apply to your task. If multiple docs are relevant, read all of them before starting. This is non-negotiable regardless of how simple the change appears. -If the change touches an agent-loop endpoint (`/intent`, `/context`, `/version-patch`, `/region`, `/diff`, or `POST /annotations`), consult [`docs/agent-loop/`](agent-loop/INDEX.md) **before** writing code. See the [agent-loop rule](../CLAUDE.md#agent-loop-rule-strict--non-negotiable). +If the change touches an agent-loop endpoint (`/intent`, `/context`, `/version-patch`, `/diff`, or `POST /annotations`), consult [`docs/agent-loop/`](agent-loop/INDEX.md) **before** writing code. See the [agent-loop rule](../CLAUDE.md#agent-loop-rule-strict--non-negotiable). If the change replicates a `tests/fixtures/mockups/.zip` fixture (lumen-coffee, helio-pricing, drone-console), follow the [mockup-replication rule](../CLAUDE.md#mockup-replication-rule-when-the-user-points-at-a-fixture). diff --git a/docs/testing.md b/docs/testing.md index 14bb66f2..1dbb31d0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -86,7 +86,7 @@ return token; App Router routes are imported and invoked as functions: ```ts -import { GET } from '@/app/api/annotations/[id]/region/route'; +import { GET } from '@/app/api/annotations/[id]/detail/route'; import { POST as createMockupRoute } from '@/app/api/mockups/route'; const res = await GET( @@ -118,14 +118,6 @@ const png = Buffer.from([ ]); ``` -For tests that need real images (`/region.png` crop, puppeteer rendering), use `sharp` to generate a buffer: - -```ts -const png = await sharp({ - create: { width: 200, height: 200, channels: 4, background: { r: 100, g: 200, b: 100, alpha: 1 } }, -}).png().toBuffer(); -``` - ## Fixtures - **`tests/fixtures/mockups/valid-simple.zip`** — 28-byte `` for tests that just need a valid zip diff --git a/package.json b/package.json index dedb141b..e40dcdae 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "react-dom": "latest", "react-icons": "^5.6.0", "server-only": "^0.0.1", - "sharp": "^0.34.5", "yauzl": "latest", "zod": "latest" }, @@ -80,8 +79,7 @@ "better-sqlite3", "esbuild", "prisma", - "puppeteer", - "sharp" + "puppeteer" ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 07b0ddf9..df31513f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: server-only: specifier: ^0.0.1 version: 0.0.1 - sharp: - specifier: ^0.34.5 - version: 0.34.5 yauzl: specifier: latest version: 3.3.0 @@ -3093,7 +3090,8 @@ snapshots: dependencies: hono: 4.12.18 - '@img/colour@1.1.0': {} + '@img/colour@1.1.0': + optional: true '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -4950,6 +4948,7 @@ snapshots: '@img/sharp-win32-arm64': 0.34.5 '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + optional: true shebang-command@2.0.0: dependencies: diff --git a/src/app/api/annotations/[id]/region/route.ts b/src/app/api/annotations/[id]/region/route.ts deleted file mode 100644 index 9588d142..00000000 --- a/src/app/api/annotations/[id]/region/route.ts +++ /dev/null @@ -1,55 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { NextResponse } from 'next/server'; -import { parsePinCoords } from '@/lib/annotation/pin-coords'; -import { identify } from '@/lib/auth/identify'; -import { env } from '@/lib/env'; -import { prisma } from '@/lib/prisma'; -import { cropRegion } from '@/lib/region/crop'; - -const PADDING = 20; - -export async function GET(req: Request, ctx: { params: Promise<{ id: string }> }) { - const ident = await identify(req); - if (!ident) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); - const { id } = await ctx.params; - const annotation = await prisma.annotation.findUnique({ where: { id } }); - if (!annotation) return NextResponse.json({ error: 'not_found' }, { status: 404 }); - if (!annotation.pinCoords) { - return NextResponse.json({ error: 'no_pin_coords' }, { status: 404 }); - } - const pin = parsePinCoords(annotation.pinCoords); - if (!pin) return NextResponse.json({ error: 'invalid_pin_coords' }, { status: 500 }); - - const screenshotAbs = path.join(env().DATA_DIR, annotation.screenshotPath); - if (!fs.existsSync(screenshotAbs)) { - return NextResponse.json({ error: 'screenshot_missing' }, { status: 404 }); - } - const annDir = path.dirname(screenshotAbs); - const sidecarPath = path.join(annDir, 'region.png'); - const screenshotMtime = fs.statSync(screenshotAbs).mtimeMs; - const sidecarMtime = fs.existsSync(sidecarPath) ? fs.statSync(sidecarPath).mtimeMs : 0; - - let body: Buffer; - if (sidecarMtime >= screenshotMtime && sidecarMtime > 0) { - body = fs.readFileSync(sidecarPath); - } else { - const src = fs.readFileSync(screenshotAbs); - body = await cropRegion(src, { - x: pin.bboxX, - y: pin.bboxY, - w: pin.bboxW, - h: pin.bboxH, - padding: PADDING, - }); - fs.writeFileSync(sidecarPath, body); - } - return new NextResponse(body as unknown as BodyInit, { - headers: { - 'Content-Type': 'image/png', - 'Cache-Control': 'private, max-age=300', - }, - }); -} - -export const dynamic = 'force-dynamic'; diff --git a/src/lib/region/crop.ts b/src/lib/region/crop.ts deleted file mode 100644 index 8307d591..00000000 --- a/src/lib/region/crop.ts +++ /dev/null @@ -1,25 +0,0 @@ -import 'server-only'; - -import sharp from 'sharp'; - -interface CropInput { - x: number; - y: number; - w: number; - h: number; - padding?: number; -} - -export async function cropRegion(src: Buffer, input: CropInput): Promise { - const padding = input.padding ?? 0; - const meta = await sharp(src).metadata(); - const imgW = meta.width ?? 0; - const imgH = meta.height ?? 0; - const left = Math.max(0, Math.floor(input.x - padding)); - const top = Math.max(0, Math.floor(input.y - padding)); - const right = Math.min(imgW, Math.ceil(input.x + input.w + padding)); - const bottom = Math.min(imgH, Math.ceil(input.y + input.h + padding)); - const width = Math.max(1, right - left); - const height = Math.max(1, bottom - top); - return sharp(src).extract({ left, top, width, height }).png().toBuffer(); -} diff --git a/tests/unit/lib/region/crop.test.ts b/tests/unit/lib/region/crop.test.ts deleted file mode 100644 index 749eaa47..00000000 --- a/tests/unit/lib/region/crop.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import sharp from 'sharp'; -import { describe, expect, it } from 'vitest'; -import { cropRegion } from '@/lib/region/crop'; - -async function makeRedSquarePng(w: number, h: number): Promise { - return sharp({ - create: { width: w, height: h, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } }, - }) - .png() - .toBuffer(); -} - -describe('cropRegion', () => { - it('crops the requested bbox with optional padding', async () => { - const src = await makeRedSquarePng(200, 200); - const out = await cropRegion(src, { x: 50, y: 50, w: 100, h: 100, padding: 10 }); - const meta = await sharp(out).metadata(); - expect(meta.width).toBe(120); // 100 + 10*2 - expect(meta.height).toBe(120); - }); - - it('clamps padding at left/top edges', async () => { - const src = await makeRedSquarePng(200, 200); - const out = await cropRegion(src, { x: 0, y: 0, w: 100, h: 100, padding: 50 }); - const meta = await sharp(out).metadata(); - // Left edge clamped to 0; right gets full padding. So width = 100 + 50 = 150. - expect(meta.width).toBe(150); - expect(meta.height).toBe(150); - }); - - it('returns the entire image when bbox + padding cover it', async () => { - const src = await makeRedSquarePng(200, 200); - const out = await cropRegion(src, { x: 0, y: 0, w: 200, h: 200, padding: 0 }); - const meta = await sharp(out).metadata(); - expect(meta.width).toBe(200); - expect(meta.height).toBe(200); - }); - - it('handles bbox at right edge clamping to image width', async () => { - const src = await makeRedSquarePng(200, 200); - const out = await cropRegion(src, { x: 150, y: 50, w: 100, h: 100, padding: 0 }); - const meta = await sharp(out).metadata(); - // Right side clamped: x=150, w=100 -> should clamp to 200 width = 50px wide - expect(meta.width).toBe(50); - expect(meta.height).toBe(100); - }); -}); From 2fe71e7b30ad8583fcd65a89826d6cbf867b646a Mon Sep 17 00:00:00 2001 From: AlexandreCamillo <43162926+AlexandreCamillo@users.noreply.github.com> Date: Thu, 28 May 2026 09:46:41 +0000 Subject: [PATCH 2/2] test(e2e): align landing demo specs with current selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two failing landing.spec.ts tests still looked for a "Drop pin" button that has not existed since the ViewerShell migration. The demo's create-annotation flow now: click "New annotation" → click inside the iframe (title="Mockup") → fill the draft textarea ("Annotation body") → click "Send" → annotation card appears with data-pin-target. Drops the window.prompt mock (the old DraftCard used prompt, the new one is a real textarea). Reset test uses the same flow to seed a 4th annotation before exercising the two-click reset. --- tests/e2e/landing.spec.ts | 49 ++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/tests/e2e/landing.spec.ts b/tests/e2e/landing.spec.ts index 2baedc8b..9390534b 100644 --- a/tests/e2e/landing.spec.ts +++ b/tests/e2e/landing.spec.ts @@ -9,35 +9,36 @@ test.describe('Landing page', () => { test('demo accepts a pin drop and persists across reload', async ({ page }) => { await page.goto('/landing#demo'); - await page.evaluate(() => { - window.prompt = () => 'Smoke test annotation'; - }); - const annotItems = page.locator('ul li[aria-label^="Annotation "]'); - const before = await annotItems.count(); - await page.getByRole('button', { name: /Drop pin/i }).click(); - const canvas = page.locator('[role="application"][aria-label*="Mockup canvas"]'); - await canvas.click({ position: { x: 200, y: 200 } }); - await expect(annotItems).toHaveCount(before + 1); + const annotCards = page.locator('[data-pin-target]'); + const before = await annotCards.count(); + + await page.getByRole('button', { name: /new annotation/i }).click(); + await page + .frameLocator('iframe[title="Mockup"]') + .locator('body') + .click({ position: { x: 200, y: 200 } }); + await page.getByRole('textbox', { name: /annotation body/i }).fill('Smoke test annotation'); + await page.getByRole('button', { name: /^send$/i }).click(); + + await expect(annotCards).toHaveCount(before + 1); await page.reload(); - await expect(annotItems).toHaveCount(before + 1); + await expect(annotCards).toHaveCount(before + 1); }); test('reset demo restores seeded state after confirmation', async ({ page }) => { await page.goto('/landing#demo'); - // window.prompt is mocked so the pin-drop flow doesn't block the test. - await page.evaluate(() => { - window.prompt = () => 'Extra annotation'; - }); - await expect(page.locator('ul li[aria-label^="Annotation "]')).toHaveCount(3); + const annotCards = page.locator('[data-pin-target]'); + await expect(annotCards).toHaveCount(3); - // Drop a 4th pin via the UI so reset has something to clear. This avoids - // the previous approach of editing localStorage + reloading (which caused - // a React hydration mismatch because the server-rendered tree used - // seeded state while the client read the populated localStorage). - await page.getByRole('button', { name: /Drop pin/i }).click(); - const canvas = page.locator('[role="application"][aria-label*="Mockup canvas"]'); - await canvas.click({ position: { x: 150, y: 150 } }); - await expect(page.locator('ul li[aria-label^="Annotation "]')).toHaveCount(4); + // Drop a 4th annotation via the draft flow so reset has something to clear. + await page.getByRole('button', { name: /new annotation/i }).click(); + await page + .frameLocator('iframe[title="Mockup"]') + .locator('body') + .click({ position: { x: 150, y: 150 } }); + await page.getByRole('textbox', { name: /annotation body/i }).fill('Extra annotation'); + await page.getByRole('button', { name: /^send$/i }).click(); + await expect(annotCards).toHaveCount(4); // Two-step confirm: first click arms the confirm, second click resets. // Match BOTH the rest-state ("Reset demo") and the armed-state @@ -46,7 +47,7 @@ test.describe('Landing page', () => { const reset = page.getByRole('button', { name: /Reset|Click again/i }); await reset.click(); await reset.click(); - await expect(page.locator('ul li[aria-label^="Annotation "]')).toHaveCount(3); + await expect(annotCards).toHaveCount(3); }); test('no horizontal scroll on mobile viewport', async ({ page }) => {