diff --git a/v5/AGENTS.md b/v5/AGENTS.md new file mode 100644 index 0000000..09c3be0 --- /dev/null +++ b/v5/AGENTS.md @@ -0,0 +1,95 @@ +# AGENTS.md — MakerLab Tools v5 + +Guidance for AI agents (and humans) working in the **v5** app. This is the +active application. Everything below is scoped to the `v5/` directory. + +> [!IMPORTANT] +> The **root `CLAUDE.md` describes v4** — an AirTable-backed app with +> `AIRTABLE_TABLE_*` env vars. **That does not apply to v5.** v5's data layer is +> **Notion** (`NOTION_DB_*`). When working in `v5/`, follow this file, not the +> root v4 doc. + +## What this is + +A digital inventory + discovery app for makerspace equipment: browse/search a +tool gallery, view tool detail pages, chat with an AI assistant (tool-aware, +can look up units and file maintenance tickets), and an MCP endpoint exposing +the catalog to external agents. White-labelled via env vars. + +## Stack + +- **Next.js 16** (App Router, React Server Components, `cacheComponents` enabled), **React 19**, **TypeScript**, **Tailwind CSS 4**. +- **i18n:** `next-intl`, **12 locales**, cookie-based (`NEXT_LOCALE`) — no URL-prefix routing. Config in `src/i18n/config.ts`; messages in `messages/*.json`. +- **AI:** Vercel **AI SDK v6** (`ai`, `@ai-sdk/react`) with `@ai-sdk/anthropic` → `claude-sonnet-4-6`. Markdown via `react-markdown` + `remark-gfm`. +- **MCP:** `@modelcontextprotocol/sdk` (HTTP JSON-RPC server at `/api/mcp`). +- **Validation:** `zod`. **Search:** `match-sorter` (fuzzy, ranked). + +## Data layer — Notion (not AirTable) + +Seven Notion databases, IDs supplied via env (`NOTION_DB_TOOLS`, `_CATEGORIES`, +`_LOCATIONS`, `_UNITS`, `_RESOURCES`, `_MAINTENANCE_LOGS`, `_FLAGS`) plus +`NOTION_API_KEY`. See `.env.example` for the full list and defaults. + +- `src/lib/notion.ts` — server-only Notion REST client: page→record parsers, pagination, 429 retry, `createMaintenanceLog`. Tolerant of snake_case **and** Title-case property names. +- `src/lib/catalog.ts` — orchestration: fetch + join + derive `MakerLabTool`s, cached with `cacheTag("catalog")` / `cacheLife("minutes")`. +- **Mock-catalog fallback (important):** `getCatalogTools()` / `getCatalogTool(id)` return the built-in `src/components/mock-catalog.ts` data whenever `hasNotionCatalogEnv()` is false (**any** Notion env var missing) **or** a fetch throws. So the app — and the whole E2E/test suite — runs with no Notion creds. + +## Key files + +| Path | Purpose | +|---|---| +| `src/lib/site-config.ts` | White-label branding (env-driven, all have defaults) | +| `src/lib/notion.ts` | Notion API client (server-only) | +| `src/lib/catalog.ts` | Catalog orchestration + cache + mock fallback | +| `src/lib/rate-limit.ts` | In-memory (or Upstash) sliding-window limiter | +| `src/lib/types.ts` / `src/components/catalog-types.ts` | Notion record types / resolved view types | +| `src/app/api/chat/route.ts` | Claude chat: streaming, tools (`get_unit_details`, `report_issue`, `web_fetch`), PDF manual attach | +| `src/app/api/mcp/route.ts` | MCP JSON-RPC server (5 tools), bearer-token auth | +| `src/app/api/upload-notion/route.ts` | Image upload proxy → Notion file_uploads | +| `src/app/api/admin/revalidate/route.ts` | Cache invalidation (`x-admin-secret`) | +| `src/components/ChatFab.tsx` | Chat UI (`useChat`, citations stripped, photo upload) | +| `src/app/page.tsx`, `tools/[id]/page.tsx` | Gallery + tool detail | + +## Conventions + +- Use the `@/` import alias (→ `src/`). +- Server components by default; add `"use client"` only when needed. +- Server-only modules import `"server-only"` (e.g. `rate-limit.ts`). +- Theme/brand via **CSS variables** (`--primary`, `--background`, …) — `[data-theme="light|dark"]` on ``, never hardcoded colors. +- All branding strings come from `siteConfig` (`@/lib/site-config`). +- Every API route is **rate-limited by IP** before expensive work. +- Maintenance tickets are always written in **English** even when the chat replies in another locale. + +## Testing + +Comprehensive, **fully-mocked** suite (no live Notion/Anthropic/Redis). Run +everything with one command: + +```bash +npm run test:all # lint + typecheck + vitest + playwright +``` + +Or individually: `npm test` (Vitest: unit + integration + component), +`npm run test:e2e` (Playwright — run `npx playwright install chromium` once +first), `npm run test:coverage`. + +- **Vitest** (jsdom) + React Testing Library + MSW; **Playwright** for E2E. +- E2E boots its own server on **port 3100** with `NOTION_*` unset (mock catalog) and intercepts `/api/chat` — it never touches your `:3000` dev server or real services. +- Tests are colocated (`*.test.ts(x)` next to source); shared harness in `test/`. +- **Read these before writing tests:** `TESTING.md` (runbook), `test/README.md` (harness internals + the `streamText`-capture and env-stubbing patterns), and `docs/superpowers/specs/2026-05-29-v5-test-suite-design.md` (design + coverage matrix). The harness deps/scripts are already wired — don't hand-edit `package.json` for them. + +## Commands + +```bash +npm run dev # dev server (:3000) +npm run build # production build +npm run lint # eslint +npm run typecheck # tsc --noEmit +npm run test:all # full test suite +``` + +## Gotchas + +- Because `cacheComponents` is enabled, API routes **cannot set `runtime`** — they use the default Node runtime. +- The in-memory rate limiter is a per-process singleton; it resets on cold start (fine for abuse prevention). Upstash backs it only when **both** `UPSTASH_REDIS_REST_*` vars are set. +- Python scripts under `scripts/` use Node with `--experimental-strip-types`; they are migration/maintenance tools, not part of the app build. diff --git a/v5/e2e/dark-mode.spec.ts b/v5/e2e/dark-mode.spec.ts new file mode 100644 index 0000000..5957b91 --- /dev/null +++ b/v5/e2e/dark-mode.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from "@playwright/test"; + +// Guards the tool-detail dark-mode work (#23). The ThemeToggle cycles +// system → light → dark; we click until the explicit dark theme is active, +// then assert the dark design tokens actually resolve on a tool-detail page. +// This protects the token wiring (not every pixel) from regressions. + +const DARK_BACKGROUND = "#0f0f0f"; // --background under [data-theme="dark"] + +async function cycleToDark(page: import("@playwright/test").Page) { + const toggle = page.getByRole("button", { + name: "Cycle color theme (system → light → dark)", + }); + // At most 3 clicks reaches "dark" from any starting point in the cycle. + for (let i = 0; i < 3; i++) { + const theme = await page.evaluate(() => + document.documentElement.getAttribute("data-theme") + ); + if (theme === "dark") return; + await toggle.click(); + } +} + +test.describe("Dark mode", () => { + test("tool-detail page resolves dark design tokens when dark is active", async ({ + page, + }) => { + await page.goto("/tools/form-4"); + await expect( + page.getByRole("heading", { name: "Form 4", level: 1 }) + ).toBeVisible(); + + await cycleToDark(page); + + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + + const background = await page.evaluate(() => + getComputedStyle(document.documentElement) + .getPropertyValue("--background") + .trim() + ); + expect(background).toBe(DARK_BACKGROUND); + }); + + test("dark theme persists across a reload", async ({ page }) => { + await page.goto("/tools/form-4"); + await cycleToDark(page); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + + await page.reload(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + }); +}); diff --git a/v5/package-lock.json b/v5/package-lock.json index 64367ff..bd7e799 100644 --- a/v5/package-lock.json +++ b/v5/package-lock.json @@ -32,6 +32,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^9", "eslint-config-next": "16.1.6", "jsdom": "^29.1.1", @@ -442,6 +443,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3789,17 +3800,49 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", + "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.8", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.8", + "vitest": "4.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", - "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3808,13 +3851,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", - "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.7", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3835,9 +3878,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", - "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { @@ -3848,13 +3891,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", - "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.7", + "@vitest/utils": "4.1.8", "pathe": "^2.0.3" }, "funding": { @@ -3862,14 +3905,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", - "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3878,9 +3921,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", - "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", "funding": { @@ -3888,13 +3931,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", - "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.7", + "@vitest/pretty-format": "4.1.8", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -4233,6 +4276,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -6399,6 +6461,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7074,6 +7143,45 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -7137,7 +7245,6 @@ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", @@ -7639,6 +7746,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -8636,7 +8784,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", @@ -11422,19 +11569,19 @@ } }, "node_modules/vitest": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", - "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.7", - "@vitest/mocker": "4.1.7", - "@vitest/pretty-format": "4.1.7", - "@vitest/runner": "4.1.7", - "@vitest/snapshot": "4.1.7", - "@vitest/spy": "4.1.7", - "@vitest/utils": "4.1.7", + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -11462,12 +11609,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.7", - "@vitest/browser-preview": "4.1.7", - "@vitest/browser-webdriverio": "4.1.7", - "@vitest/coverage-istanbul": "4.1.7", - "@vitest/coverage-v8": "4.1.7", - "@vitest/ui": "4.1.7", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/v5/package.json b/v5/package.json index a23d659..88c4022 100644 --- a/v5/package.json +++ b/v5/package.json @@ -43,6 +43,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.2", + "@vitest/coverage-v8": "^4.1.8", "eslint": "^9", "eslint-config-next": "16.1.6", "jsdom": "^29.1.1", diff --git a/v5/src/components/ChatFab.test.tsx b/v5/src/components/ChatFab.test.tsx index 625b2e2..d95fe17 100644 --- a/v5/src/components/ChatFab.test.tsx +++ b/v5/src/components/ChatFab.test.tsx @@ -290,3 +290,215 @@ describe("ChatFab", () => { expect(typeof opts.onData).toBe("function"); }); }); + +// ── Citation stripping (#22) ─────────────────────────────────────── +// +// The assistant grounds answers with inline markup. +// react-markdown has no raw-HTML plugin, so without stripping these would +// render as literal text. Assert the tags are removed but the prose survives. +describe("ChatFab — tag stripping", () => { + async function open(user: ReturnType) { + await user.click( + screen.getByRole("button", { name: "Open MakerLab assistant" }) + ); + } + + it("removes a tag (with attributes) while keeping the cited prose", async () => { + const user = userEvent.setup(); + useChatReturn = baseReturn({ + messages: [ + userMsg("u1", "how do I cut acrylic?"), + assistantMsg( + "a1", + 'Use the laser cutter after safety training first.' + ), + ], + }); + render(); + await open(user); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveTextContent( + "Use the laser cutter after safety training first." + ); + expect(dialog.textContent).not.toContain(""); + expect(dialog.textContent).not.toContain("index="); + }); + + it("removes multiple tags in one message", async () => { + const user = userEvent.setup(); + useChatReturn = baseReturn({ + messages: [ + assistantMsg( + "a1", + 'First and second point.' + ), + ], + }); + render(); + await open(user); + + const dialog = screen.getByRole("dialog"); + expect(dialog).toHaveTextContent("First and second point."); + expect(dialog.textContent).not.toContain("cite"); + }); + + it("leaves a message without citations untouched", async () => { + const user = userEvent.setup(); + useChatReturn = baseReturn({ + messages: [assistantMsg("a1", "Just plain advice, no sources.")], + }); + render(); + await open(user); + + expect( + screen.getByText("Just plain advice, no sources.") + ).toBeInTheDocument(); + }); +}); + +// ── Pending tool-call status ─────────────────────────────────────── +// +// While a tool call is mid-flight (state !== "output-available") and the +// assistant message has no text yet, the UI shows a per-tool status line. +describe("ChatFab — pending tool-call status", () => { + function toolMsg(id: string, toolType: string, state = "input-available") { + return { + id, + role: "assistant" as const, + parts: [{ type: toolType, state }], + }; + } + + async function openWith(messages: UseChatReturn["messages"]) { + const user = userEvent.setup(); + useChatReturn = baseReturn({ messages }); + render(); + await user.click( + screen.getByRole("button", { name: "Open MakerLab assistant" }) + ); + } + + it("shows the unit-lookup label for a pending get_unit_details call", async () => { + await openWith([ + userMsg("u1", "is Prusa #1 ok?"), + toolMsg("a1", "tool-get_unit_details"), + ]); + expect( + screen.getByText("🔍 Looking up unit details…") + ).toBeInTheDocument(); + expect(screen.getByLabelText("Tool running")).toBeInTheDocument(); + }); + + it("shows the ticket-filing label for a pending report_issue call", async () => { + await openWith([toolMsg("a1", "tool-report_issue")]); + expect( + screen.getByText("📝 Filing maintenance ticket…") + ).toBeInTheDocument(); + }); + + it("shows a generic working label for any other pending tool", async () => { + await openWith([toolMsg("a1", "tool-web_fetch")]); + expect(screen.getByText("Working on it…")).toBeInTheDocument(); + }); +}); + +// ── Photo upload + attachment hint ───────────────────────────────── +// +// Selecting an image uploads it to /api/upload-notion, shows a removable +// preview, and on submit appends a parseable [Attached photos: …] hint that +// the chat route turns into report_issue photo_uploads. +describe("ChatFab — photo uploads", () => { + let origCreate: typeof URL.createObjectURL; + let origRevoke: typeof URL.revokeObjectURL; + + beforeEach(() => { + origCreate = URL.createObjectURL; + origRevoke = URL.revokeObjectURL; + URL.createObjectURL = vi.fn(() => "blob:preview"); + URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + URL.createObjectURL = origCreate; + URL.revokeObjectURL = origRevoke; + vi.unstubAllGlobals(); + }); + + it("uploads an image and includes its file_upload hint in the sent message", async () => { + const user = userEvent.setup(); + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ file_upload_id: "fu_123", name: "broken.png" }), + { status: 200, headers: { "content-type": "application/json" } } + ) + ); + vi.stubGlobal("fetch", fetchMock); + render(); + + await user.click( + screen.getByRole("button", { name: "Open MakerLab assistant" }) + ); + + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + const file = new File([new Uint8Array([1, 2, 3])], "broken.png", { + type: "image/png", + }); + await user.upload(fileInput, file); + + // Preview + remove control appear once the upload resolves. + expect( + await screen.findByRole("button", { name: "Remove broken.png" }) + ).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/upload-notion", + expect.objectContaining({ method: "POST" }) + ); + + const input = screen.getByRole("textbox", { name: "Ask the lab console" }); + await user.type(input, "the printer is broken"); + await user.click(screen.getByRole("button", { name: "Send" })); + + expect(sendMessage).toHaveBeenCalledTimes(1); + const arg = sendMessage.mock.calls[0][0] as { text: string }; + expect(arg.text).toContain("the printer is broken"); + expect(arg.text).toContain( + "[Attached photos: file_upload_id=fu_123 name=broken.png]" + ); + }); + + it("surfaces an upload error and does not add a preview", async () => { + const user = userEvent.setup(); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ error: "File too large (max 18MB)" }), { + status: 400, + headers: { "content-type": "application/json" }, + }) + ); + vi.stubGlobal("fetch", fetchMock); + render(); + + await user.click( + screen.getByRole("button", { name: "Open MakerLab assistant" }) + ); + const fileInput = document.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + await user.upload( + fileInput, + new File([new Uint8Array([1])], "huge.png", { type: "image/png" }) + ); + + expect( + await screen.findByText("File too large (max 18MB)") + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /^Remove / }) + ).not.toBeInTheDocument(); + }); +});