diff --git a/bun.lock b/bun.lock index e6f60e4cd2..8473ee948d 100644 --- a/bun.lock +++ b/bun.lock @@ -130,6 +130,7 @@ "workbox-window": "^7.4.0", }, "devDependencies": { + "@playwright/test": "1.49.1", "@tailwindcss/postcss": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -714,6 +715,8 @@ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], + "@playwright/test": ["@playwright/test@1.49.1", "", { "dependencies": { "playwright": "1.49.1" }, "bin": { "playwright": "cli.js" } }, "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g=="], + "@primer/octicons": ["@primer/octicons@19.23.1", "", { "dependencies": { "object-assign": "^4.1.1" } }, "sha512-CzjGmxkmNhyst6EekrS3SJPdtzgIkUMP/LSJch65y99/kmiFXbO1a+q7zoYe3hnI9NaOM0IN+ydDIbOmd8YqcA=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], diff --git a/docs/tooling/mermaid-lightbox-dogfood.md b/docs/tooling/mermaid-lightbox-dogfood.md new file mode 100644 index 0000000000..081f07e4f3 --- /dev/null +++ b/docs/tooling/mermaid-lightbox-dogfood.md @@ -0,0 +1,56 @@ +# Mermaid lightbox dogfood (Playwright) + +Two Playwright targets: + +| Target | What it exercises | Command | +|--------|-------------------|---------| +| **Component (Vite)** | `MermaidDiagram` in isolation on dev server | `npm run test:mermaid-lightbox:playwright` | +| **Live session (hub)** | Real chat thread, click-to-zoom | `npm run test:mermaid-lightbox:live` | + +## Live session (production-shaped) + +**Session URL (after seed):** + +`{HAPI_URL}/sessions/a7370000-0000-4000-8000-000000000737` + +Default `HAPI_URL` for live tests: `http://127.0.0.1:3006` (daily driver). +For tailnet: `HAPI_URL=https://hapi.tail9944ee.ts.net` (seed **that** hub's DB first). + +### 1. Seed fixtures (hub DB) + +On the machine that owns `HAPI_DB_PATH` (usually `~/.hapi/hapi.db`): + +```bash +bun run seed:mermaid-lightbox:session +``` + +Inserts 15 assistant messages (one per diagram type). Re-run to replace messages in that session. + +### 2. Deploy web with your branch + +```bash +hapi-driver-rebuild --build-web +# activate soup when ready (restarts hub) +``` + +Hard-refresh the browser after web changes. + +### 3. Run live Playwright + +```bash +HAPI_LIVE=1 HAPI_URL=http://127.0.0.1:3006 npm run test:mermaid-lightbox:live +``` + +Requires `~/.hapi/settings.json` `cliApiToken` (or `HAPI_ACCESS_TOKEN`). + +**Pass criteria:** dialog opens, SVG in **shadow root** (`[data-mermaid-lightbox]`), expands vs inline, sequence has multiple actors/lines. + +If tests report `legacy` or `empty` lightbox, the served web bundle predates the shadow-DOM fix — rebuild driver. + +## Isolation page (not chat) + +Only for component regression; **not** the same as chat: + +`http://127.0.0.1:5173/mermaid-lightbox-e2e.html?case=sequence` (Vite dev, not on tailnet dist unless you add the HTML to a build). + +Diagram sources: `web/src/dev/mermaid-lightbox-cases.ts` diff --git a/package.json b/package.json index 3d57f453e6..650dc65d74 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "test:hub": "cd hub && bun run test", "test:web": "cd web && bun run test", "test:shared": "cd shared && bun run test", + "test:mermaid-lightbox:playwright": "timeout 600 node scripts/dev/mermaid-lightbox-playwright.mjs", + "test:mermaid-lightbox:live": "timeout 900 env HAPI_LIVE=1 playwright test -c web/playwright.live.config.ts", + "seed:mermaid-lightbox:session": "bun run scripts/dev/mermaid-lightbox-seed-session-db.ts", "clean-session": "bun run hub/scripts/cleanup-sessions.ts", "release-all": "cd cli && bun run release-all" }, diff --git a/scripts/dev/mermaid-lightbox-live-playwright.mjs b/scripts/dev/mermaid-lightbox-live-playwright.mjs new file mode 100644 index 0000000000..e137029cd1 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-live-playwright.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** Bounded wrapper: Playwright against a real HAPI chat session (no Vite). */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:live'], + { + cwd: REPO_ROOT, + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-playwright.mjs b/scripts/dev/mermaid-lightbox-playwright.mjs new file mode 100644 index 0000000000..6914ef73e1 --- /dev/null +++ b/scripts/dev/mermaid-lightbox-playwright.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/** + * Bounded wrapper for mermaid lightbox Playwright (web/e2e). + * Vite lifecycle is owned by web/playwright.config.ts webServer — not this process. + * + * Usage (from repo root): + * npm run test:mermaid-lightbox:playwright + */ +import { spawnSync } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const WEB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../web') +const npmBin = process.env.NPM_BIN ?? 'npm' + +const result = spawnSync( + npmBin, + ['run', 'test:mermaid-lightbox:e2e'], + { + cwd: WEB_DIR, + stdio: 'inherit', + env: { ...process.env, PATH: process.env.PATH }, + }, +) + +process.exit(result.status === null ? 1 : result.status) diff --git a/scripts/dev/mermaid-lightbox-seed-session-db.ts b/scripts/dev/mermaid-lightbox-seed-session-db.ts new file mode 100644 index 0000000000..8b2fa9181b --- /dev/null +++ b/scripts/dev/mermaid-lightbox-seed-session-db.ts @@ -0,0 +1,85 @@ +/** + * Seed assistant messages with mermaid fixtures into a hub SQLite DB. + * Run on the host that owns HAPI_DB_PATH (usually the hub machine). + * + * HAPI_DB_PATH=~/.hapi/hapi.db SESSION_ID= bun run scripts/dev/mermaid-lightbox-seed-session-db.ts + */ +import { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, +} from '../../web/src/dev/mermaid-lightbox-cases' + +const dbPath = process.env.HAPI_DB_PATH ?? join(homedir(), '.hapi', 'hapi.db') +/** Stable id for mermaid Playwright live session (create if missing). */ +const sessionId = process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +const sessionTag = 'mermaid-lightbox-e2e' + +function agentMermaidEnvelope(caseId: string, code: string) { + const text = `\n\`\`\`mermaid\n${code.trim()}\n\`\`\`` + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + uuid: randomUUID(), + parentUuid: null, + isSidechain: false, + message: { + content: [{ type: 'text', text }], + }, + }, + }, + } +} + +const db = new Database(dbPath) +const now = Date.now() +const existing = db.prepare('SELECT id, tag FROM sessions WHERE id = ?').get(sessionId) as + | { id: string; tag: string | null } + | undefined + +if (existing && existing.tag !== sessionTag) { + throw new Error( + `Refusing to seed mermaid fixtures into session ${sessionId}: tag is ` + + `${JSON.stringify(existing.tag)}, expected ${JSON.stringify(sessionTag)}. ` + + `Unset SESSION_ID or use a session created by this script.`, + ) +} + +if (!existing) { + db.prepare(` + INSERT INTO sessions ( + id, tag, namespace, created_at, updated_at, active, seq + ) VALUES (?, ?, 'default', ?, ?, 0, 0) + `).run(sessionId, sessionTag, now, now) + console.log(`created session ${sessionId} (${sessionTag})`) +} + +const insert = db.prepare(` + INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at) + VALUES (?, ?, ?, ?, ?, NULL, ?, NULL) +`) + +db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId) + +let seqRow = db.prepare('SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?').get(sessionId) as { + maxSeq: number +} + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + const code = MERMAID_LIGHTBOX_CASES[caseId] + const envelope = agentMermaidEnvelope(caseId, code) + const seq = (seqRow.maxSeq ?? 0) + 1 + seqRow = { maxSeq: seq } + const messageId = randomUUID() + insert.run(messageId, sessionId, JSON.stringify(envelope), now, seq, now) + console.log(`seeded ${caseId} @ seq ${seq}`) +} + +db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(now, sessionId) +console.log(`Done. Open: /sessions/${sessionId}`) diff --git a/web/.gitignore b/web/.gitignore index 9ba881afd8..f9f5a543f4 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,3 +1,4 @@ node_modules/ dist/ dev-dist/ +test-results/ diff --git a/web/e2e/helpers/hapi-live.ts b/web/e2e/helpers/hapi-live.ts new file mode 100644 index 0000000000..60f5aef2f5 --- /dev/null +++ b/web/e2e/helpers/hapi-live.ts @@ -0,0 +1,82 @@ +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { Page } from '@playwright/test' + +export function getHapiBaseUrl(): string { + return (process.env.HAPI_URL ?? 'http://127.0.0.1:3006').replace(/\/$/, '') +} + +export function getMermaidTestSessionId(): string { + return process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737' +} + +export function readCliAccessToken(): string { + if (process.env.HAPI_ACCESS_TOKEN?.trim()) { + return process.env.HAPI_ACCESS_TOKEN.trim() + } + const settingsPath = process.env.HAPI_SETTINGS_PATH ?? join(homedir(), '.hapi', 'settings.json') + const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { cliApiToken?: string } + if (!settings.cliApiToken) { + throw new Error(`Missing cliApiToken in ${settingsPath}`) + } + return settings.cliApiToken +} + +export async function installHapiAuth(page: Page, baseUrl: string, accessToken: string) { + await page.addInitScript(({ token, url }) => { + localStorage.setItem(`hapi_access_token::${url}`, token) + }, { token: accessToken, url: baseUrl }) +} + +export async function scrollChatToBottom(page: Page) { + for (let i = 0; i < 24; i += 1) { + const found = await page.locator('[data-mermaid-diagram][data-rendered="true"]').count() + if (found > 0) break + await page.evaluate(() => { + const scrollers = [...document.querySelectorAll('*')].filter( + (el) => el.scrollHeight > el.clientHeight + 80, + ) + scrollers.sort((a, b) => b.scrollHeight - a.scrollHeight) + const target = scrollers[0] + if (target) target.scrollTop = target.scrollHeight + window.scrollTo(0, document.body.scrollHeight) + }) + await page.waitForTimeout(400) + } +} + +export type LiveLightboxMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + hasShadowSvg: boolean + shapeTotal: number + coverage: number +} + +export async function readLiveLightboxMetrics(page: Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = + (lightboxSvg?.querySelectorAll('rect').length ?? 0) + + (lightboxSvg?.querySelectorAll('path').length ?? 0) + + (lightboxSvg?.querySelectorAll('line').length ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + hasShadowSvg: Boolean(lightboxSvg), + shapeTotal: shapes, + coverage: Math.max((lightboxBox?.width ?? 0) / vw, (lightboxBox?.height ?? 0) / vh), + } + }) +} diff --git a/web/e2e/mermaid-lightbox-session.spec.ts b/web/e2e/mermaid-lightbox-session.spec.ts new file mode 100644 index 0000000000..f5af7c9e59 --- /dev/null +++ b/web/e2e/mermaid-lightbox-session.spec.ts @@ -0,0 +1,107 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' +import { + getHapiBaseUrl, + getMermaidTestSessionId, + installHapiAuth, + readCliAccessToken, + readLiveLightboxMetrics, + scrollChatToBottom, +} from './helpers/hapi-live' + +const liveEnabled = process.env.HAPI_LIVE === '1' +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_EXPAND_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.25') + +test.describe.configure({ mode: 'serial' }) + +test.describe('mermaid lightbox (live HAPI session)', () => { + test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session') + + test.beforeAll(() => { + readCliAccessToken() + }) + + for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`live session lightbox: ${caseId}`, async ({ page }) => { + const baseUrl = getHapiBaseUrl() + const sessionId = getMermaidTestSessionId() + const token = readCliAccessToken() + + await installHapiAuth(page, baseUrl, token) + await page.goto(`${baseUrl}/sessions/${sessionId}`, { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }) + await page.waitForTimeout(2000) + await scrollChatToBottom(page) + + const diagramIndex = MERMAID_LIGHTBOX_CASE_IDS.indexOf(caseId) + const rendered = page.locator('[data-mermaid-diagram][data-rendered="true"]') + await expect( + rendered, + `Expected ${MERMAID_LIGHTBOX_CASE_IDS.length} seeded diagrams. Run: bun run seed:mermaid-lightbox:session`, + ).toHaveCount(MERMAID_LIGHTBOX_CASE_IDS.length, { timeout: 20_000 }) + + const diagram = rendered.nth(diagramIndex) + + await diagram.scrollIntoViewIfNeeded() + const before = await diagram.evaluate((el) => { + const svg = el.querySelector('svg') + const box = svg?.getBoundingClientRect() + return { inlineW: box?.width ?? 0, inlineH: box?.height ?? 0 } + }) + + await diagram.click({ timeout: 15_000 }) + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + const lightboxKind = await page.waitForFunction(() => { + const dialog = document.querySelector('[role="dialog"]') + if (!dialog) return 'no-dialog' + const shadowSvg = dialog.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + if (shadowSvg) { + const box = shadowSvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'shadow' + } + const legacySvg = dialog.querySelector('.rounded-lg svg') + if (legacySvg) { + const box = legacySvg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) return 'legacy' + } + return 'empty' + }, { timeout: 15_000 }).then((h) => h.jsonValue() as Promise) + + expect( + lightboxKind, + `${caseId}: expected shadow-DOM lightbox (rebuild driver web after feat/mermaid-lightbox-737)`, + ).toBe('shadow') + + const after = await readLiveLightboxMetrics(page) + const areaRatio = + before.inlineW > 0 && before.inlineH > 0 + ? (after.lightboxW * after.lightboxH) / (before.inlineW * before.inlineH) + : 0 + + expect(after.hasShadowSvg, `${caseId}: shadow SVG in live chat`).toBe(true) + expect(after.shapeTotal, `${caseId}: diagram shapes`).toBeGreaterThan(0) + expect(after.coverage, `${caseId}: viewport coverage`).toBeGreaterThanOrEqual(MIN_COVERAGE) + expect( + areaRatio >= MIN_EXPAND_RATIO || after.lightboxW > before.inlineW * 1.05, + `${caseId}: expand ${areaRatio.toFixed(2)}x inline ${Math.round(before.inlineW)}x${Math.round(before.inlineH)} → lightbox ${Math.round(after.lightboxW)}x${Math.round(after.lightboxH)}`, + ).toBe(true) + + if (caseId === 'sequence') { + const seqShapes = await page.evaluate(() => { + const svg = document.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg') + return { + rect: svg?.querySelectorAll('rect').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + }) + expect(seqShapes.rect >= 2 || seqShapes.line >= 2, `${caseId}: sequence content`).toBe(true) + } + + await page.keyboard.press('Escape') + await page.waitForSelector('[role="dialog"]', { state: 'detached', timeout: 5000 }).catch(() => undefined) + }) + } +}) diff --git a/web/e2e/mermaid-lightbox.spec.ts b/web/e2e/mermaid-lightbox.spec.ts new file mode 100644 index 0000000000..35c9c93202 --- /dev/null +++ b/web/e2e/mermaid-lightbox.spec.ts @@ -0,0 +1,156 @@ +import { expect, test } from '@playwright/test' +import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases' + +const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35') +const MIN_SVG_PX = Number(process.env.MERMAID_E2E_MIN_SVG_PX ?? '200') + +type LightboxMetrics = { + hasShadowSvg: boolean + usesDataUrlImg: boolean + svgW: number + svgH: number + coverageW: number + coverageH: number + shapeTotal: number + shapes: { rect: number; path: number; line: number } +} + +type ExpandMetrics = { + inlineW: number + inlineH: number + lightboxW: number + lightboxH: number + areaRatio: number +} + +async function readExpandMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg') + const inlineBox = inlineSvg?.getBoundingClientRect() + const host = document.querySelector('[data-mermaid-lightbox]') + const lightboxSvg = host?.shadowRoot?.querySelector('svg') + const lightboxBox = lightboxSvg?.getBoundingClientRect() + const inlineArea = (inlineBox?.width ?? 0) * (inlineBox?.height ?? 0) + const lightboxArea = (lightboxBox?.width ?? 0) * (lightboxBox?.height ?? 0) + return { + inlineW: inlineBox?.width ?? 0, + inlineH: inlineBox?.height ?? 0, + lightboxW: lightboxBox?.width ?? 0, + lightboxH: lightboxBox?.height ?? 0, + areaRatio: inlineArea > 0 ? lightboxArea / inlineArea : 0, + } + }) +} + +async function readLightboxMetrics(page: import('@playwright/test').Page): Promise { + return page.evaluate(() => { + const dialog = document.querySelector('[role="dialog"]') + const host = dialog?.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + const shapes = { + rect: svg?.querySelectorAll('rect').length ?? 0, + path: svg?.querySelectorAll('path').length ?? 0, + line: svg?.querySelectorAll('line').length ?? 0, + } + const shapeTotal = + shapes.rect + + shapes.path + + shapes.line + + (svg?.querySelectorAll('text').length ?? 0) + + (svg?.querySelectorAll('circle').length ?? 0) + return { + hasShadowSvg: Boolean(svg), + usesDataUrlImg: Boolean(dialog?.querySelector('img[src^="data:image/svg"]')), + svgW: box?.width ?? 0, + svgH: box?.height ?? 0, + coverageW: (box?.width ?? 0) / vw, + coverageH: (box?.height ?? 0) / vh, + shapeTotal, + shapes, + } + }) +} + +function assertMetrics(caseId: string, metrics: LightboxMetrics) { + expect(metrics.hasShadowSvg, `${caseId}: shadow-root svg`).toBe(true) + expect(metrics.usesDataUrlImg, `${caseId}: data-url img regression`).toBe(false) + expect( + metrics.svgW >= MIN_SVG_PX || metrics.svgH >= MIN_SVG_PX, + `${caseId}: svg ${Math.round(metrics.svgW)}x${Math.round(metrics.svgH)}px`, + ).toBe(true) + const coverage = Math.max(metrics.coverageW, metrics.coverageH) + const wideShort = caseId === 'gantt' || caseId === 'kanban' + if (wideShort) { + expect( + metrics.coverageW >= MIN_COVERAGE || metrics.svgW >= 600, + `${caseId}: wide chart width cov ${(metrics.coverageW * 100).toFixed(0)}%`, + ).toBe(true) + } else { + expect(coverage, `${caseId}: viewport coverage ${(coverage * 100).toFixed(0)}%`).toBeGreaterThanOrEqual( + MIN_COVERAGE, + ) + } + expect(metrics.shapeTotal, `${caseId}: shape count`).toBeGreaterThan(0) + if (caseId === 'sequence') { + expect( + metrics.shapes.rect >= 2 || metrics.shapes.line >= 2, + `${caseId}: sequence actors/lines`, + ).toBe(true) + } +} + +/** Lightbox should be visibly larger than the inline chat preview after click. */ +const MIN_EXPAND_AREA_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.4') + +for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) { + test(`mermaid lightbox: ${caseId}`, async ({ page }) => { + await page.goto(`/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`) + await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 }) + + const beforeExpand = await readExpandMetrics(page) + expect(beforeExpand.lightboxW, `${caseId}: dialog closed before click`).toBe(0) + + await page.locator('[data-mermaid-diagram][data-rendered="true"]').click() + await page.waitForSelector('[role="dialog"]', { timeout: 10_000 }) + await page.waitForFunction(() => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + return Boolean(box && box.width > 0 && box.height > 0) + }, { timeout: 15_000 }) + + await page + .waitForFunction( + (minCoverage) => { + const host = document.querySelector('[data-mermaid-lightbox]') + const svg = host?.shadowRoot?.querySelector('svg') + const box = svg?.getBoundingClientRect() + if (!box || box.width <= 0) return false + const vw = window.visualViewport?.width ?? window.innerWidth + const vh = window.visualViewport?.height ?? window.innerHeight + return Math.max(box.width / vw, box.height / vh) >= minCoverage + }, + MIN_COVERAGE, + { timeout: 8_000 }, + ) + .catch(() => undefined) + + const expand = await readExpandMetrics(page) + const inlineMax = Math.max(expand.inlineW, expand.inlineH) + const lightboxMax = Math.max(expand.lightboxW, expand.lightboxH) + const expandedVisibly = + expand.areaRatio >= MIN_EXPAND_AREA_RATIO || lightboxMax > inlineMax * 1.05 + expect(expandedVisibly, `${caseId}: expand inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)}`).toBe(true) + + const metrics = await readLightboxMetrics(page) + assertMetrics(caseId, metrics) + + test.info().annotations.push({ + type: 'expand', + description: `${caseId}: inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)} (${expand.areaRatio.toFixed(1)}x area)`, + }) + }) +} diff --git a/web/package.json b/web/package.json index 735f38a0aa..20ca02e8e5 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "build": "vite build && cp dist/index.html dist/404.html", "typecheck": "tsc --noEmit", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:mermaid-lightbox:e2e": "playwright test e2e/mermaid-lightbox.spec.ts" }, "dependencies": { "@assistant-ui/react": "^0.11.53", @@ -62,6 +63,7 @@ "tailwindcss": "^4.1.18", "typescript": "^5.9.3", "vite": "^7.3.0", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "@playwright/test": "1.49.1" } } diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000000..e80617226a --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +export default defineConfig({ + testDir: './e2e', + timeout: 45_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + baseURL: 'http://127.0.0.1:5173', + viewport: { width: 1440, height: 900 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, + webServer: { + command: 'npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/web/playwright.live.config.ts b/web/playwright.live.config.ts new file mode 100644 index 0000000000..73b93f45af --- /dev/null +++ b/web/playwright.live.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from '@playwright/test' + +const chromePath = process.env.PLAYWRIGHT_CHROME_PATH + +/** Live hub session tests — no Vite; hits HAPI_URL with HAPI_LIVE=1. */ +export default defineConfig({ + testDir: './e2e', + testMatch: 'mermaid-lightbox-session.spec.ts', + timeout: 120_000, + expect: { timeout: 20_000 }, + fullyParallel: false, + workers: 1, + reporter: [['list']], + use: { + ...devices['Desktop Chrome'], + viewport: { width: 1440, height: 1100 }, + ...(chromePath + ? { + launchOptions: { + executablePath: chromePath, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + }, + } + : {}), + }, +}) diff --git a/web/public/mermaid-lightbox-e2e.html b/web/public/mermaid-lightbox-e2e.html new file mode 100644 index 0000000000..6ee710df09 --- /dev/null +++ b/web/public/mermaid-lightbox-e2e.html @@ -0,0 +1,23 @@ + + + + + + Mermaid lightbox e2e + + + +
+ + + + diff --git a/web/public/mermaid-lightbox-smoke.html b/web/public/mermaid-lightbox-smoke.html new file mode 100644 index 0000000000..0defbd4ee8 --- /dev/null +++ b/web/public/mermaid-lightbox-smoke.html @@ -0,0 +1,23 @@ + + + + + + Mermaid lightbox smoke + + + +
+ + + + diff --git a/web/src/components/ZoomableLightbox.test.ts b/web/src/components/ZoomableLightbox.test.ts new file mode 100644 index 0000000000..c8cd542bf0 --- /dev/null +++ b/web/src/components/ZoomableLightbox.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { getScreenFitSize, measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox' + +type Rect = { width: number; height: number } + +function makeSvg(opts: { + viewBox?: { width: number; height: number } + widthAttr?: string | null + heightAttr?: string | null + rect?: Rect | null +}): SVGSVGElement { + const svg = { + viewBox: opts.viewBox + ? { baseVal: { width: opts.viewBox.width, height: opts.viewBox.height } } + : { baseVal: { width: 0, height: 0 } }, + getAttribute(name: string) { + if (name === 'width') return opts.widthAttr ?? null + if (name === 'height') return opts.heightAttr ?? null + return null + }, + getBoundingClientRect() { + return opts.rect ?? { width: 0, height: 0 } + }, + } + return svg as unknown as SVGSVGElement +} + +function makeContent(opts: { + img?: { naturalWidth: number; naturalHeight: number; rect?: Rect } | null + svg?: SVGSVGElement | null + rect?: Rect | null +}): HTMLElement { + const queryResults = new Map() + if (opts.img) { + queryResults.set('img', { + naturalWidth: opts.img.naturalWidth, + naturalHeight: opts.img.naturalHeight, + getBoundingClientRect: () => opts.img?.rect ?? { width: 0, height: 0 }, + }) + } + if (opts.svg) queryResults.set('svg', opts.svg) + const content = { + querySelector(selector: string) { + return queryResults.get(selector) ?? null + }, + getBoundingClientRect() { + return opts.rect ?? { width: 0, height: 0 } + }, + } + return content as unknown as HTMLElement +} + +describe('measureSvgIntrinsicSize', () => { + it('prefers viewBox over the (possibly transformed) bounding rect', () => { + const svg = makeSvg({ + viewBox: { width: 1200, height: 900 }, + rect: { width: 60, height: 45 }, + }) + expect(measureSvgIntrinsicSize(svg, 0.05)).toEqual({ width: 1200, height: 900 }) + }) + + it('falls back to width/height attributes when viewBox is missing', () => { + const svg = makeSvg({ widthAttr: '640', heightAttr: '480' }) + expect(measureSvgIntrinsicSize(svg)).toEqual({ width: 640, height: 480 }) + }) + + it('divides bounding rect by the current scale to undo wrapper transform', () => { + const svg = makeSvg({ rect: { width: 200, height: 100 } }) + expect(measureSvgIntrinsicSize(svg, 0.5)).toEqual({ width: 400, height: 200 }) + }) + + it('returns null when no source is usable', () => { + const svg = makeSvg({}) + expect(measureSvgIntrinsicSize(svg)).toBeNull() + }) +}) + +describe('getScreenFitSize', () => { + const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport') + const originalInnerWidth = window.innerWidth + const originalInnerHeight = window.innerHeight + + function setViewport(width: number, height: number) { + Object.defineProperty(window, 'visualViewport', { + configurable: true, + value: { width, height }, + }) + } + + function clearViewport() { + Object.defineProperty(window, 'visualViewport', { configurable: true, value: null }) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight }) + } + + function restore() { + if (originalVisualViewport) { + Object.defineProperty(window, 'visualViewport', originalVisualViewport) + } + } + + it('subtracts the reserved top region (toolbar) from height', () => { + setViewport(1000, 800) + try { + expect(getScreenFitSize(40)).toEqual({ width: 1000, height: 760 }) + } finally { + restore() + } + }) + + it('clamps reserved height at zero (no negative regions)', () => { + setViewport(800, 100) + try { + expect(getScreenFitSize(200)).toEqual({ width: 800, height: 0 }) + } finally { + restore() + } + }) + + it('falls back to window inner size when visualViewport is unavailable', () => { + clearViewport() + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 }) + try { + expect(getScreenFitSize(60)).toEqual({ width: 1280, height: 840 }) + } finally { + restore() + } + }) +}) + +describe('measureContentSize', () => { + it('prefers img.naturalSize over its bounding rect', () => { + const content = makeContent({ + img: { naturalWidth: 800, naturalHeight: 600, rect: { width: 80, height: 60 } }, + }) + expect(measureContentSize(content, 0.1)).toEqual({ width: 800, height: 600 }) + }) + + it('uses svg intrinsic size when no img is present', () => { + const svg = makeSvg({ viewBox: { width: 500, height: 250 } }) + const content = makeContent({ svg }) + expect(measureContentSize(content, 0.5)).toEqual({ width: 500, height: 250 }) + }) + + it('divides the host rect by current scale as the last fallback', () => { + const content = makeContent({ rect: { width: 100, height: 50 } }) + expect(measureContentSize(content, 0.5)).toEqual({ width: 200, height: 100 }) + }) + + it('treats non-positive scale as identity to avoid divide-by-zero', () => { + const content = makeContent({ rect: { width: 100, height: 100 } }) + expect(measureContentSize(content, 0)).toEqual({ width: 100, height: 100 }) + }) +}) diff --git a/web/src/components/ZoomableLightbox.tsx b/web/src/components/ZoomableLightbox.tsx new file mode 100644 index 0000000000..503a8e17ef --- /dev/null +++ b/web/src/components/ZoomableLightbox.tsx @@ -0,0 +1,480 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react' +import { CloseIcon } from '@/components/icons' + +const MIN_SCALE = 0.25 +/** Floor for fit-to-screen only; dense diagrams can need under 25% to fit. */ +const MIN_FIT_SCALE = 0.01 +const MAX_SCALE = 8 +const SCALE_STEP = 0.25 +const BACKDROP_CLICK_MAX_MOVEMENT = 4 +/** Edge margin when fitting to the device screen (not the inner panel only). */ +const SCREEN_FIT_PADDING_PX = 12 + +export function getScreenFitSize(reservedTopPx = 0): { width: number; height: number } { + const viewport = window.visualViewport + const width = viewport ? viewport.width : window.innerWidth + const fullHeight = viewport ? viewport.height : window.innerHeight + const reserved = Math.max(0, reservedTopPx) + return { width, height: Math.max(0, fullHeight - reserved) } +} + +type Point = { x: number; y: number } + +function clampScale(value: number, minScale = MIN_SCALE): number { + return Math.min(MAX_SCALE, Math.max(minScale, value)) +} + +function getPointDistance(a: Point, b: Point): number { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +function getPointCenter(a: Point, b: Point): Point { + return { + x: (a.x + b.x) / 2, + y: (a.y + b.y) / 2, + } +} + +/** + * Measure SVG intrinsic size, ignoring the wrapper's `scale(...)` transform. + * Order: viewBox -> width/height attrs -> bounding rect divided by current scale. + */ +export function measureSvgIntrinsicSize( + svg: SVGSVGElement, + currentScale = 1, +): { width: number; height: number } | null { + const viewBox = svg.viewBox?.baseVal + if (viewBox && viewBox.width > 0 && viewBox.height > 0) { + return { width: viewBox.width, height: viewBox.height } + } + + const widthAttr = svg.getAttribute('width') ?? '' + const heightAttr = svg.getAttribute('height') ?? '' + const parsedWidth = Number.parseFloat(widthAttr) + const parsedHeight = Number.parseFloat(heightAttr) + if (parsedWidth > 0 && parsedHeight > 0) { + return { width: parsedWidth, height: parsedHeight } + } + + const safeScale = currentScale > 0 ? currentScale : 1 + const box = svg.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width / safeScale, height: box.height / safeScale } + } + + return null +} + +/** + * Measure rendered content size, ignoring any ancestor `scale(...)` transform. + * Prefer intrinsic dimensions (img.naturalSize, SVG viewBox/attrs) before the + * bounding rect, which otherwise compounds with `scaleRef.current` on retry. + */ +export function measureContentSize( + content: HTMLElement, + currentScale = 1, +): { width: number; height: number } | null { + const safeScale = currentScale > 0 ? currentScale : 1 + + const img = content.querySelector('img') + if (img) { + if (img.naturalWidth > 0 && img.naturalHeight > 0) { + return { width: img.naturalWidth, height: img.naturalHeight } + } + const box = img.getBoundingClientRect() + if (box.width > 0 && box.height > 0) { + return { width: box.width / safeScale, height: box.height / safeScale } + } + } + + const svg = content.querySelector('svg') + if (svg) { + const intrinsic = measureSvgIntrinsicSize(svg, safeScale) + if (intrinsic) return intrinsic + } + + const rect = content.getBoundingClientRect() + if (rect.width > 0 && rect.height > 0) { + return { width: rect.width / safeScale, height: rect.height / safeScale } + } + + return null +} + +export type ZoomableLightboxProps = { + open: boolean + onClose: () => void + title?: string + ariaLabel: string + children: ReactNode + /** When set, re-fit viewport when this value changes (e.g. after async SVG load). */ + fitContentKey?: string | number | null + /** Intrinsic content size for fit (e.g. mermaid viewBox) when layout is not measurable yet. */ + fitContentSize?: { width: number; height: number } | null + /** Compute initial scale to fill the device screen (default true). */ + fitOnOpen?: boolean +} + +export function ZoomableLightbox(props: ZoomableLightboxProps) { + const { + open, + onClose, + title, + ariaLabel, + children, + fitContentKey = null, + fitContentSize = null, + fitOnOpen = true, + } = props + const [scale, setScale] = useState(1) + const [offset, setOffset] = useState({ x: 0, y: 0 }) + const [toolbarHeight, setToolbarHeight] = useState(0) + const scaleRef = useRef(scale) + const offsetRef = useRef(offset) + const baseScaleRef = useRef(1) + const viewportRef = useRef(null) + const contentRef = useRef(null) + const toolbarRef = useRef(null) + const activePointersRef = useRef(new Map()) + const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null) + const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null) + const backdropPressRef = useRef<{ pointerId: number; x: number; y: number } | null>(null) + + const updateScale = useCallback((next: number | ((current: number) => number)) => { + setScale((current) => { + const value = typeof next === 'function' ? next(current) : next + scaleRef.current = value + return value + }) + }, []) + + const updateOffset = useCallback((next: Point) => { + offsetRef.current = next + setOffset(next) + }, []) + + const applyFitScale = useCallback(() => { + if (!fitOnOpen) { + baseScaleRef.current = 1 + updateScale(1) + updateOffset({ x: 0, y: 0 }) + return + } + + const content = contentRef.current + if (!content) return + + const contentSize = fitContentSize ?? measureContentSize(content, scaleRef.current) + if (!contentSize) return + + const screen = getScreenFitSize(toolbarHeight) + const pad = SCREEN_FIT_PADDING_PX * 2 + const fitWidth = (screen.width - pad) / contentSize.width + const fitHeight = (screen.height - pad) / contentSize.height + const fitScale = clampScale(Math.min(fitWidth, fitHeight), MIN_FIT_SCALE) + + baseScaleRef.current = fitScale + updateScale(fitScale) + updateOffset({ x: 0, y: 0 }) + }, [fitContentSize, fitOnOpen, toolbarHeight, updateOffset, updateScale]) + + const resetView = useCallback(() => { + updateScale(baseScaleRef.current) + updateOffset({ x: 0, y: 0 }) + }, [updateOffset, updateScale]) + + const closeViewer = useCallback(() => { + onClose() + activePointersRef.current.clear() + dragRef.current = null + pinchRef.current = null + backdropPressRef.current = null + baseScaleRef.current = 1 + updateScale(1) + updateOffset({ x: 0, y: 0 }) + }, [onClose, updateOffset, updateScale]) + + /** + * Lower bound for interactive zoom. Carries the fit floor when the diagram + * was opened below MIN_SCALE (e.g. 0.05); otherwise sticks at MIN_SCALE. + */ + const getMinInteractiveScale = useCallback(() => { + return Math.min(MIN_SCALE, baseScaleRef.current) + }, []) + + const zoomBy = useCallback((delta: number) => { + updateScale((current) => clampScale(current + delta, getMinInteractiveScale())) + }, [getMinInteractiveScale, updateScale]) + + const handleWheel = useCallback((event: WheelEvent) => { + event.preventDefault() + const delta = event.deltaY < 0 ? SCALE_STEP : -SCALE_STEP + zoomBy(delta) + }, [zoomBy]) + + const beginPinch = useCallback(() => { + const pointers = Array.from(activePointersRef.current.values()) + if (pointers.length < 2) return + + const [first, second] = pointers + pinchRef.current = { + startDistance: getPointDistance(first, second), + startScale: scaleRef.current, + startCenter: getPointCenter(first, second), + origin: offsetRef.current, + } + dragRef.current = null + }, []) + + const handlePointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) return + event.currentTarget.setPointerCapture(event.pointerId) + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) + backdropPressRef.current = event.target === event.currentTarget + ? { pointerId: event.pointerId, x: event.clientX, y: event.clientY } + : null + + if (activePointersRef.current.size >= 2) { + backdropPressRef.current = null + beginPinch() + return + } + + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: offsetRef.current.x, + originY: offsetRef.current.y, + } + }, [beginPinch]) + + const handlePointerMove = useCallback((event: PointerEvent) => { + if (!activePointersRef.current.has(event.pointerId)) return + activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY }) + + if (activePointersRef.current.size >= 2 && pinchRef.current) { + const pointers = Array.from(activePointersRef.current.values()) + const [first, second] = pointers + const distance = getPointDistance(first, second) + const center = getPointCenter(first, second) + const pinch = pinchRef.current + const nextScale = pinch.startDistance > 0 + ? clampScale( + pinch.startScale * (distance / pinch.startDistance), + getMinInteractiveScale(), + ) + : pinch.startScale + + updateScale(nextScale) + updateOffset({ + x: pinch.origin.x + center.x - pinch.startCenter.x, + y: pinch.origin.y + center.y - pinch.startCenter.y, + }) + return + } + + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) return + updateOffset({ + x: drag.originX + event.clientX - drag.startX, + y: drag.originY + event.clientY - drag.startY, + }) + }, [getMinInteractiveScale, updateOffset, updateScale]) + + const handlePointerUp = useCallback((event: PointerEvent) => { + const backdropPress = backdropPressRef.current + const moved = backdropPress + ? Math.hypot(event.clientX - backdropPress.x, event.clientY - backdropPress.y) + : Number.POSITIVE_INFINITY + const shouldCloseFromBackdrop = event.type === 'pointerup' + && backdropPress?.pointerId === event.pointerId + && event.target === event.currentTarget + && activePointersRef.current.size === 1 + && moved <= BACKDROP_CLICK_MAX_MOVEMENT + + activePointersRef.current.delete(event.pointerId) + if (backdropPress?.pointerId === event.pointerId) { + backdropPressRef.current = null + } + if (dragRef.current?.pointerId === event.pointerId) { + dragRef.current = null + } + pinchRef.current = null + + const remainingPointer = activePointersRef.current.entries().next().value as [number, Point] | undefined + if (remainingPointer) { + dragRef.current = { + pointerId: remainingPointer[0], + startX: remainingPointer[1].x, + startY: remainingPointer[1].y, + originX: offsetRef.current.x, + originY: offsetRef.current.y, + } + } + if (shouldCloseFromBackdrop) { + closeViewer() + } + }, [closeViewer]) + + useLayoutEffect(() => { + if (!open) return + if (fitOnOpen && !fitContentKey) return + + let frame = 0 + let attempt = 0 + const maxAttempts = 16 + + const scheduleFit = () => { + frame = requestAnimationFrame(() => { + const content = contentRef.current + const hadSize = fitContentSize ?? (content ? measureContentSize(content) : null) + applyFitScale() + attempt += 1 + if (!hadSize && attempt < maxAttempts) { + scheduleFit() + } + }) + } + + scheduleFit() + const retry = window.setTimeout(scheduleFit, 50) + const lateRetry = window.setTimeout(scheduleFit, 200) + + return () => { + cancelAnimationFrame(frame) + window.clearTimeout(retry) + window.clearTimeout(lateRetry) + } + }, [fitContentKey, fitContentSize, fitOnOpen, open, applyFitScale]) + + useLayoutEffect(() => { + if (!open) return undefined + const toolbar = toolbarRef.current + if (!toolbar) return undefined + + const apply = () => { + const next = toolbar.getBoundingClientRect().height + setToolbarHeight((current) => (Math.abs(current - next) < 0.5 ? current : next)) + } + + apply() + window.addEventListener('resize', apply) + + if (typeof ResizeObserver === 'undefined') { + return () => window.removeEventListener('resize', apply) + } + + const resize = new ResizeObserver(apply) + resize.observe(toolbar) + return () => { + resize.disconnect() + window.removeEventListener('resize', apply) + } + }, [open]) + + useEffect(() => { + if (!open) return + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeViewer() + } + if (event.key === '0') { + resetView() + } + if (event.key === '+' || event.key === '=') { + zoomBy(SCALE_STEP) + } + if (event.key === '-') { + zoomBy(-SCALE_STEP) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeViewer, open, resetView, zoomBy]) + + if (!open) return null + + const baseScale = baseScaleRef.current + const zoomLabel = baseScale > 0 + ? `${Math.round((scale / baseScale) * 100)}%` + : `${Math.round(scale * 100)}%` + const minInteractiveScale = Math.min(MIN_SCALE, baseScale) + + return ( +
+
+
+ {children} +
+
+
event.stopPropagation()} + > +
+
{title ?? ariaLabel}
+ + + + +
+
+
+ ) +} diff --git a/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx new file mode 100644 index 0000000000..e026389b11 --- /dev/null +++ b/web/src/components/assistant-ui/mermaid-diagram.live.test.tsx @@ -0,0 +1,101 @@ +import type React from 'react' +import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { I18nProvider } from '@/lib/i18n-context' + +function installSvgBBoxPolyfill() { + const bbox = () => ({ + x: 0, + y: 0, + width: 120, + height: 24, + top: 0, + left: 0, + right: 120, + bottom: 24, + toJSON() { + return {} + }, + }) + + for (const proto of [Element.prototype, HTMLElement.prototype, SVGElement.prototype]) { + if (proto && !('getBBox' in proto)) { + Object.defineProperty(proto, 'getBBox', { + configurable: true, + value: bbox, + }) + } + } +} + +const sequenceDiagram = `sequenceDiagram + participant U as Operator + participant C as Chat + participant M as Mermaid + U->>C: Send message + C->>M: Render SVG + U->>M: Click diagram + M-->>U: Lightbox + zoom` + +const defaultComponents = { + Pre: (props: React.ComponentProps<'pre'>) =>
,
+    Code: (props: React.ComponentProps<'code'>) => ,
+}
+
+async function expectLightboxShowsDiagram(code: string) {
+    installSvgBBoxPolyfill()
+
+    render(
+        
+            
+        ,
+    )
+
+    await waitFor(
+        () => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg')).toBeTruthy()
+        },
+        { timeout: 10000 },
+    )
+
+    fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+
+    await waitFor(
+        () => {
+            const dialog = screen.getByRole('dialog', { name: 'Diagram' })
+            const host = dialog.querySelector('[data-mermaid-lightbox]')
+            expect(host).toBeTruthy()
+            const lightboxSvg = host?.shadowRoot?.querySelector('svg')
+            expect(lightboxSvg).toBeTruthy()
+            expect(lightboxSvg?.querySelector('path, line, rect')).toBeTruthy()
+            if (code.includes('sequenceDiagram')) {
+                const actorRects = lightboxSvg?.querySelectorAll('rect.actor, g.actor rect, rect').length ?? 0
+                expect(actorRects).toBeGreaterThanOrEqual(3)
+            }
+        },
+        { timeout: 10000 },
+    )
+}
+
+describe('MermaidDiagram live render', () => {
+    it(
+        'renders real mermaid source to svg in jsdom',
+        async () => {
+            await expectLightboxShowsDiagram('flowchart LR\n  Hub --> WebUI\n  WebUI --> SVG')
+        },
+        20_000,
+    )
+
+    it(
+        'renders sequence diagrams in the lightbox',
+        async () => {
+            await expectLightboxShowsDiagram(sequenceDiagram)
+        },
+        20_000,
+    )
+})
diff --git a/web/src/components/assistant-ui/mermaid-diagram.test.tsx b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
index 1e00b2f507..0bac0336bb 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.test.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.test.tsx
@@ -1,5 +1,7 @@
-import { describe, expect, it, vi } from 'vitest'
-import { render, waitFor } from '@testing-library/react'
+import type { ComponentProps } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { I18nProvider } from '@/lib/i18n-context'
 
 const mermaidMocks = vi.hoisted(() => ({
     initializeMock: vi.fn(),
@@ -18,18 +20,34 @@ vi.mock('mermaid', () => ({
 import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
 import { MARKDOWN_COMPONENTS_BY_LANGUAGE } from '@/components/assistant-ui/markdown-text'
 
+const defaultComponents = {
+    Pre: (props: ComponentProps<'pre'>) => 
,
+    Code: (props: ComponentProps<'code'>) => ,
+}
+
+function renderDiagram(props: ComponentProps) {
+    return render(
+        
+            
+        ,
+    )
+}
+
 describe('MermaidDiagram', () => {
+    afterEach(() => {
+        cleanup()
+        mermaidMocks.renderMock.mockReset()
+        mermaidMocks.renderMock.mockResolvedValue({
+            svg: '',
+        })
+    })
+
     it('is wired into the shared markdown language overrides and renders svg output', async () => {
-        render(
-             B'}
-                language="mermaid"
-                components={{
-                    Pre: (props) => 
,
-                    Code: (props) => ,
-                }}
-            />
-        )
+        renderDiagram({
+            code: 'graph TD\nA --> B',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
 
         await waitFor(() => {
             const diagram = document.querySelector('[data-mermaid-diagram][data-rendered="true"]')
@@ -44,4 +62,47 @@ describe('MermaidDiagram', () => {
         expect(mermaidMocks.renderMock).toHaveBeenCalledWith(expect.stringContaining('mermaid-'), 'graph TD\nA --> B')
         expect(MARKDOWN_COMPONENTS_BY_LANGUAGE.mermaid.SyntaxHighlighter).toBe(MermaidDiagram)
     })
+
+    it('opens a zoomable lightbox when the rendered diagram is clicked', async () => {
+        renderDiagram({
+            code: 'graph TD\nA --> B',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
+
+        await waitFor(() => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"]')).toBeTruthy()
+        })
+
+        fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
+
+        await waitFor(() => {
+            const dialog = screen.getByRole('dialog', { name: 'Diagram' })
+            const host = dialog.querySelector('[data-mermaid-lightbox]')
+            expect(host?.shadowRoot?.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy()
+        })
+
+        expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(1)
+        expect(mermaidMocks.renderMock).toHaveBeenCalledWith(
+            expect.stringContaining('mermaid-'),
+            'graph TD\nA --> B',
+        )
+        expect(document.querySelector('[data-mermaid-lightbox]')).toBeTruthy()
+    })
+
+    it('does not expose a lightbox trigger when rendering fails', async () => {
+        mermaidMocks.renderMock.mockRejectedValue(new Error('syntax'))
+
+        renderDiagram({
+            code: 'not valid mermaid',
+            language: 'mermaid',
+            components: defaultComponents,
+        })
+
+        await waitFor(() => {
+            expect(document.querySelector('[data-mermaid-diagram][data-rendered="false"]')).toBeTruthy()
+        })
+
+        expect(screen.queryByRole('button', { name: 'Open diagram full screen' })).toBeNull()
+    })
 })
diff --git a/web/src/components/assistant-ui/mermaid-diagram.tsx b/web/src/components/assistant-ui/mermaid-diagram.tsx
index b942b62c20..05164c45a3 100644
--- a/web/src/components/assistant-ui/mermaid-diagram.tsx
+++ b/web/src/components/assistant-ui/mermaid-diagram.tsx
@@ -1,6 +1,16 @@
 import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
-import { useEffect, useId, useState, type ComponentPropsWithoutRef } from 'react'
+import {
+    useEffect,
+    useId,
+    useRef,
+    useState,
+    type ComponentPropsWithoutRef,
+    type Ref,
+    type SyntheticEvent,
+} from 'react'
+import { ZoomableLightbox } from '@/components/ZoomableLightbox'
 import { cn } from '@/lib/utils'
+import { useTranslation } from '@/lib/use-translation'
 
 let initializedTheme: 'light' | 'dark' | null = null
 let mermaidPromise: Promise | null = null
@@ -39,6 +49,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
                 clusterBkg: '#2d3440',
                 clusterBorder: '#6d8fd6',
                 edgeLabelBackground: '#2a2f35',
+                actorBkg: '#323843',
+                actorBorder: '#6d8fd6',
+                actorTextColor: '#edf1f5',
+                signalColor: '#94a3b8',
+                labelBoxBkgColor: '#323843',
+                labelTextColor: '#edf1f5',
+                loopTextColor: '#edf1f5',
+                noteBkgColor: '#2d3440',
+                noteTextColor: '#edf1f5',
             }
             : {
                 primaryColor: '#f8fbff',
@@ -53,6 +72,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
                 clusterBkg: '#eef4ff',
                 clusterBorder: '#b8cdfd',
                 edgeLabelBackground: '#f5f6f7',
+                actorBkg: '#f8fbff',
+                actorBorder: '#b8cdfd',
+                actorTextColor: '#2d333b',
+                signalColor: '#94a3b8',
+                labelBoxBkgColor: '#f8fbff',
+                labelTextColor: '#2d333b',
+                loopTextColor: '#2d333b',
+                noteBkgColor: '#eef4ff',
+                noteTextColor: '#2d333b',
             },
     })
 
@@ -60,24 +88,170 @@ async function ensureMermaid(theme: 'light' | 'dark') {
     return mermaid
 }
 
+export async function renderMermaidSvg(code: string, elementId: string, theme: 'light' | 'dark') {
+    const mermaid = await ensureMermaid(theme)
+    const result = await mermaid.render(elementId, code)
+    return result.svg
+}
+
+export function getMermaidSvgLayoutSize(svg: string): { width: number; height: number } | null {
+    const viewBoxMatch = svg.match(/\bviewBox=(['"])([^'"]+)\1/i)
+    if (!viewBoxMatch) return null
+    const parts = viewBoxMatch[2].trim().split(/[\s,]+/).map(Number)
+    if (parts.length < 4 || parts.some(Number.isNaN) || parts[2] <= 0 || parts[3] <= 0) return null
+    return { width: parts[2], height: parts[3] }
+}
+
+/** Mermaid often emits width="100%"; normalize before rasterizing for the lightbox. */
+export function normalizeMermaidSvgForStandaloneDisplay(svg: string): string {
+    let result = svg
+    const viewBoxSize = getMermaidSvgLayoutSize(result)
+    if (!viewBoxSize) return result
+
+    const { width, height } = viewBoxSize
+    result = result.replace(/\swidth="100%"/gi, '')
+    result = result.replace(/\sheight="100%"/gi, '')
+
+    if (/\sstyle="/i.test(result)) {
+        result = result.replace(
+            /(]*?\sstyle=")([^"]*)(")/i,
+            (_full, prefix: string, style: string, suffix: string) => {
+                const cleaned = style
+                    .replace(/(?:^|;)\s*max-width:\s*[^;]+/gi, '')
+                    .replace(/(?:^|;)\s*width:\s*[^;]+/gi, '')
+                    .replace(/(?:^|;)\s*height:\s*[^;]+/gi, '')
+                    .replace(/^;+|;+$/g, '')
+                    .replace(/;\s*;/g, ';')
+                    .trim()
+                const nextStyle = cleaned
+                    ? `${cleaned};width:${width}px;height:${height}px`
+                    : `width:${width}px;height:${height}px`
+                return `${prefix}${nextStyle}${suffix}`
+            },
+        )
+    } else {
+        result = result.replace(
+            / & { code: string }) {
+    const { code, className, ...rest } = props
     return (
         
-            {props.code}
+            {code}
         
) } +function MermaidSvgContent(props: { svg: string; className?: string; hostRef?: Ref }) { + return ( +
+ ) +} + +/** Prefer viewBox layout; use getBBox when Mermaid pads the viewBox (e.g. gitGraph). */ +export function resolveMermaidLightboxFitSize( + svgElement: SVGSVGElement | null, + svgString: string, +): { width: number; height: number } | null { + const fromViewBox = getMermaidSvgLayoutSize(svgString) + if (!svgElement) return fromViewBox + + try { + const bbox = svgElement.getBBox() + if (bbox.width <= 0 || bbox.height <= 0) return fromViewBox + if (!fromViewBox) return { width: bbox.width, height: bbox.height } + + const viewBoxArea = fromViewBox.width * fromViewBox.height + const bboxArea = bbox.width * bbox.height + if (viewBoxArea > bboxArea * 2) { + return { width: bbox.width, height: bbox.height } + } + } catch { + // getBBox unavailable (some test environments) + } + + return fromViewBox +} + +/** + * Shadow root isolates duplicate mermaid ids from the inline diagram in the page. + * + * Mermaid emits `width="100%"` on every diagram. Inside a shadow root whose host + * has no explicit size, that collapses to zero in Chromium for most diagram types + * (only ones that ship pixel attrs - e.g. `journey` - happen to render). Strip + * the relative size and bake explicit pixels from the viewBox before injecting, + * and give the host an inline-block layout so it sizes to the SVG. + */ +function MermaidLightboxSvg(props: { svg: string }) { + const hostRef = useRef(null) + + useEffect(() => { + const host = hostRef.current + if (!host) return + + const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' }) + const normalized = normalizeMermaidSvgForStandaloneDisplay(props.svg) + root.innerHTML = [ + '', + normalized, + ].join('') + }, [props.svg]) + + return
+} + +function readMermaidE2eCaseId(code: string): string | undefined { + return code.match(//i)?.[1] +} + export function MermaidDiagram(props: SyntaxHighlighterProps) { + const { t } = useTranslation() + const e2eCaseId = readMermaidE2eCaseId(props.code) const [theme, setTheme] = useState<'light' | 'dark'>(() => resolveTheme()) const [renderError, setRenderError] = useState(false) const [svg, setSvg] = useState(null) + const [lightboxOpen, setLightboxOpen] = useState(false) + const [lightboxFitSize, setLightboxFitSize] = useState<{ width: number; height: number } | null>(null) + const inlineHostRef = useRef(null) const id = useId().replace(/:/g, '-') + const openLabel = t('mermaid.openFullscreen') + const viewerLabel = t('mermaid.viewerTitle') + + const stopEvent = (event: SyntheticEvent) => { + event.stopPropagation() + } + + const openLightbox = (event: SyntheticEvent) => { + event.preventDefault() + event.stopPropagation() + if (!svg) return + const inlineSvg = inlineHostRef.current?.querySelector('svg') ?? null + setLightboxFitSize(resolveMermaidLightboxFitSize(inlineSvg, svg)) + setLightboxOpen(true) + } useEffect(() => { if (typeof document === 'undefined') return undefined @@ -100,10 +274,9 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { const render = async () => { try { - const mermaid = await ensureMermaid(theme) - const result = await mermaid.render(`mermaid-${id}`, props.code) + const nextSvg = await renderMermaidSvg(props.code, `mermaid-${id}`, theme) if (cancelled) return - setSvg(result.svg) + setSvg(nextSvg) setRenderError(false) } catch { if (cancelled) return @@ -119,20 +292,43 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) { } }, [id, props.code, theme]) + const lightboxLayoutSize = lightboxFitSize ?? (svg ? getMermaidSvgLayoutSize(svg) : null) + if (renderError || !svg) { return } return ( -
-
-
+ <> + + + setLightboxOpen(false)} + title={viewerLabel} + ariaLabel={viewerLabel} + fitContentKey={lightboxOpen ? svg : null} + fitContentSize={lightboxLayoutSize} + > +
+ +
+
+ ) } diff --git a/web/src/components/assistant-ui/mermaid-svg-id.test.ts b/web/src/components/assistant-ui/mermaid-svg-id.test.ts new file mode 100644 index 0000000000..5a7d3787d3 --- /dev/null +++ b/web/src/components/assistant-ui/mermaid-svg-id.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + getMermaidSvgLayoutSize, + normalizeMermaidSvgForStandaloneDisplay, +} from '@/components/assistant-ui/mermaid-diagram' + +describe('getMermaidSvgLayoutSize', () => { + it('reads simple unsigned viewBox', () => { + expect(getMermaidSvgLayoutSize('')).toEqual({ width: 200, height: 80 }) + }) + + it('accepts signed origin values (negative offsets)', () => { + expect(getMermaidSvgLayoutSize('')).toEqual({ width: 640, height: 480 }) + }) + + it('accepts single-quoted attribute and comma separators', () => { + expect(getMermaidSvgLayoutSize("")).toEqual({ width: 300, height: 150 }) + }) + + it('rejects malformed viewBox (NaN, missing dim, zero size)', () => { + expect(getMermaidSvgLayoutSize('')).toBeNull() + expect(getMermaidSvgLayoutSize('')).toBeNull() + expect(getMermaidSvgLayoutSize('')).toBeNull() + }) + + it('returns null when no viewBox attribute exists', () => { + expect(getMermaidSvgLayoutSize('')).toBeNull() + }) +}) + +describe('normalizeMermaidSvgForStandaloneDisplay', () => { + it('replaces width="100%" with explicit viewBox dimensions', () => { + const svg = '' + const prepared = normalizeMermaidSvgForStandaloneDisplay(svg) + + expect(prepared).not.toContain('width="100%"') + expect(prepared).toContain('width:200px') + expect(prepared).toContain('height:80px') + }) + + it('normalizes width/height for SVGs with negative viewBox origins', () => { + const svg = '' + const prepared = normalizeMermaidSvgForStandaloneDisplay(svg) + + expect(prepared).not.toContain('width="100%"') + expect(prepared).toContain('width="800"') + expect(prepared).toContain('height="600"') + }) +}) diff --git a/web/src/dev/mermaid-lightbox-cases.ts b/web/src/dev/mermaid-lightbox-cases.ts new file mode 100644 index 0000000000..c926802151 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-cases.ts @@ -0,0 +1,96 @@ +/** Minimal valid sources for Playwright lightbox coverage (one case per diagram kind). */ +export const MERMAID_LIGHTBOX_CASES = { + flowchart: `flowchart LR + Hub --> WebUI + WebUI --> Lightbox`, + + sequence: `sequenceDiagram + participant U as Operator + participant C as Chat + participant M as Mermaid + U->>C: Send message + C->>M: Render SVG + M-->>U: Lightbox`, + + class: `classDiagram + Animal <|-- Duck + Animal : +int age + Duck : +swim()`, + + state: `stateDiagram-v2 + [*] --> Still + Still --> Moving + Moving --> Still`, + + er: `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains`, + + journey: `journey + title Checkout + section Browse + Open site: 5: User + Add item: 3: User`, + + gantt: `gantt + title Plan + dateFormat YYYY-MM-DD + section Build + Ship feature :2024-06-01, 3d`, + + pie: `pie title Pets + "Dogs" : 386 + "Cats" : 214`, + + quadrant: `quadrantChart + title Reach and engagement + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 We should expand + Product A: [0.3, 0.6] + Product B: [0.45, 0.23]`, + + requirement: `requirementDiagram + requirement test_req { + id: 1 + text: the tested requirement. + risk: high + verifymethod: test + }`, + + gitGraph: `gitGraph + commit + branch develop + checkout develop + commit + checkout main + merge develop`, + + c4: `C4Context + title System + Person(user, "User") + System(app, "Application") + Rel(user, app, "Uses")`, + + mindmap: `mindmap + root((HAPI)) + Chat + Hub + Web`, + + timeline: `timeline + title History + 2024 : Alpha + 2025 : Beta`, + + kanban: `kanban + title Board + column Todo + task1[Task 1] + column Done + task2[Task 2]`, +} as const + +export type MermaidLightboxCaseId = keyof typeof MERMAID_LIGHTBOX_CASES + +export const MERMAID_LIGHTBOX_CASE_IDS = Object.keys(MERMAID_LIGHTBOX_CASES) as MermaidLightboxCaseId[] diff --git a/web/src/dev/mermaid-lightbox-e2e.tsx b/web/src/dev/mermaid-lightbox-e2e.tsx new file mode 100644 index 0000000000..e38b007a17 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-e2e.tsx @@ -0,0 +1,39 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' +import { + MERMAID_LIGHTBOX_CASE_IDS, + MERMAID_LIGHTBOX_CASES, + type MermaidLightboxCaseId, +} from '@/dev/mermaid-lightbox-cases' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +const params = new URLSearchParams(window.location.search) +const caseId = params.get('case') as MermaidLightboxCaseId | null +const code = caseId && caseId in MERMAID_LIGHTBOX_CASES + ? MERMAID_LIGHTBOX_CASES[caseId] + : MERMAID_LIGHTBOX_CASES.flowchart + +document.title = `Mermaid lightbox e2e: ${caseId ?? 'flowchart'}` + +createRoot(root).render( + +
+
,
+                    Code: (props) => ,
+                }}
+            />
+        
+
, +) + +// Expose case list for Playwright discovery without importing TS in Node. +;(window as Window & { __MERMAID_E2E_CASES__?: string[] }).__MERMAID_E2E_CASES__ = [...MERMAID_LIGHTBOX_CASE_IDS] diff --git a/web/src/dev/mermaid-lightbox-smoke.tsx b/web/src/dev/mermaid-lightbox-smoke.tsx new file mode 100644 index 0000000000..80ff954632 --- /dev/null +++ b/web/src/dev/mermaid-lightbox-smoke.tsx @@ -0,0 +1,21 @@ +import { createRoot } from 'react-dom/client' +import { I18nProvider } from '@/lib/i18n-context' +import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram' + +const root = document.getElementById('root') +if (!root) { + throw new Error('missing #root') +} + +createRoot(root).render( + + Lightbox\n Lightbox --> Visible'} + language="mermaid" + components={{ + Pre: (props) =>
,
+                Code: (props) => ,
+            }}
+        />
+    ,
+)
diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts
index 842097126a..b471e02168 100644
--- a/web/src/lib/locales/en.ts
+++ b/web/src/lib/locales/en.ts
@@ -108,6 +108,12 @@ export default {
   'dialog.delete.confirming': 'Deleting…',
   'dialog.error.default': 'Operation failed. Please try again.',
 
+  // Mermaid diagrams
+  'mermaid.openFullscreen': 'Open diagram full screen',
+  'mermaid.viewerTitle': 'Diagram',
+  'mermaid.loading': 'Loading diagram…',
+  'mermaid.renderError': 'Could not render diagram.',
+
   // Common buttons
   'button.cancel': 'Cancel',
   'button.save': 'Save',
diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts
index c1873a303a..cd918a9a43 100644
--- a/web/src/lib/locales/zh-CN.ts
+++ b/web/src/lib/locales/zh-CN.ts
@@ -110,6 +110,12 @@ export default {
   'dialog.delete.confirming': '删除中…',
   'dialog.error.default': '操作失败,请重试。',
 
+  // Mermaid diagrams
+  'mermaid.openFullscreen': '全屏查看图表',
+  'mermaid.viewerTitle': '图表',
+  'mermaid.loading': '正在加载图表…',
+  'mermaid.renderError': '无法渲染图表。',
+
   // Common buttons
   'button.cancel': '取消',
   'button.save': '保存',