diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8a10ad6b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,133 @@ +# AGENTS.md + +This file provides guidance to coding agents working in this repository. + +## Project Overview + +Directus documentation site built with Nuxt 4 and `@nuxt/content`. Markdown files in `/content` are rendered as pages. Deployed to Vercel on merge to main, served at `https://directus.com/docs` (note the `/docs` base URL in `nuxt.config.ts`). + +## Commands + +```bash +pnpm install # Install dependencies (requires Node.js >=22.18, pnpm 10.29.2) +pnpm dev # Dev server at http://localhost:3000/docs +pnpm build # Production build +pnpm generate # Static site generation (used for Vercel deploy) +pnpm preview # Preview production build locally +pnpm test:mermaid # Mermaid component and export tests +``` + +`pnpm dev` fails with `Invalid URL` unless `DIRECTUS_URL` is set, because the Nuxt server passes it to `createDirectus()` during render. Copy any missing environment variables from `.env.example` to `.env` before the first run. For content-only work, `DIRECTUS_URL` is the sole required value; the remaining variables emit warnings and disable search, analytics, and the assistant. + +Component tests use Vitest and Nuxt Test Utils. Linting is via `@nuxt/eslint` (run through Nuxt's built-in integration). + +## Architecture + +### Content System + +All documentation lives in `/content` as Markdown with YAML frontmatter. Two collections defined in `content.config.ts`: +- `landing` — just `index.md` +- `content` — everything else, with schema requiring `title` (and optional `description`, `authors`, `technologies`, `links`, `icon`) + +Reusable content fragments live in `/content/_partials/` and are included via the `Partial` component. + +### Routing + +- `app/pages/[...slug].vue` — catch-all for content pages +- `app/pages/api/[tag].vue` — OpenAPI-generated API reference (spec comes from the `@directus/openapi` package via `scripts/generate-api-reference.ts`; fixes to endpoint docs belong in the [directus/openapi](https://github.com/directus/openapi) repo) +- `app/pages/tutorials/` — tutorial section with nested routes + +### Custom Markdown Components + +Common Vue components in `app/components/content/` are available in markdown via MDC syntax: + +| Component | MDC Usage | +|---|---| +| `TwoUp` | `::two-up` | +| `ShinyGrid` | `::shiny-grid` | +| `ShinyCard` | `:::shiny-card` | +| `Example` | `:::example` | +| `Faq` | `:::faq` | +| `Chat` | `:::chat` | +| `Mermaid` | `::mermaid{title="Diagram title" filename="diagram-filename"}` | +| `VideoEmbed` | `:video-embed{video-id="..."}` | +| `DocCliSnippet` | `:doc-cli-snippet{command="..."}` | +| `Partial` | `:partial{content="path/to/partial"}` | +| `CtaCloud` | `:cta-cloud` | +| `ProductLink` | `:product-link` | +| `ProseImg` | Overrides default `` in prose | + +Put Mermaid source in a fenced `mermaid` block inside the component. The component renders a themed, interactive diagram with zoom, pan, reset, and self-contained PNG and SVG downloads. + +````mdc +::mermaid{title="Directus request flow" filename="directus-request-flow"} +```mermaid +flowchart LR + Client --> Directus + Directus --> Database +``` +:: +```` + +### Key Config Files + +- `nuxt.config.ts` — modules, prerendering rules, ESLint config, base URL +- `content.config.ts` — content collection schemas (Zod) +- `app/app.config.ts` — navigation structure, UI theme (purple primary), footer links +- `.env.example` — required env vars: Algolia, Directus URL, GTM, Nuxt UI Pro license, PostHog + +### Modules & Integrations + +Nuxt modules: `@nuxt/ui-pro`, `@nuxt/content`, `@nuxtjs/robots`, `@nuxtjs/sitemap`, `@nuxtjs/algolia` (conditional on env vars), `@vueuse/nuxt`, `@nuxt/scripts`. Custom PostHog module in `/modules/posthog/`. + +## Code Style + +- Tabs for indentation (spaces for `.md` and `.yml` — see `.editorconfig`) +- Semicolons required +- ESLint stylistic rules enforced via `@nuxt/eslint` config in `nuxt.config.ts` +- TypeScript throughout + +## Tone of Voice for Documentation Content + +Matching the existing tone is mission-critical. All new or edited content in `/content` must follow these rules: + +### Voice & Person +- Always address the reader as "you" (second person) +- Use active voice — "Create a collection" not "A collection should be created" +- Use imperative mood for instructions — "Run the following command" not "You might want to run" +- Be direct and confident. No hedging ("you might want to", "you could consider") — just tell the reader what to do + +### Formality +- Semi-formal: professional and authoritative, but not stiff or corporate +- Assume the reader is a competent developer — don't over-explain basic concepts +- Contractions are acceptable in explanatory prose ("you'll", "don't", "can't") but keep step-by-step instructions slightly more formal ("you will need" over "you'll need") + +### Sentence Structure +- Keep sentences short to medium length — concise and scannable +- Prefer bullet points and numbered lists to break down processes +- Lead with context ("why") before diving into instructions ("how") +- One idea per sentence. Break complex thoughts into smaller pieces + +### Technical Writing Conventions +- Inline code for: `collection_names`, `field_names`, env vars, API endpoints, file paths +- **Bold** for UI elements ("Click **Create Field**") and key terms on first introduction +- Introduce concepts with a plain-language definition before going deeper — "Collections are database tables with additional metadata and configuration used by Directus." +- Use callout boxes for warnings and important notes, not inline ALL-CAPS or exclamation marks + +### Structure +- Start guides with a "Before You Start" section listing prerequisites +- Use "Next Steps" sections to point to related content +- Use transitions like "Now that..." to connect sections +- Every explanation should tie to a concrete action or use case — minimize abstract theory + +### Things to Avoid +- Filler phrases ("In order to", "It should be noted that", "As a matter of fact") +- Marketing language or hype ("powerful", "revolutionary", "seamless") +- Passive voice in instructions +- Walls of text — if a paragraph exceeds 3-4 sentences, break it up or use a list +- AI-isms ("I'd be happy to help", "Great question!", "Certainly!") +- Telltale AI writing patterns: em dashes (—) used as general-purpose punctuation, "delve", "leverage", "utilize", "straightforward", "it's worth noting", "key" as an adjective. Use normal dashes (-) or rewrite the sentence instead + +## Hosting + +The docs website is hosted as a nested path on the main Directus marketing website https://directus.com/docs. The rest of the Directus website is a separate repo. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8ea1b59f..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,119 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Directus documentation site built with Nuxt 3 and `@nuxt/content`. Markdown files in `/content` are rendered as pages. Deployed to Vercel on merge to main, served at `https://directus.com/docs` (note the `/docs` base URL in `nuxt.config.ts`). - -## Commands - -```bash -pnpm install # Install dependencies (requires Node.js >=22.18, pnpm 10.29.2) -pnpm dev # Dev server at http://localhost:3000/docs -pnpm build # Production build -pnpm generate # Static site generation (used for Vercel deploy) -pnpm preview # Preview production build locally -``` - -`pnpm dev` fails with `Invalid URL` unless `DIRECTUS_URL` is set, because the Nuxt server passes it to `createDirectus()` during render. Copy any missing environment variables from `.env.example` to `.env` before the first run. For content-only work, `DIRECTUS_URL` is the sole required value; the remaining variables emit warnings and disable search, analytics, and the assistant. - -There is no test runner configured. Linting is via `@nuxt/eslint` (run through Nuxt's built-in integration). - -## Architecture - -### Content System - -All documentation lives in `/content` as Markdown with YAML frontmatter. Two collections defined in `content.config.ts`: -- `landing` — just `index.md` -- `content` — everything else, with schema requiring `title` (and optional `description`, `authors`, `technologies`, `links`, `icon`) - -Reusable content fragments live in `/content/_partials/` and are included via the `Partial` component. - -### Routing - -- `app/pages/[...slug].vue` — catch-all for content pages -- `app/pages/api/[tag].vue` — OpenAPI-generated API reference (spec comes from the `@directus/openapi` package via `scripts/generate-api-reference.ts`; fixes to endpoint docs belong in the [directus/openapi](https://github.com/directus/openapi) repo) -- `app/pages/tutorials/` — tutorial section with nested routes - -### Custom Markdown Components - -12 Vue components in `app/components/content/` are available in markdown via MDC syntax: - -| Component | MDC Usage | -|---|---| -| `TwoUp` | `::two-up` | -| `ShinyGrid` | `::shiny-grid` | -| `ShinyCard` | `:::shiny-card` | -| `Example` | `:::example` | -| `Faq` | `:::faq` | -| `Chat` | `:::chat` | -| `VideoEmbed` | `:video-embed{video-id="..."}` | -| `DocCliSnippet` | `:doc-cli-snippet{command="..."}` | -| `Partial` | `:partial{content="path/to/partial"}` | -| `CtaCloud` | `:cta-cloud` | -| `ProductLink` | `:product-link` | -| `ProseImg` | Overrides default `` in prose | - -### Key Config Files - -- `nuxt.config.ts` — modules, prerendering rules, ESLint config, base URL -- `content.config.ts` — content collection schemas (Zod) -- `app/app.config.ts` — navigation structure, UI theme (purple primary), footer links -- `.env.example` — required env vars: Algolia, Directus URL, GTM, Nuxt UI Pro license, PostHog - -### Modules & Integrations - -Nuxt modules: `@nuxt/ui-pro`, `@nuxt/content`, `@nuxtjs/robots`, `@nuxtjs/sitemap`, `@nuxtjs/algolia` (conditional on env vars), `@vueuse/nuxt`, `@nuxt/scripts`. Custom PostHog module in `/modules/posthog/`. - -## Code Style - -- Tabs for indentation (spaces for `.md` and `.yml` — see `.editorconfig`) -- Semicolons required -- ESLint stylistic rules enforced via `@nuxt/eslint` config in `nuxt.config.ts` -- TypeScript throughout - -## Tone of Voice for Documentation Content - -Matching the existing tone is mission-critical. All new or edited content in `/content` must follow these rules: - -### Voice & Person -- Always address the reader as "you" (second person) -- Use active voice — "Create a collection" not "A collection should be created" -- Use imperative mood for instructions — "Run the following command" not "You might want to run" -- Be direct and confident. No hedging ("you might want to", "you could consider") — just tell the reader what to do - -### Formality -- Semi-formal: professional and authoritative, but not stiff or corporate -- Assume the reader is a competent developer — don't over-explain basic concepts -- Contractions are acceptable in explanatory prose ("you'll", "don't", "can't") but keep step-by-step instructions slightly more formal ("you will need" over "you'll need") - -### Sentence Structure -- Keep sentences short to medium length — concise and scannable -- Prefer bullet points and numbered lists to break down processes -- Lead with context ("why") before diving into instructions ("how") -- One idea per sentence. Break complex thoughts into smaller pieces - -### Technical Writing Conventions -- Inline code for: `collection_names`, `field_names`, env vars, API endpoints, file paths -- **Bold** for UI elements ("Click **Create Field**") and key terms on first introduction -- Introduce concepts with a plain-language definition before going deeper — "Collections are database tables with additional metadata and configuration used by Directus." -- Use callout boxes for warnings and important notes, not inline ALL-CAPS or exclamation marks - -### Structure -- Start guides with a "Before You Start" section listing prerequisites -- Use "Next Steps" sections to point to related content -- Use transitions like "Now that..." to connect sections -- Every explanation should tie to a concrete action or use case — minimize abstract theory - -### Things to Avoid -- Filler phrases ("In order to", "It should be noted that", "As a matter of fact") -- Marketing language or hype ("powerful", "revolutionary", "seamless") -- Passive voice in instructions -- Walls of text — if a paragraph exceeds 3-4 sentences, break it up or use a list -- AI-isms ("I'd be happy to help", "Great question!", "Certainly!") -- Telltale AI writing patterns: em dashes (—) used as general-purpose punctuation, "delve", "leverage", "utilize", "straightforward", "it's worth noting", "key" as an adjective. Use normal dashes (-) or rewrite the sentence instead - -## Hosting - -The docs website is hosted as a nested path on the main Directus marketing website https://directus.com/docs. The rest of the Directus website is a separate repo. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/app/components/content/Mermaid.vue b/app/components/content/Mermaid.vue new file mode 100644 index 00000000..2282daa7 --- /dev/null +++ b/app/components/content/Mermaid.vue @@ -0,0 +1,479 @@ + + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index eab1ad05..25ec3300 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -169,6 +169,12 @@ export default defineNuxtConfig({ transpile: ['shiki'], }, + vite: { + optimizeDeps: { + include: ['debug'], + }, + }, + routeRules: { ...loadRedirectRouteRules(), '/api/**': { prerender: true }, diff --git a/package.json b/package.json index a2a9e975..50e5e041 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "typecheck:scripts": "tsc -p scripts/tsconfig.json", "index:docs": "node scripts/index-docs.ts", "typesense:cleanup-preview": "node scripts/cleanup-typesense-preview.ts", + "test:mermaid": "vitest run tests/components/Mermaid.test.ts", "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/scripts/cleanup-typesense-preview.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" }, "dependencies": { @@ -23,6 +24,7 @@ "@directus/openapi": "0.4.0", "@directus/sdk": "^21.2.2", "@directus/vue-split-panel": "^0.8.9", + "@fontsource-variable/inter": "^5.3.0", "@iconify-json/lucide": "1.2.111", "@iconify-json/material-symbols": "1.2.68", "@iconify-json/ph": "^1.2.2", @@ -44,6 +46,7 @@ "@vueuse/core": "14.2.1", "@vueuse/nuxt": "14.2.1", "ai": "6.0.185", + "beautiful-mermaid": "1.1.3", "h3": "1.15.11", "nuxt": "4.4.2", "nuxt-llms": "0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 056c1efb..c6f83f9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@directus/vue-split-panel': specifier: ^0.8.9 version: 0.8.13(vue@3.5.33(typescript@6.0.3)) + '@fontsource-variable/inter': + specifier: ^5.3.0 + version: 5.3.0 '@iconify-json/lucide': specifier: 1.2.111 version: 1.2.111 @@ -87,6 +90,9 @@ importers: ai: specifier: 6.0.185 version: 6.0.185(zod@4.4.3) + beautiful-mermaid: + specifier: 1.1.3 + version: 1.1.3 h3: specifier: 1.15.11 version: 1.15.11 @@ -836,6 +842,9 @@ packages: '@floating-ui/vue@1.1.11': resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -3874,6 +3883,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + beautiful-mermaid@1.1.3: + resolution: {integrity: sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg==} + better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} @@ -4442,6 +4454,9 @@ packages: electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + embla-carousel-auto-height@8.6.0: resolution: {integrity: sha512-/HrJQOEM6aol/oF33gd2QlINcXy3e19fJWvHDuHWp2bpyTa+2dm9tVVJak30m2Qy6QyQ6Fc8DkImtv7pxWOJUQ==} peerDependencies: @@ -8722,6 +8737,8 @@ snapshots: - '@vue/composition-api' - vue + '@fontsource-variable/inter@5.3.0': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 @@ -12083,6 +12100,11 @@ snapshots: baseline-browser-mapping@2.10.21: {} + beautiful-mermaid@1.1.3: + dependencies: + elkjs: 0.11.1 + entities: 7.0.1 + better-sqlite3@11.10.0: dependencies: bindings: 1.5.0 @@ -12624,6 +12646,8 @@ snapshots: electron-to-chromium@1.5.344: {} + elkjs@0.11.1: {} + embla-carousel-auto-height@8.6.0(embla-carousel@8.6.0): dependencies: embla-carousel: 8.6.0 diff --git a/tests/components/Mermaid.test.ts b/tests/components/Mermaid.test.ts new file mode 100644 index 00000000..3959230e --- /dev/null +++ b/tests/components/Mermaid.test.ts @@ -0,0 +1,138 @@ +import { h, nextTick } from 'vue'; +import { flushPromises } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime'; +import Mermaid from '../../app/components/content/Mermaid.vue'; + +mockNuxtImport('useColorMode', () => () => ({ value: 'light' })); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const diagram = `flowchart LR + Client --> Directus`; +const tooltipStub = { template: '' }; + +describe('Mermaid', () => { + it('renders Mermaid source passed as a prop', async () => { + const wrapper = await mountSuspended(Mermaid, { + props: { code: diagram, title: 'Request flow' }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + expect(wrapper.text()).toContain('Request flow'); + expect(wrapper.find('.mermaid-canvas svg').exists()).toBe(true); + expect(wrapper.find('.mermaid-canvas').html()).not.toContain('@import url'); + }); + + it('reads source from an MDC code block slot', async () => { + const wrapper = await mountSuspended(Mermaid, { + slots: { + default: () => h('pre', { code: diagram }, [h('code', diagram)]), + }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + expect(wrapper.find('.mermaid-canvas svg').exists()).toBe(true); + }); + + it('zooms, pans with the keyboard, and resets', async () => { + const wrapper = await mountSuspended(Mermaid, { + props: { code: diagram }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + await wrapper.get('[aria-label="Zoom in"]').trigger('click'); + expect(wrapper.get('.mermaid-canvas').attributes('style')).toContain('scale(1.2)'); + + await wrapper.get('[role="group"]').trigger('keydown', { key: 'ArrowRight' }); + expect(wrapper.get('.mermaid-canvas').attributes('style')).toContain('translate3d(32px, 0px, 0)'); + + await wrapper.get('[aria-label="Reset view"]').trigger('click'); + await nextTick(); + expect(wrapper.get('.mermaid-canvas').attributes('style')).toContain('translate3d(0px, 0px, 0) scale(1)'); + }); + + it('shows renderer errors without breaking the page', async () => { + const wrapper = await mountSuspended(Mermaid, { + props: { code: 'not a diagram' }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + expect(wrapper.get('[role="alert"]').text()).toContain('Invalid mermaid header'); + expect(wrapper.find('.mermaid-canvas').exists()).toBe(false); + }); + + it('downloads the rendered SVG', async () => { + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:diagram'); + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function () { + expect(this.isConnected).toBe(true); + }); + const wrapper = await mountSuspended(Mermaid, { + props: { code: diagram, filename: 'request-flow' }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + await wrapper.get('[aria-label="Download diagram"]').trigger('click'); + await nextTick(); + const svgItem = [...document.querySelectorAll('[role="menuitem"]')] + .find(item => item.textContent?.includes('Download SVG')); + expect(svgItem).toBeDefined(); + svgItem?.click(); + await flushPromises(); + + await vi.waitFor(() => { + expect(createObjectUrl.mock.calls.some(([value]) => value.type.includes('svg'))).toBe(true); + }); + const blob = createObjectUrl.mock.calls.find(([value]) => value.type.includes('svg'))?.[0]; + expect(blob).toBeInstanceOf(Blob); + expect(blob?.type).toBe('image/svg+xml;charset=utf-8'); + const exportedSvg = await blob?.text(); + expect(exportedSvg).toContain('@font-face{font-family:\'Inter\''); + expect(exportedSvg).toContain('data:font/woff2;base64,'); + expect(exportedSvg?.match(/data:font\/woff2;base64,/g)).toHaveLength(1); + expect(exportedSvg).toContain('fill="#ffffff"'); + expect(exportedSvg).not.toContain('var('); + expect(exportedSvg).not.toContain('color-mix('); + expect(click).toHaveBeenCalledOnce(); + expect(document.querySelector('a[download="request-flow.svg"]')).toBeNull(); + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:diagram'); + }); + + it('converts the rendered SVG to PNG before downloading', async () => { + const createObjectUrl = vi.spyOn(URL, 'createObjectURL') + .mockImplementation(blob => blob.type === 'image/png' ? 'blob:png' : 'blob:svg'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const drawImage = vi.fn(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(callback => callback(new Blob(['png'], { type: 'image/png' }))); + vi.stubGlobal('Image', class { + onload: (() => void) | null = null; + + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } + }); + const wrapper = await mountSuspended(Mermaid, { + props: { code: diagram, filename: 'request-flow' }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + await wrapper.get('[aria-label="Download diagram"]').trigger('click'); + await nextTick(); + const pngItem = [...document.querySelectorAll('[role="menuitem"]')] + .find(item => item.textContent?.includes('Download PNG')); + expect(pngItem).toBeDefined(); + pngItem?.click(); + await flushPromises(); + + await vi.waitFor(() => expect(drawImage).toHaveBeenCalledOnce()); + const svgBlob = createObjectUrl.mock.calls.find(([value]) => value.type.includes('svg'))?.[0]; + expect(await svgBlob?.text()).toContain('data:font/woff2;base64,'); + expect(createObjectUrl.mock.calls.some(([value]) => value.type === 'image/png')).toBe(true); + }); +});