import { expect, test, type Locator, type Page } from '@playwright/test' import fs from 'fs' import path from 'path' import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault' import { executeCommand, openCommandPalette } from './helpers' import { RUNTIME_STYLE_NONCE } from '../../src/lib/runtimeStyleNonce' import { APP_COMMAND_IDS } from '../../src/hooks/appCommandCatalog' import { triggerMenuCommand, triggerShortcutCommand } from './testBridge' let tempVaultDir: string const FIRST_DIAGRAM = [ '```mermaid', 'flowchart LR', ' A[Draft] --> B[Saved]', '```', ].join('\n') const UPDATED_FIRST_DIAGRAM = FIRST_DIAGRAM.replace('B[Saved]', 'C[Published]') const SECOND_DIAGRAM = [ '```mermaid', 'sequenceDiagram', ' Alice->>Bob: Hello', '```', ].join('\n') const REPORTED_THEME_DIAGRAM = [ '```mermaid', "%%{init: {'theme':'base','themeVariables':{'primaryColor':'#dbe7ff','primaryTextColor':'#0b1220','primaryBorderColor':'#1f3a8a','lineColor':'#1f3a8a','secondaryColor':'#fff4d6','tertiaryColor':'#ffe0e0','fontSize':'14px'}}}%%", 'flowchart TD', ' A(["Employee clocks in"]) --> B{"Linked to a planned shift?"}', '', ' B -- "Yes" --> C["Other path
for scheduled shift"]', ' C --> D["Contract path
for scheduled shift"]', '', ' B -- "No (unscheduled)" --> E["other path
for unscheduled clocking"]', '', ' classDef path fill:#fff4d6,stroke:#7a5b00,stroke-width:2px,color:#3a2c00;', ' class C,D,E,F path;', '```', ].join('\n') const REPORTED_DATABASE_DIAGRAM = [ '```mermaid', 'flowchart TB', ' app["app
api"] --> db[("DB Type
rows")]', '```', ].join('\n') const REPORTED_GANTT_DIAGRAM = [ '```mermaid', 'gantt', ' title Project Plan', ' dateFormat YYYY-MM-DD', ' section Phase 1', ' Requirements Analysis:a1, 2026-06-24, 7d', ' Design:a2, after a1, 5d', ' section Phase 2', ' Development:b1, after a2, 14d', ' Testing & Deployment:b2, after b1, 7d', '```', ].join('\n') const SYSTEM_OVERVIEW_DIAGRAM = [ '```mermaid', 'flowchart TD', ' subgraph TW["Tauri v2 Window"]', ' subgraph FE["React Frontend"]', ' App["App.tsx (orchestrator)"]', ' WS["WelcomeScreen\\n(onboarding)"]', ' SB["Sidebar\\n(navigation + filters + types)"]', ' NL["NoteList / PulseView\\n(filtered list / activity)"]', ' ED["Editor\\n(BlockNote + diff + raw)"]', ' IN["Inspector\\n(metadata + relationships)"]', ' AIP["AiPanel\\n(selected CLI agent + tools)"]', ' SP["SearchPanel\\n(keyword search)"]', ' ST["StatusBar\\n(vault picker + sync + version)"]', ' CP["CommandPalette\\n(Cmd+K launcher)"]', '', ' App --> WS & SB & NL & ED & SP & ST & CP', ' ED --> IN & AIP', ' end', '', ' subgraph RB["Rust Backend"]', ' LIB["lib.rs → Tauri commands"]', ' VAULT["vault/"]', ' FM["frontmatter/"]', ' GIT["git/\\n(commit, sync, clone)"]', ' SETTINGS["settings.rs"]', ' SEARCH["search.rs"]', ' CLI["ai_agents.rs\\n+ claude_cli.rs"]', ' end', '', ' subgraph EXT["External Services"]', ' CCLI["Claude / Codex / OpenCode / Pi CLI\\n(agent subprocesses)"]', ' MCP["MCP Server\\n(ws://9710, 9711)"]', ' GCLI["git CLI\\n(system executable)"]', ' REMOTE["Git remotes\\n(GitHub/GitLab/Gitea/etc.)"]', ' end', '', ' FE -->|"Tauri IPC"| RB', ' CLI -->|"spawn subprocess"| CCLI', ' LIB -->|"register / monitor"| MCP', ' GIT -->|"clone / fetch / push / pull"| GCLI', ' GCLI -->|"network auth via user config"| REMOTE', ' end', '', ' style FE fill:#e8f4fd,stroke:#2196f3,color:#000', ' style RB fill:#fff8e1,stroke:#ff9800,color:#000', ' style EXT fill:#f3e5f5,stroke:#9c27b0,color:#000', '```', ].join('\n') const INVALID_DIAGRAM = [ '```mermaid', 'not a diagram', '```', ].join('\n') const REPORTED_INVALID_DIAGRAM = [ '```mermaid', 'flowchart TD', ' A[Christmas] -->|Get money| B(Go shopping)', ' B --> C{Let me think}', ' C -->|One| D[Laptop]', ' C -->|Two| E[iPhone]', ' C -->|Three| F[fa:fa-car Car]', '## ABC', '```', ].join('\n') test.beforeEach(async ({ page }, testInfo) => { testInfo.setTimeout(90_000) tempVaultDir = createFixtureVaultCopy() fs.writeFileSync( path.join(tempVaultDir, 'note', 'mermaid-reported.md'), [ '---', 'Status: Active', 'Date: 2026-04-29T00:00:00', '---', '', '# Mermaid Reported', '', REPORTED_THEME_DIAGRAM, '', ].join('\n'), ) fs.writeFileSync( path.join(tempVaultDir, 'note', 'mermaid-database.md'), [ '---', 'Status: Active', 'Date: 2026-06-21T00:00:00', '---', '', '# Mermaid Database', '', REPORTED_DATABASE_DIAGRAM, '', ].join('\n'), ) fs.writeFileSync( path.join(tempVaultDir, 'note', 'mermaid-gantt.md'), [ '---', 'Status: Active', 'Date: 2026-06-24T00:00:00', '---', '', '# Mermaid Gantt', '', REPORTED_GANTT_DIAGRAM, '', ].join('\n'), ) await openFixtureVault(page, tempVaultDir, { installedAiAgents: ['claude_code'] }) }) test.afterEach(async () => { removeFixtureVaultCopy(tempVaultDir) }) async function openNote(page: Page, title: string): Promise { await page.locator('[data-testid="note-list-container"]').getByText(title, { exact: true }).click() await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) } async function toggleRawMode(page: Page, visibleSelector: '.bn-editor' | '.cm-content'): Promise { await openCommandPalette(page) await executeCommand(page, 'Toggle Raw') await expect(page.locator(visibleSelector)).toBeVisible({ timeout: 5_000 }) } async function getRawEditorContent(page: Page): Promise { return page.evaluate(() => { type CodeMirrorHost = Element & { cmTile?: { view?: { state: { doc: { toString(): string } } } } } const host = document.querySelector('.cm-content') as CodeMirrorHost | null return host?.cmTile?.view?.state.doc.toString() ?? host?.textContent ?? '' }) } async function setRawEditorContent(page: Page, content: string): Promise { await page.evaluate((nextContent) => { type CodeMirrorHost = Element & { cmTile?: { view?: { state: { doc: { length: number } } dispatch(update: { changes: { from: number to: number insert: string } }): void } } } const host = document.querySelector('.cm-content') as CodeMirrorHost | null const view = host?.cmTile?.view if (!view) return view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: nextContent }, }) }, content) } async function expectRenderedDiagramCount(page: Page, count: number): Promise { await expect(page.locator('[data-testid="mermaid-diagram-viewport"] svg')).toHaveCount(count, { timeout: 15_000 }) } async function reloadVault(page: Page): Promise { await triggerMenuCommand(page, APP_COMMAND_IDS.vaultReload) await expect(page.getByText(/Vault reloaded \(\d+ entries\)/).last()).toBeVisible({ timeout: 5_000, }) } async function countLargeBlackFilledShapes(page: Page, diagramIndex: number): Promise { return page.locator('[data-testid="mermaid-diagram-viewport"] svg').nth(diagramIndex).evaluate((svg) => { const shapes = Array.from(svg.querySelectorAll('rect,path,polygon')) return shapes.filter((shape) => { const box = shape.getBoundingClientRect() return getComputedStyle(shape).fill === 'rgb(0, 0, 0)' && box.width > 12 && box.height > 8 }).length }) } function mermaidSvg(page: Page, diagramIndex: number): Locator { return page.locator('[data-testid="mermaid-diagram-viewport"] svg').nth(diagramIndex) } function mermaidBodyArtifacts(page: Page): Locator { return page.locator([ 'body > [data-tolaria-mermaid-render-host]', 'body > [id^="tolaria-mermaid-"]', 'body > [id^="dtolaria-mermaid-"]', 'body > [id^="itolaria-mermaid-"]', ].join(', ')) } function mermaidNode(page: Page, diagramIndex: number, text: string): Locator { return mermaidSvg(page, diagramIndex).locator('.node').filter({ hasText: text }).first() } async function computedCss(locator: Locator, property: 'background-color' | 'color' | 'fill' | 'stroke'): Promise { return locator.evaluate((element, styleProperty) => ( getComputedStyle(element).getPropertyValue(styleProperty) ), property) } async function switchToResolvedTheme(page: Page, mode: 'dark' | 'light'): Promise { const root = page.locator('html') if (await root.getAttribute('data-theme') !== mode) { await page.getByTestId('status-theme-mode').click() } await expect(root).toHaveAttribute('data-theme', mode) } async function setDocumentZoom(page: Page, percent: number): Promise { await page.evaluate((zoomPercent) => { const factor = zoomPercent / 100 document.documentElement.style.setProperty('zoom', `${zoomPercent}%`) document.documentElement.style.setProperty('--tolaria-overlay-zoom-factor', String(factor)) document.documentElement.style.setProperty('--tolaria-overlay-zoom-inverse', String(1 / factor)) window.dispatchEvent(new Event('laputa-zoom-change')) }, percent) } async function readDialogViewportBounds(page: Page) { return page.getByTestId('mermaid-diagram-dialog-viewport').evaluate((viewport) => { const rect = viewport.getBoundingClientRect() return { width: rect.width, height: rect.height, windowWidth: window.innerWidth, windowHeight: window.innerHeight, } }) } async function dialogViewportFitsWindow(page: Page): Promise { const bounds = await readDialogViewportBounds(page) const horizontallyContained = bounds.width <= bounds.windowWidth - 16 const verticallyContained = bounds.height <= bounds.windowHeight - 16 const stillFullscreenWidth = bounds.width >= bounds.windowWidth - 96 const stillFullscreenHeight = bounds.height >= bounds.windowHeight - 96 return horizontallyContained && verticallyContained && stillFullscreenWidth && stillFullscreenHeight } function mermaidNodeLabel(node: Locator): Locator { return node.locator('.nodeLabel, text').first() } async function readStyleNonce(svg: Locator): Promise { return svg.locator('style').first().evaluate((style) => { const nonce = (style as HTMLElement).nonce return nonce.length > 0 ? nonce : style.getAttribute('nonce') }) } async function labelFitsNode(node: Locator): Promise { return node.evaluate((element) => { const shape = element.querySelector('rect, polygon, circle, ellipse, path')! const label = element.querySelector('.nodeLabel, text')! const shapeBox = shape.getBoundingClientRect() const labelBox = label.getBoundingClientRect() return [ labelBox.width <= shapeBox.width + 2, labelBox.height <= shapeBox.height + 2, ].every(Boolean) }) } async function labelIsHorizontallyCenteredInNode(node: Locator): Promise { return node.evaluate((element) => { const shape = element.querySelector('rect, polygon, circle, ellipse, path')! const label = element.querySelector('.nodeLabel, text')! const shapeBox = shape.getBoundingClientRect() const labelBox = label.getBoundingClientRect() const shapeCenter = shapeBox.left + shapeBox.width / 2 const labelCenter = labelBox.left + labelBox.width / 2 return Math.abs(shapeCenter - labelCenter) <= 2 }) } async function readReportedDiagramMetrics(page: Page, diagramIndex: number) { const svg = mermaidSvg(page, diagramIndex) const scheduledNode = mermaidNode(page, diagramIndex, 'Other path') const scheduledShape = scheduledNode.locator('rect, polygon, circle, ellipse, path').first() const scheduledLabel = mermaidNodeLabel(scheduledNode) return { connectorStroke: await computedCss(svg.locator('.flowchart-link').first(), 'stroke'), markerFill: await computedCss(svg.locator('marker path').first(), 'fill'), scheduledFill: await computedCss(scheduledShape, 'fill'), scheduledStroke: await computedCss(scheduledShape, 'stroke'), scheduledTextFill: await computedCss(scheduledLabel, 'fill'), foreignObjectCount: await svg.locator('foreignObject').count(), labelsFit: [ await labelFitsNode(scheduledNode), await labelFitsNode(mermaidNode(page, diagramIndex, 'Contract path')), await labelFitsNode(mermaidNode(page, diagramIndex, 'unscheduled clocking')), ].every(Boolean), styleNonce: await readStyleNonce(svg), text: await svg.evaluate((element) => element.textContent ?? ''), } } async function readDatabaseDiagramMetrics(page: Page, diagramIndex: number) { const svg = mermaidSvg(page, diagramIndex) const appNode = mermaidNode(page, diagramIndex, 'app') const databaseNode = mermaidNode(page, diagramIndex, 'DB Type') return { rectangleLabelCentered: await labelIsHorizontallyCenteredInNode(appNode), databaseLabelCentered: await labelIsHorizontallyCenteredInNode(databaseNode), databaseLabelFits: await labelFitsNode(databaseNode), foreignObjectCount: await svg.locator('foreignObject').count(), text: await svg.evaluate((element) => element.textContent ?? ''), } } async function readGanttDiagramMetrics(page: Page, diagramIndex: number) { const svg = mermaidSvg(page, diagramIndex) return svg.evaluate((element) => { const viewBox = element.getAttribute('viewBox')?.split(/\s+/u).map(Number) ?? [] const [, , width, height] = viewBox return { roleDescription: element.getAttribute('aria-roledescription'), width, height, text: element.textContent ?? '', taskCount: element.querySelectorAll('.task').length, } }) } function readNoteBFile(): string { return fs.readFileSync(path.join(tempVaultDir, 'note', 'note-b.md'), 'utf8') } test('Mermaid diagrams render when opening saved notes directly', async ({ page }) => { await openNote(page, 'Mermaid Reported') await expectRenderedDiagramCount(page, 1) await expect.poll(() => readReportedDiagramMetrics(page, 0)).toMatchObject({ connectorStroke: 'rgb(31, 58, 138)', markerFill: 'rgb(31, 58, 138)', scheduledFill: 'rgb(255, 244, 214)', scheduledStroke: 'rgb(122, 91, 0)', scheduledTextFill: 'rgb(58, 44, 0)', foreignObjectCount: 0, labelsFit: true, styleNonce: RUNTIME_STYLE_NONCE, text: expect.stringContaining('Linked to a planned shift?'), }) }) test('Mermaid database node labels stay centered inside cylinders', async ({ page }) => { await openNote(page, 'Mermaid Database') await expectRenderedDiagramCount(page, 1) await expect.poll(() => readDatabaseDiagramMetrics(page, 0)).toMatchObject({ rectangleLabelCentered: true, databaseLabelCentered: true, databaseLabelFits: true, foreignObjectCount: 0, text: expect.stringContaining('DB Type'), }) }) test('Mermaid Gantt diagrams render with a nonzero timeline width', async ({ page }) => { await openNote(page, 'Mermaid Gantt') await expectRenderedDiagramCount(page, 1) await expect.poll(() => readGanttDiagramMetrics(page, 0)).toMatchObject({ roleDescription: 'gantt', width: expect.any(Number), height: expect.any(Number), taskCount: 4, text: expect.stringContaining('Testing & Deployment'), }) expect((await readGanttDiagramMetrics(page, 0)).width).toBeGreaterThan(0) }) test('fullscreen Mermaid diagrams keep the active Tolaria surface in dark mode', async ({ page }) => { await openNote(page, 'Mermaid Reported') await expectRenderedDiagramCount(page, 1) await switchToResolvedTheme(page, 'dark') const inlineViewport = page.getByTestId('mermaid-diagram-viewport').first() await expect.poll(() => computedCss(inlineViewport, 'background-color')).not.toBe('rgb(255, 255, 255)') const inlineBackground = await computedCss(inlineViewport, 'background-color') await page.getByTestId('mermaid-diagram').first().hover() await page.getByRole('button', { name: 'Open Mermaid diagram' }).first().click() const dialogViewport = page.getByTestId('mermaid-diagram-dialog-viewport') await expect(dialogViewport).toBeVisible() await expect.poll(() => computedCss(dialogViewport, 'background-color')).toBe(inlineBackground) }) test('fullscreen Mermaid diagrams stay within the viewport while the app is zoomed', async ({ page }) => { await openNote(page, 'Mermaid Reported') await expectRenderedDiagramCount(page, 1) await setDocumentZoom(page, 120) await page.getByTestId('mermaid-diagram').first().hover() await page.getByRole('button', { name: 'Open Mermaid diagram' }).first().click() await expect(page.getByTestId('mermaid-diagram-dialog-viewport')).toBeVisible() await expect.poll(() => dialogViewportFitsWindow(page)).toBe(true) }) test('Mermaid diagrams stay mounted after property edits refresh frontmatter', async ({ page }) => { const pageErrors: string[] = [] page.on('pageerror', error => pageErrors.push(error.message)) await openNote(page, 'Mermaid Reported') await expectRenderedDiagramCount(page, 1) await triggerShortcutCommand(page, APP_COMMAND_IDS.viewToggleProperties) await expect(page.getByTestId('add-property-row')).toBeVisible() const dateRow = page.getByTestId('editable-property').filter({ hasText: 'Date' }) await dateRow.getByTestId('date-display').click() const nextDateLabel = await page.evaluate(() => new Date(2026, 3, 30).toLocaleDateString()) await page.locator(`button[data-day="${nextDateLabel}"]`).first().click() await expect.poll(() => ( fs.readFileSync(path.join(tempVaultDir, 'note', 'mermaid-reported.md'), 'utf8') )).toMatch(/Date: "?2026-04-30"?/) await expectRenderedDiagramCount(page, 1) await expect(page.locator('[data-testid="mermaid-diagram-viewport"]').first()).toContainText('Linked to a planned shift?') expect(pageErrors).toEqual([]) }) test('Mermaid diagram clicks stay stable while a vault reload settles', async ({ page }) => { const pageErrors: string[] = [] page.on('pageerror', error => pageErrors.push(error.message)) await openNote(page, 'Mermaid Reported') await expectRenderedDiagramCount(page, 1) await reloadVault(page) await mermaidSvg(page, 0).click() await expectRenderedDiagramCount(page, 1) await expect(page.locator('#tolaria-fatal-render-error')).toHaveCount(0) await expect(mermaidSvg(page, 0)).toContainText('Linked to a planned shift?') expect(pageErrors).toEqual([]) }) test('Mermaid diagrams render, fall back, and round-trip through raw mode', async ({ page }) => { await openNote(page, 'Note B') await toggleRawMode(page, '.cm-content') const originalContent = await getRawEditorContent(page) const nextContent = `${originalContent.trimEnd()} ${FIRST_DIAGRAM} ${REPORTED_THEME_DIAGRAM} ${INVALID_DIAGRAM} ${SECOND_DIAGRAM} ${SYSTEM_OVERVIEW_DIAGRAM} ` await setRawEditorContent(page, nextContent) await expect.poll(readNoteBFile).toContain(FIRST_DIAGRAM) await toggleRawMode(page, '.bn-editor') await expectRenderedDiagramCount(page, 4) await expect.poll(() => countLargeBlackFilledShapes(page, 3)).toBe(0) await expect.poll(() => readReportedDiagramMetrics(page, 1)).toMatchObject({ connectorStroke: 'rgb(31, 58, 138)', markerFill: 'rgb(31, 58, 138)', scheduledFill: 'rgb(255, 244, 214)', scheduledStroke: 'rgb(122, 91, 0)', scheduledTextFill: 'rgb(58, 44, 0)', foreignObjectCount: 0, labelsFit: true, styleNonce: RUNTIME_STYLE_NONCE, text: expect.stringContaining('Linked to a planned shift?'), }) await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toHaveCount(1) await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toContainText('not a diagram') await page.locator('[data-testid="mermaid-diagram"]').nth(3).hover() await page.getByRole('button', { name: 'Open Mermaid diagram' }).nth(3).click() await expect(page.locator('[data-testid="mermaid-diagram-dialog-viewport"] svg')).toBeVisible() await page.keyboard.press('Escape') await toggleRawMode(page, '.cm-content') const rawAfterRichMode = await getRawEditorContent(page) expect(rawAfterRichMode).toContain(FIRST_DIAGRAM) expect(rawAfterRichMode).toContain(REPORTED_THEME_DIAGRAM) expect(rawAfterRichMode).toContain(INVALID_DIAGRAM) expect(rawAfterRichMode).toContain(SECOND_DIAGRAM) expect(rawAfterRichMode).toContain(SYSTEM_OVERVIEW_DIAGRAM) await setRawEditorContent(page, rawAfterRichMode.replace(FIRST_DIAGRAM, UPDATED_FIRST_DIAGRAM)) await expect.poll(readNoteBFile).toContain(UPDATED_FIRST_DIAGRAM) await toggleRawMode(page, '.bn-editor') await expectRenderedDiagramCount(page, 4) await expect(page.locator('[data-testid="mermaid-diagram-viewport"]').first()).toContainText('Published') await openNote(page, 'Note C') await openNote(page, 'Note B') await toggleRawMode(page, '.cm-content') const reopenedRaw = await getRawEditorContent(page) expect(reopenedRaw).toContain(UPDATED_FIRST_DIAGRAM) expect(reopenedRaw).toContain(REPORTED_THEME_DIAGRAM) expect(reopenedRaw).toContain(INVALID_DIAGRAM) expect(reopenedRaw).toContain(SECOND_DIAGRAM) expect(reopenedRaw).toContain(SYSTEM_OVERVIEW_DIAGRAM) }) test('invalid reported Mermaid syntax recovers across source and AI panel rerenders', async ({ page }) => { await openNote(page, 'Note B') await toggleRawMode(page, '.cm-content') const originalContent = await getRawEditorContent(page) await setRawEditorContent(page, `${originalContent.trimEnd()} ${REPORTED_INVALID_DIAGRAM} `) await expect.poll(readNoteBFile).toContain(REPORTED_INVALID_DIAGRAM) await toggleRawMode(page, '.bn-editor') await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toContainText('## ABC') await expect(mermaidBodyArtifacts(page)).toHaveCount(0) await expect(page.locator('#tolaria-fatal-render-error')).toHaveCount(0) await page.getByRole('button', { name: 'Open the AI panel' }).click() await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 5_000 }) await toggleRawMode(page, '.cm-content') await toggleRawMode(page, '.bn-editor') await expect(page.locator('[data-testid="mermaid-diagram-error"]')).toContainText('## ABC') await expect(mermaidBodyArtifacts(page)).toHaveCount(0) const input = page.getByTestId('agent-input') await input.fill('Confirm the note is still usable.') await page.getByTestId('agent-send').click() await expect(page.getByTestId('ai-message').last()).toBeVisible({ timeout: 5_000 }) await expect(mermaidBodyArtifacts(page)).toHaveCount(0) await expect(page.locator('#tolaria-fatal-render-error')).toHaveCount(0) })