Compare commits

...

3 Commits

Author SHA1 Message Date
dayuan.jiang
3359d1c8f7 fix: use OpenAI provider for Doubao multimodal models
DeepSeek provider was not properly formatting image content for Doubao's
API. Now uses OpenAI provider for Doubao models (multimodal support),
while keeping DeepSeek provider for DeepSeek/Kimi models on the platform.
2026-01-05 22:26:58 +09:00
Dayuan Jiang
0026639ee8 fix: add NEXT_PUBLIC_BASE_PATH build arg for subdirectory deployment (#518)
Dockerfile was missing the ARG declaration to receive NEXT_PUBLIC_BASE_PATH
from docker-compose build args, causing subdirectory deployment to fail.

Fixes #478
2026-01-05 21:40:15 +09:00
Dayuan Jiang
c7a85d398f test: add Vitest and Playwright testing infrastructure (#512)
* test: add Vitest and Playwright testing infrastructure

- Add Vitest for unit tests (39 tests)
  - cached-responses.test.ts
  - ai-providers.test.ts
  - chat-helpers.test.ts
  - utils.test.ts
- Add Playwright for E2E tests (3 smoke tests)
  - Homepage load
  - Japanese locale
  - Settings dialog
- Add CI workflow (.github/workflows/test.yml)
- Add vitest.config.mts and playwright.config.ts
- Update .gitignore for test artifacts

* test: add more E2E tests for UI components

- Chat panel tests (interactive elements, iframe)
- Settings tests (dark mode, language, draw.io theme)
- Save dialog tests (buttons exist)
- History dialog tests
- Model config tests
- Keyboard interaction tests
- Upload area tests

Total: 15 E2E tests, all passing

* test: fix E2E test issues from review

Fixes based on Gemini and Codex review:
- Remove brittle nth(1) selector in keyboard tests
- Remove waitForTimeout(500) race condition
- Remove if(isVisible) silent skip patterns
- Add proper assertions instead of no-op checks
- Remove expect(count >= 0) that always passes
- Remove unused hasProviderUI variable

All 14 E2E tests and 39 unit tests pass.

* style: auto-format with Biome

* fix: resolve lint errors for CI

* test(e2e): add diagram generation tests with mocked AI responses

- Add tests for generate, edit, and append diagram operations
- Use SSE mocked responses matching AI SDK UI message stream format
- Generate mxCell XML directly in tests for deterministic assertions
- Tests verify tool card rendering and 'Complete' badge state

* test: add comprehensive E2E tests for all major features

- Error handling tests (API errors, rate limits, network timeout, truncated XML)
- Multi-turn conversation tests (sequential requests, history preservation)
- File upload tests (upload button, file preview, sending with message)
- Theme switching tests (dark mode toggle, persistence, system preference)
- Language switching tests (EN/JA/ZH, persistence, locale URLs)
- Iframe interaction tests (draw.io loading, toolbar, diagram rendering)
- Copy/paste tests (chat input, XML input, special characters)
- History restore tests (new chat, persistence, browser navigation)

* refactor: extract shared test helpers and improve error assertions

- Create tests/e2e/lib/helpers.ts with shared SSE mock functions
- Add proper error UI assertions to error-handling.spec.ts
- Remove waitForTimeout calls in favor of real assertions
- Update 6 test files to use shared helpers

* docs: add testing section to CONTRIBUTING.md

* fix: improve test infrastructure based on PR review

- Fix double build in CI: remove redundant build from playwright webServer
- Export chat helpers from shared module for proper unit testing
- Replace waitForTimeout with explicit waits in E2E tests
- Add data-testid attributes to settings and new chat buttons
- Add list reporter for CI to show failures in logs
- Add Playwright browser caching to speed up CI
- Add vitest coverage configuration
- Fix conditional test assertions to use test.skip() instead of silent pass
- Remove unused variables flagged by linter

* fix: improve E2E test assertions and remove silent skips

- Replace silent test.skip() with explicit conditional skips
- Add actual persistence assertion after page reload
- Use data-testid selector for new chat button test

* refactor: add shared fixtures and test.step() patterns

- Add tests/e2e/lib/fixtures.ts with shared test helpers
- Add tests/e2e/fixtures/diagrams.ts with XML test data
- Add expectBeforeAndAfterReload() helper for persistence tests
- Add test.step() for better test reporting in complex tests
- Consolidate mock helpers into fixtures module
- Reduce code duplication across 17 test files

* fix: make persistence tests more reliable

- Remove expectBeforeAndAfterReload from mocked API tests
- Add explicit test.step() for before/after reload checks
- Add retry config for flaky clipboard tests
- Add sleep after reload for language persistence test

* test: remove flaky XML paste test

* docs: run both unit and e2e tests before PR

* chore: add type check and unit test git hooks

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-01-05 01:37:32 +09:00
43 changed files with 3822 additions and 134 deletions

View File

@@ -20,15 +20,42 @@ npm run lint # Check lint errors
npm run check # Run all checks (CI)
```
Pre-commit hooks via Husky will run Biome automatically on staged files.
Git hooks via Husky run automatically:
- **Pre-commit**: Biome (format/lint) + TypeScript type check
- **Pre-push**: Unit tests
For a better experience, install the [Biome VS Code extension](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) for real-time linting and format-on-save.
## Testing
Run tests before submitting PRs:
```bash
npm run test # Unit tests (Vitest)
npm run test:e2e # E2E tests (Playwright)
```
E2E tests use mocked API responses - no AI provider needed. Tests are in `tests/e2e/`.
To run a specific test file:
```bash
npx playwright test tests/e2e/diagram-generation.spec.ts
```
To run tests with UI mode:
```bash
npx playwright test --ui
```
## Pull Requests
1. Create a feature branch
2. Make changes and ensure `npm run check` passes
3. Submit PR against `main` with a clear description
2. Make changes (pre-commit runs lint + type check automatically)
3. Run E2E tests with `npm run test:e2e`
4. Push (pre-push runs unit tests automatically)
5. Submit PR against `main` with a clear description
CI will run the full test suite on your PR.
## Issues

75
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Test
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
lint-and-unit:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run lint
run: npm run check
- name: Run unit tests
run: npm run test -- --run
e2e:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install chromium --with-deps
- name: Install Playwright deps (cached)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium
- name: Build app
run: npm run build
- name: Run E2E tests
run: npm run test:e2e
env:
CI: true
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7

2
.gitignore vendored
View File

@@ -14,6 +14,8 @@ packages/*/dist
# testing
/coverage
/playwright-report/
/test-results/
# next.js
/.next/

View File

@@ -1 +1,2 @@
npx lint-staged
npx tsc --noEmit

4
.husky/pre-push Normal file
View File

@@ -0,0 +1,4 @@
# Skip if node_modules not installed (e.g., on EC2 push server)
if [ -d "node_modules" ]; then
npm run test -- --run
fi

View File

@@ -30,6 +30,10 @@ ENV NEXT_PUBLIC_DRAWIO_BASE_URL=${NEXT_PUBLIC_DRAWIO_BASE_URL}
ARG NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=false
ENV NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE=${NEXT_PUBLIC_SHOW_ABOUT_AND_NOTICE}
# Build-time argument for subdirectory deployment (e.g., /nextaidrawio)
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH=${NEXT_PUBLIC_BASE_PATH}
# Build Next.js application (standalone mode)
RUN npm run build

View File

@@ -18,6 +18,11 @@ import {
supportsPromptCaching,
} from "@/lib/ai-providers"
import { findCachedResponse } from "@/lib/cached-responses"
import {
isMinimalDiagram,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
import {
checkAndIncrementRequest,
isQuotaEnabled,
@@ -34,93 +39,6 @@ import { getUserIdFromRequest } from "@/lib/user-id"
export const maxDuration = 120
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
const MAX_FILES = 5
// Helper function to validate file parts in messages
function validateFileParts(messages: any[]): {
valid: boolean
error?: string
} {
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
if (sizeInBytes > MAX_FILE_SIZE) {
return {
valid: false,
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
}
}
}
}
}
return { valid: true }
}
// Helper function to check if diagram is minimal/empty
function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent }
})
}
// Helper function to create cached stream response
function createCachedStreamResponse(xml: string): Response {
const toolCallId = `cached-${Date.now()}`

View File

@@ -251,13 +251,26 @@ export async function POST(req: Request) {
}
case "doubao": {
// ByteDance Doubao uses DeepSeek-compatible API
const doubao = createDeepSeek({
apiKey,
baseURL:
baseUrl || "https://ark.cn-beijing.volces.com/api/v3",
})
model = doubao(modelId)
// ByteDance Doubao: use DeepSeek for DeepSeek/Kimi models, OpenAI for others
const doubaoBaseUrl =
baseUrl || "https://ark.cn-beijing.volces.com/api/v3"
const lowerModelId = modelId.toLowerCase()
if (
lowerModelId.includes("deepseek") ||
lowerModelId.includes("kimi")
) {
const doubao = createDeepSeek({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao(modelId)
} else {
const doubao = createOpenAI({
apiKey,
baseURL: doubaoBaseUrl,
})
model = doubao.chat(modelId)
}
break
}

View File

@@ -134,6 +134,7 @@ export const ModelSelectorLogo = ({
}
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
{...props}
alt={`${provider} logo`}

View File

@@ -38,7 +38,6 @@ import {
replaceNodes,
validateAndFixXml,
} from "@/lib/utils"
import ExamplePanel from "./chat-example-panel"
// Helper to extract complete operations from streaming input
function getCompleteOperations(

View File

@@ -1185,6 +1185,7 @@ export default function ChatPanel({
status === "streaming" || status === "submitted"
}
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="new-chat-button"
>
<MessageSquarePlus
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
@@ -1197,6 +1198,7 @@ export default function ChatPanel({
size="icon"
onClick={() => setShowSettingsDialog(true)}
className="hover:bg-accent"
data-testid="settings-button"
>
<Settings
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}

View File

@@ -102,6 +102,7 @@ function ProviderLogo({
const logoName = PROVIDER_LOGO_MAP[provider] || provider
return (
// biome-ignore lint/performance/noImgElement: External URL from models.dev
<img
alt={`${provider} logo`}
className={cn("size-4 dark:invert", className)}
@@ -273,7 +274,7 @@ export function ModelConfigDialog({
// Validate all models
const handleValidate = useCallback(async () => {
if (!selectedProvider) return
if (!selectedProvider || !selectedProviderId) return
// Check credentials based on provider type
const isBedrock = selectedProvider.provider === "bedrock"
@@ -331,14 +332,14 @@ export function ModelConfigDialog({
const data = await response.json()
if (data.valid) {
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: true,
validationError: undefined,
})
} else {
allValid = false
errorCount++
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: data.error || "Validation failed",
})
@@ -346,7 +347,7 @@ export function ModelConfigDialog({
} catch {
allValid = false
errorCount++
updateModel(selectedProviderId!, model.id, {
updateModel(selectedProviderId, model.id, {
validated: false,
validationError: "Network error",
})
@@ -357,7 +358,7 @@ export function ModelConfigDialog({
if (allValid) {
setValidationStatus("success")
updateProvider(selectedProviderId!, { validated: true })
updateProvider(selectedProviderId, { validated: true })
// Reset to idle after showing success briefly (with cleanup)
if (validationResetTimeoutRef.current) {
clearTimeout(validationResetTimeoutRef.current)
@@ -1298,20 +1299,24 @@ export function ModelConfigDialog({
null,
)
}
updateModel(
selectedProviderId!,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
if (
selectedProviderId
) {
updateModel(
selectedProviderId,
model.id,
{
modelId:
e
.target
.value,
validated:
undefined,
validationError:
undefined,
},
)
}
}}
onKeyDown={(
e,

View File

@@ -99,7 +99,7 @@ function handleOptionsRequest(): Response {
})
}
export async function onRequest({ request, env }: any) {
export async function onRequest({ request, env: _env }: any) {
if (request.method === "OPTIONS") {
return handleOptionsRequest()
}

View File

@@ -573,8 +573,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
const bedrockProvider = hasClientCredentials
? createAmazonBedrock({
region: bedrockRegion,
accessKeyId: overrides.awsAccessKeyId!,
secretAccessKey: overrides.awsSecretAccessKey!,
accessKeyId: overrides.awsAccessKeyId as string,
secretAccessKey: overrides.awsSecretAccessKey as string,
...(overrides?.awsSessionToken && {
sessionToken: overrides.awsSessionToken,
}),
@@ -871,11 +871,24 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
overrides?.baseUrl ||
process.env.DOUBAO_BASE_URL ||
"https://ark.cn-beijing.volces.com/api/v3"
const doubaoProvider = createDeepSeek({
apiKey,
baseURL,
})
model = doubaoProvider(modelId)
const lowerModelId = modelId.toLowerCase()
// Use DeepSeek provider for DeepSeek/Kimi models, OpenAI for others (multimodal support)
if (
lowerModelId.includes("deepseek") ||
lowerModelId.includes("kimi")
) {
const doubaoProvider = createDeepSeek({
apiKey,
baseURL,
})
model = doubaoProvider(modelId)
} else {
const doubaoProvider = createOpenAI({
apiKey,
baseURL,
})
model = doubaoProvider.chat(modelId)
}
break
}

89
lib/chat-helpers.ts Normal file
View File

@@ -0,0 +1,89 @@
// Shared helper functions for chat route
// Exported for testing
// File upload limits (must match client-side)
export const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
export const MAX_FILES = 5
// Helper function to validate file parts in messages
export function validateFileParts(messages: any[]): {
valid: boolean
error?: string
} {
const lastMessage = messages[messages.length - 1]
const fileParts =
lastMessage?.parts?.filter((p: any) => p.type === "file") || []
if (fileParts.length > MAX_FILES) {
return {
valid: false,
error: `Too many files. Maximum ${MAX_FILES} allowed.`,
}
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url?.startsWith("data:")) {
const base64Data = filePart.url.split(",")[1]
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4)
if (sizeInBytes > MAX_FILE_SIZE) {
return {
valid: false,
error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.`,
}
}
}
}
}
return { valid: true }
}
// Helper function to check if diagram is minimal/empty
export function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, "")
return !stripped.includes('id="2"')
}
// Helper function to replace historical tool call XML with placeholders
// This reduces token usage and forces LLM to rely on the current diagram XML (source of truth)
// Also fixes invalid/undefined inputs from interrupted streaming
export function replaceHistoricalToolInputs(messages: any[]): any[] {
return messages.map((msg) => {
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
return msg
}
const replacedContent = msg.content
.map((part: any) => {
if (part.type === "tool-call") {
const toolName = part.toolName
// Fix invalid/undefined inputs from interrupted streaming
if (
!part.input ||
typeof part.input !== "object" ||
Object.keys(part.input).length === 0
) {
// Skip tool calls with invalid inputs entirely
return null
}
if (
toolName === "display_diagram" ||
toolName === "edit_diagram"
) {
return {
...part,
input: {
placeholder:
"[XML content replaced - see current diagram XML in system context]",
},
}
}
}
return part
})
.filter(Boolean) // Remove null entries (invalid tool calls)
return { ...msg, content: replacedContent }
})
}

1400
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,9 @@
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
"dist:win": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --win",
"dist:linux": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --linux",
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux"
"dist:all": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac --win --linux",
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.1",
@@ -66,7 +68,6 @@
"cmdk": "^1.1.1",
"idb": "^8.0.3",
"js-tiktoken": "^1.0.21",
"jsdom": "^27.0.0",
"jsonrepair": "^3.13.1",
"lucide-react": "^0.562.0",
"motion": "^12.23.25",
@@ -103,13 +104,19 @@
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "^2.3.10",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/negotiator": "^0.6.4",
"@types/node": "^24.0.0",
"@types/pako": "^2.0.3",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.2",
"@vitest/coverage-v8": "^4.0.16",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"electron": "^39.2.7",
@@ -118,10 +125,13 @@
"eslint": "9.39.2",
"eslint-config-next": "16.1.1",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
"lint-staged": "^16.2.7",
"shx": "^0.4.0",
"tailwindcss": "^4",
"typescript": "^5",
"vite-tsconfig-paths": "^6.0.3",
"vitest": "^4.0.16",
"wait-on": "^9.0.3",
"wrangler": "4.54.0"
},

28
playwright.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from "@playwright/test"
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [["list"], ["html"]] : "html",
webServer: {
command: process.env.CI ? "npm run start" : "npm run dev",
port: process.env.CI ? 6001 : 6002,
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
use: {
baseURL: process.env.CI
? "http://localhost:6001"
: "http://localhost:6002",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { browserName: "chromium" },
},
],
})

22
tests/e2e/chat.spec.ts Normal file
View File

@@ -0,0 +1,22 @@
import { expect, getIframe, test } from "./lib/fixtures"
test.describe("Chat Panel", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("page has interactive elements", async ({ page }) => {
const buttons = page.locator("button")
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
})
test("draw.io iframe is interactive", async ({ page }) => {
const iframe = getIframe(page)
await expect(iframe).toBeVisible()
const src = await iframe.getAttribute("src")
expect(src).toBeTruthy()
})
})

View File

@@ -0,0 +1,137 @@
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("Copy/Paste Functionality", () => {
test("can paste text into chat input", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.focus()
await page.keyboard.insertText("Create a flowchart diagram")
await expect(chatInput).toHaveValue("Create a flowchart diagram")
})
test("can paste multiline text into chat input", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.focus()
const multilineText = "Line 1\nLine 2\nLine 3"
await page.keyboard.insertText(multilineText)
await expect(chatInput).toHaveValue(multilineText)
})
test("copy button copies response text", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Here is your diagram with a test box.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create a test box")
// Wait for response
await expect(
page.locator('text="Here is your diagram with a test box."'),
).toBeVisible({ timeout: 15000 })
// Find copy button in message
const copyButton = page.locator(
'[data-testid="copy-button"], button[aria-label*="Copy"], button:has(svg.lucide-copy), button:has(svg.lucide-clipboard)',
)
// Copy button feature may not exist - skip if not available
const buttonCount = await copyButton.count()
if (buttonCount === 0) {
test.skip()
return
}
await copyButton.first().click()
await expect(
page.locator('text="Copied"').or(page.locator("svg.lucide-check")),
).toBeVisible({ timeout: 3000 })
})
test("keyboard shortcuts work in chat input", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.fill("Hello world")
await chatInput.press("ControlOrMeta+a")
await chatInput.fill("New text")
await expect(chatInput).toHaveValue("New text")
})
test("can undo/redo in chat input", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.fill("First text")
await chatInput.press("Tab")
await chatInput.focus()
await chatInput.fill("Second text")
await chatInput.press("ControlOrMeta+z")
// Verify page is still functional after undo
await expect(chatInput).toBeVisible()
})
test("chat input handles special characters", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
const specialText = "Test <>&\"' special chars 日本語 中文 🎉"
await chatInput.fill(specialText)
await expect(chatInput).toHaveValue(specialText)
})
test("long text in chat input scrolls", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
const longText = "This is a very long text. ".repeat(50)
await chatInput.fill(longText)
const value = await chatInput.inputValue()
expect(value.length).toBeGreaterThan(500)
})
})

View File

@@ -0,0 +1,128 @@
import {
CAT_DIAGRAM_XML,
FLOWCHART_XML,
NEW_NODE_XML,
} from "./fixtures/diagrams"
import {
createMultiTurnMock,
expect,
getChatInput,
sendMessage,
test,
waitForComplete,
waitForCompleteCount,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("Diagram Generation", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
CAT_DIAGRAM_XML,
"I'll create a diagram for you.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
})
test("generates and displays a diagram", async ({ page }) => {
await sendMessage(page, "Draw a cat")
await expect(page.locator('text="Generate Diagram"')).toBeVisible({
timeout: 15000,
})
await waitForComplete(page)
})
test("chat input clears after sending", async ({ page }) => {
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.fill("Draw a cat")
await chatInput.press("ControlOrMeta+Enter")
await expect(chatInput).toHaveValue("", { timeout: 5000 })
})
test("user message appears in chat", async ({ page }) => {
await sendMessage(page, "Draw a cute cat")
await expect(page.locator('text="Draw a cute cat"')).toBeVisible({
timeout: 10000,
})
})
test("assistant text message appears in chat", async ({ page }) => {
await sendMessage(page, "Draw a cat")
await expect(
page.locator('text="I\'ll create a diagram for you."'),
).toBeVisible({ timeout: 10000 })
})
})
test.describe("Diagram Edit", () => {
test.beforeEach(async ({ page }) => {
await page.route(
"**/api/chat",
createMultiTurnMock([
{ xml: FLOWCHART_XML, text: "I'll create a diagram for you." },
{
xml: FLOWCHART_XML.replace("Process", "Updated Process"),
text: "I'll create a diagram for you.",
},
]),
)
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
})
test("can edit an existing diagram", async ({ page }) => {
// First: create initial diagram
await sendMessage(page, "Create a flowchart")
await waitForComplete(page)
// Second: edit the diagram
await sendMessage(page, "Change Process to Updated Process")
await waitForCompleteCount(page, 2)
})
})
test.describe("Diagram Append", () => {
test.beforeEach(async ({ page }) => {
await page.route(
"**/api/chat",
createMultiTurnMock([
{ xml: FLOWCHART_XML, text: "I'll create a diagram for you." },
{
xml: NEW_NODE_XML,
text: "I'll create a diagram for you.",
toolName: "append_diagram",
},
]),
)
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
})
test("can append to an existing diagram", async ({ page }) => {
// First: create initial diagram
await sendMessage(page, "Create a flowchart")
await waitForComplete(page)
// Second: append to diagram
await sendMessage(page, "Add a new node to the right")
await waitForCompleteCount(page, 2)
})
})

View File

@@ -0,0 +1,136 @@
import { TRUNCATED_XML } from "./fixtures/diagrams"
import {
createErrorMock,
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
test.describe("Error Handling", () => {
test("displays error message when API returns 500", async ({ page }) => {
await page.route(
"**/api/chat",
createErrorMock(500, "Internal server error"),
)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/error|failed|something went wrong/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// User should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Retry message")
await expect(chatInput).toHaveValue("Retry message")
})
test("displays error message when API returns 429 rate limit", async ({
page,
}) => {
await page.route(
"**/api/chat",
createErrorMock(429, "Rate limit exceeded"),
)
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication for rate limit
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/rate limit|too many|try again/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// User should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Retry after rate limit")
await expect(chatInput).toHaveValue("Retry after rate limit")
})
test("handles network timeout gracefully", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000))
await route.abort("timedout")
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw a cat")
// Should show error indication for network failure
const errorIndicator = page
.locator('[role="alert"]')
.or(page.locator("[data-sonner-toast]"))
.or(page.locator("text=/error|failed|network|timeout/i"))
await expect(errorIndicator.first()).toBeVisible({ timeout: 10000 })
// After timeout, user should be able to type again
const chatInput = getChatInput(page)
await chatInput.fill("Try again after timeout")
await expect(chatInput).toHaveValue("Try again after timeout")
})
test("shows truncated badge for incomplete XML", async ({ page }) => {
const toolCallId = `call_${Date.now()}`
const textId = `text_${Date.now()}`
const messageId = `msg_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: "Creating diagram..." },
{ type: "text-end", id: textId },
{
type: "tool-input-start",
toolCallId,
toolName: "display_diagram",
},
{
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml: TRUNCATED_XML },
},
{
type: "tool-output-error",
toolCallId,
error: "XML validation failed",
},
{ type: "finish" },
]
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body:
events
.map((e) => `data: ${JSON.stringify(e)}\n\n`)
.join("") + "data: [DONE]\n\n",
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Draw something")
// Should show truncated badge
await expect(page.locator('text="Truncated"')).toBeVisible({
timeout: 15000,
})
})
})

View File

@@ -0,0 +1,152 @@
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
getChatInput,
getIframe,
sendMessage,
test,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("File Upload", () => {
test("upload button opens file picker", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const uploadButton = page.locator(
'button[aria-label="Upload file"], button:has(svg.lucide-image)',
)
await expect(uploadButton.first()).toBeVisible({ timeout: 10000 })
await expect(uploadButton.first()).toBeEnabled()
})
test("shows file preview after selecting image", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles({
name: "test-image.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
})
await expect(
page.locator('[role="alert"][data-type="error"]'),
).not.toBeVisible({ timeout: 2000 })
})
test("can remove uploaded file", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles({
name: "test-image.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
})
await expect(
page.locator('[role="alert"][data-type="error"]'),
).not.toBeVisible({ timeout: 2000 })
const removeButton = page.locator(
'[data-testid="remove-file-button"], button[aria-label*="Remove"], button:has(svg.lucide-x)',
)
const removeButtonCount = await removeButton.count()
if (removeButtonCount === 0) {
test.skip()
return
}
await removeButton.first().click()
await expect(removeButton.first()).not.toBeVisible({ timeout: 2000 })
})
test("sends file with message to API", async ({ page }) => {
let capturedRequest: any = null
await page.route("**/api/chat", async (route) => {
capturedRequest = route.request()
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Based on your image, here is a diagram:",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const fileInput = page.locator('input[type="file"]')
await fileInput.setInputFiles({
name: "architecture.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
),
})
await sendMessage(page, "Convert this to a diagram")
await expect(
page.locator('text="Based on your image, here is a diagram:"'),
).toBeVisible({ timeout: 15000 })
expect(capturedRequest).not.toBeNull()
})
test("shows error for oversized file", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const fileInput = page.locator('input[type="file"]')
const largeBuffer = Buffer.alloc(3 * 1024 * 1024, "x")
await fileInput.setInputFiles({
name: "large-image.png",
mimeType: "image/png",
buffer: largeBuffer,
})
await expect(
page.locator('[role="alert"], [data-sonner-toast]').first(),
).toBeVisible({ timeout: 5000 })
})
test("drag and drop file upload works", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatForm = page.locator("form").first()
const dataTransfer = await page.evaluateHandle(() => {
const dt = new DataTransfer()
const file = new File(["test content"], "dropped-image.png", {
type: "image/png",
})
dt.items.add(file)
return dt
})
await chatForm.dispatchEvent("dragover", { dataTransfer })
await chatForm.dispatchEvent("drop", { dataTransfer })
await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })
})
})

View File

@@ -0,0 +1,50 @@
/**
* Shared XML diagram fixtures for E2E tests
*/
// Simple cat diagram
export const CAT_DIAGRAM_XML = `<mxCell id="cat-head" value="Cat Head" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4B5;" vertex="1" parent="1">
<mxGeometry x="200" y="100" width="100" height="80" as="geometry"/>
</mxCell>
<mxCell id="cat-body" value="Cat Body" style="ellipse;whiteSpace=wrap;html=1;fillColor=#FFE4B5;" vertex="1" parent="1">
<mxGeometry x="180" y="180" width="140" height="100" as="geometry"/>
</mxCell>`
// Simple flowchart
export const FLOWCHART_XML = `<mxCell id="start" value="Start" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" vertex="1" parent="1">
<mxGeometry x="200" y="50" width="100" height="40" as="geometry"/>
</mxCell>
<mxCell id="process" value="Process" style="rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;" vertex="1" parent="1">
<mxGeometry x="200" y="130" width="100" height="40" as="geometry"/>
</mxCell>
<mxCell id="end" value="End" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;" vertex="1" parent="1">
<mxGeometry x="200" y="210" width="100" height="40" as="geometry"/>
</mxCell>`
// Simple single box
export const SINGLE_BOX_XML = `<mxCell id="box" value="Test Box" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
// Test node for iframe interaction tests
export const TEST_NODE_XML = `<mxCell id="test-node-123" value="Test Node" style="rounded=1;fillColor=#d5e8d4;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
// Architecture box
export const ARCHITECTURE_XML = `<mxCell id="arch" value="Architecture" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="50" as="geometry"/>
</mxCell>`
// New node for append tests
export const NEW_NODE_XML = `<mxCell id="new-node" value="New Node" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;" vertex="1" parent="1">
<mxGeometry x="350" y="130" width="100" height="40" as="geometry"/>
</mxCell>`
// Truncated XML for error tests
export const TRUNCATED_XML = `<mxCell id="node1" value="Start" style="rounded=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="100" height="40"`
// Simple boxes for multi-turn tests
export const createBoxXml = (id: string, label: string, y = 100) =>
`<mxCell id="${id}" value="${label}" style="rounded=1;" vertex="1" parent="1"><mxGeometry x="100" y="${y}" width="100" height="40" as="geometry"/></mxCell>`

View File

@@ -0,0 +1,215 @@
import { SINGLE_BOX_XML } from "./fixtures/diagrams"
import {
expect,
expectBeforeAndAfterReload,
getChatInput,
getIframe,
getIframeContent,
openSettings,
sendMessage,
test,
waitForComplete,
waitForText,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("History and Session Restore", () => {
test("new chat button clears conversation", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created your test diagram.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("create a conversation", async () => {
await sendMessage(page, "Create a test diagram")
await waitForText(page, "Created your test diagram.")
})
await test.step("click new chat button", async () => {
const newChatButton = page.locator(
'[data-testid="new-chat-button"]',
)
await expect(newChatButton).toBeVisible({ timeout: 5000 })
await newChatButton.click()
})
await test.step("verify conversation is cleared", async () => {
await expect(
page.locator('text="Created your test diagram."'),
).not.toBeVisible({ timeout: 5000 })
})
})
test("chat history sidebar shows past conversations", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const historyButton = page.locator(
'button[aria-label*="History"]:not([disabled]), button:has(svg.lucide-history):not([disabled]), button:has(svg.lucide-menu):not([disabled]), button:has(svg.lucide-sidebar):not([disabled]), button:has(svg.lucide-panel-left):not([disabled])',
)
const buttonCount = await historyButton.count()
if (buttonCount === 0) {
test.skip()
return
}
await historyButton.first().click()
await expect(getChatInput(page)).toBeVisible({ timeout: 3000 })
})
test("conversation persists after page reload", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"This message should persist.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("create conversation", async () => {
await sendMessage(page, "Create persistent diagram")
await waitForText(page, "This message should persist.")
})
await test.step("verify message appears before reload", async () => {
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
await expect(
page.locator('text="This message should persist."'),
).toBeVisible({ timeout: 10000 })
})
// Note: After reload, mocked responses won't persist since we're not
// testing with real localStorage. We just verify the app loads correctly.
await test.step("verify app loads after reload", async () => {
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
})
})
test("diagram state persists after reload", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Created a diagram that should be saved.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create saveable diagram")
await waitForComplete(page)
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const frame = getIframeContent(page)
await expect(
frame
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
.first(),
).toBeVisible({ timeout: 30000 })
})
test("can restore from browser back/forward", async ({ page }) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
SINGLE_BOX_XML,
"Testing browser navigation.",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Test navigation")
await waitForText(page, "Testing browser navigation.")
await page.goto("/about", { waitUntil: "networkidle" })
await page.goBack({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
})
test("settings are restored after reload", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
await page.keyboard.press("Escape")
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
})
test("model selection persists", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const modelSelector = page.locator(
'button[aria-label*="Model"], [data-testid="model-selector"], button:has-text("Claude")',
)
const selectorCount = await modelSelector.count()
if (selectorCount === 0) {
test.skip()
return
}
const initialModel = await modelSelector.first().textContent()
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const modelAfterReload = await modelSelector.first().textContent()
expect(modelAfterReload).toBe(initialModel)
})
test("handles localStorage quota exceeded gracefully", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await page.evaluate(() => {
try {
const largeData = "x".repeat(5 * 1024 * 1024)
localStorage.setItem("test-large-data", largeData)
} catch {
// Expected to fail on some browsers
}
})
await expect(getChatInput(page)).toBeVisible({ timeout: 10000 })
await page.evaluate(() => {
localStorage.removeItem("test-large-data")
})
})
})

18
tests/e2e/history.spec.ts Normal file
View File

@@ -0,0 +1,18 @@
import { expect, getIframe, test } from "./lib/fixtures"
test.describe("History Dialog", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("history button exists in UI", async ({ page }) => {
// History button may be disabled initially (no history)
// Just verify it exists in the DOM
const historyButton = page
.locator("button")
.filter({ has: page.locator("svg") })
const count = await historyButton.count()
expect(count).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,122 @@
import { TEST_NODE_XML } from "./fixtures/diagrams"
import {
expect,
getChatInput,
getIframe,
getIframeContent,
sendMessage,
test,
waitForComplete,
} from "./lib/fixtures"
import { createMockSSEResponse } from "./lib/helpers"
test.describe("Iframe Interaction", () => {
test("draw.io iframe loads successfully", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
const iframe = getIframe(page)
await expect(iframe).toBeVisible({ timeout: 30000 })
// iframe should have loaded draw.io content
const frame = getIframeContent(page)
await expect(
frame
.locator(".geMenubarContainer, .geDiagramContainer, canvas")
.first(),
).toBeVisible({ timeout: 30000 })
})
test("can interact with draw.io toolbar", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const frame = getIframeContent(page)
// Draw.io menu items should be accessible
await expect(
frame
.locator('text="Diagram"')
.or(frame.locator('[title*="Diagram"]')),
).toBeVisible({ timeout: 10000 })
})
test("diagram XML is rendered in iframe after generation", async ({
page,
}) => {
await page.route("**/api/chat", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
TEST_NODE_XML,
"Here is your diagram:",
),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await sendMessage(page, "Create a test node")
await waitForComplete(page)
// Give draw.io time to render
await page.waitForTimeout(1000)
})
test("zoom controls work in draw.io", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const frame = getIframeContent(page)
// draw.io should be loaded and functional - check for diagram container
await expect(
frame.locator(".geDiagramContainer, canvas").first(),
).toBeVisible({ timeout: 10000 })
})
test("can resize the panel divider", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// Find the resizer/divider between panels
const resizer = page.locator(
'[role="separator"], [data-panel-resize-handle-id], .resize-handle',
)
if ((await resizer.count()) > 0) {
await expect(resizer.first()).toBeVisible()
const box = await resizer.first().boundingBox()
if (box) {
await page.mouse.move(
box.x + box.width / 2,
box.y + box.height / 2,
)
await page.mouse.down()
await page.mouse.move(box.x + 50, box.y + box.height / 2)
await page.mouse.up()
}
}
})
test("iframe responds to window resize", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const iframe = getIframe(page)
const initialBox = await iframe.boundingBox()
// Resize window
await page.setViewportSize({ width: 800, height: 600 })
await page.waitForTimeout(500)
const newBox = await iframe.boundingBox()
expect(newBox).toBeDefined()
if (initialBox && newBox) {
expect(newBox.width).toBeLessThanOrEqual(800)
}
})
})

View File

@@ -0,0 +1,26 @@
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
test.describe("Keyboard Interactions", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("Escape closes settings dialog", async ({ page }) => {
await openSettings(page)
const dialog = page.locator('[role="dialog"]')
await expect(dialog).toBeVisible({ timeout: 5000 })
await page.keyboard.press("Escape")
await expect(dialog).not.toBeVisible({ timeout: 2000 })
})
test("page is keyboard accessible", async ({ page }) => {
const focusableElements = page.locator(
'button, [tabindex="0"], input, textarea, a[href]',
)
const count = await focusableElements.count()
expect(count).toBeGreaterThan(0)
})
})

105
tests/e2e/language.spec.ts Normal file
View File

@@ -0,0 +1,105 @@
import {
expect,
expectBeforeAndAfterReload,
getChatInput,
getIframe,
openSettings,
sleep,
test,
} from "./lib/fixtures"
test.describe("Language Switching", () => {
test("loads English by default", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await expect(page.locator('button:has-text("Send")')).toBeVisible()
})
test("can switch to Japanese", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("open settings and select Japanese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="日本語"').click()
})
await test.step("verify UI is in Japanese", async () => {
await expect(page.locator('button:has-text("送信")')).toBeVisible({
timeout: 5000,
})
})
})
test("can switch to Chinese", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("open settings and select Chinese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="中文"').click()
})
await test.step("verify UI is in Chinese", async () => {
await expect(page.locator('button:has-text("发送")')).toBeVisible({
timeout: 5000,
})
})
})
test("language persists after reload", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step("switch to Japanese", async () => {
await openSettings(page)
const languageSelector = page.locator('button:has-text("English")')
await languageSelector.first().click()
await page.locator('text="日本語"').click()
await page.keyboard.press("Escape")
await sleep(500)
})
await test.step("verify Japanese before reload", async () => {
await expect(page.locator('button:has-text("送信")')).toBeVisible({
timeout: 10000,
})
})
await test.step("reload and verify Japanese persists", async () => {
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
// Wait for hydration and localStorage to be read
await sleep(1000)
await expect(page.locator('button:has-text("送信")')).toBeVisible({
timeout: 10000,
})
})
})
test("Japanese locale URL works", async ({ page }) => {
await page.goto("/ja", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(page.locator('button:has-text("送信")')).toBeVisible({
timeout: 10000,
})
})
test("Chinese locale URL works", async ({ page }) => {
await page.goto("/zh", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await expect(page.locator('button:has-text("发送")')).toBeVisible({
timeout: 10000,
})
})
})

208
tests/e2e/lib/fixtures.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* Playwright test fixtures for E2E tests
* Uses test.extend to provide common setup and helpers
*/
import { test as base, expect, type Page, type Route } from "@playwright/test"
import { createMockSSEResponse, createTextOnlyResponse } from "./helpers"
/**
* Extended test with common fixtures
*/
export const test = base.extend<{
/** Page with iframe already loaded */
appPage: Page
}>({
appPage: async ({ page }, use) => {
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
await use(page)
},
})
export { expect }
// ============================================
// Locator helpers
// ============================================
/** Get the chat input textarea */
export function getChatInput(page: Page) {
return page.locator('textarea[aria-label="Chat input"]')
}
/** Get the draw.io iframe */
export function getIframe(page: Page) {
return page.locator("iframe")
}
/** Get the iframe's frame locator for internal queries */
export function getIframeContent(page: Page) {
return page.frameLocator("iframe")
}
/** Get the settings button */
export function getSettingsButton(page: Page) {
return page.locator('[data-testid="settings-button"]')
}
// ============================================
// Action helpers
// ============================================
/** Send a message in the chat input */
export async function sendMessage(page: Page, message: string) {
const chatInput = getChatInput(page)
await expect(chatInput).toBeVisible({ timeout: 10000 })
await chatInput.fill(message)
await chatInput.press("ControlOrMeta+Enter")
}
/** Wait for diagram generation to complete */
export async function waitForComplete(page: Page, timeout = 15000) {
await expect(page.locator('text="Complete"')).toBeVisible({ timeout })
}
/** Wait for N "Complete" badges */
export async function waitForCompleteCount(
page: Page,
count: number,
timeout = 15000,
) {
await expect(page.locator('text="Complete"')).toHaveCount(count, {
timeout,
})
}
/** Wait for a specific text to appear */
export async function waitForText(page: Page, text: string, timeout = 15000) {
await expect(page.locator(`text="${text}"`)).toBeVisible({ timeout })
}
/** Open settings dialog */
export async function openSettings(page: Page) {
await getSettingsButton(page).click()
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 })
}
// ============================================
// Mock helpers
// ============================================
interface MockResponse {
xml: string
text: string
toolName?: string
}
/**
* Create a multi-turn mock handler
* Each request gets the next response in the array
*/
export function createMultiTurnMock(responses: MockResponse[]) {
let requestCount = 0
return async (route: Route) => {
const response =
responses[requestCount] || responses[responses.length - 1]
requestCount++
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(
response.xml,
response.text,
response.toolName,
),
})
}
}
/**
* Create a mock that returns text-only responses
*/
export function createTextOnlyMock(responses: string[]) {
let requestCount = 0
return async (route: Route) => {
const text = responses[requestCount] || responses[responses.length - 1]
requestCount++
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createTextOnlyResponse(text),
})
}
}
/**
* Create a mock that alternates between text and diagram responses
*/
export function createMixedMock(
responses: Array<
| { type: "text"; text: string }
| { type: "diagram"; xml: string; text: string }
>,
) {
let requestCount = 0
return async (route: Route) => {
const response =
responses[requestCount] || responses[responses.length - 1]
requestCount++
if (response.type === "text") {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createTextOnlyResponse(response.text),
})
} else {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createMockSSEResponse(response.xml, response.text),
})
}
}
}
/**
* Create a mock that returns an error
*/
export function createErrorMock(status: number, error: string) {
return async (route: Route) => {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ error }),
})
}
}
// ============================================
// Persistence helpers
// ============================================
/**
* Test that state persists across page reload.
* Runs assertions before reload, reloads page, then runs assertions again.
* Keep assertions narrow and explicit - test one specific thing.
*
* @param page - Playwright page
* @param description - What persistence is being tested (for debugging)
* @param assertion - Async function with expect() calls
*/
export async function expectBeforeAndAfterReload(
page: Page,
description: string,
assertion: () => Promise<void>,
) {
await test.step(`verify ${description} before reload`, assertion)
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await test.step(`verify ${description} after reload`, assertion)
}
/** Simple sleep helper */
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

88
tests/e2e/lib/helpers.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Shared test helpers for E2E tests
*/
/**
* Creates a mock SSE response for the chat API
* Format matches AI SDK UI message stream protocol
*/
export function createMockSSEResponse(
xml: string,
text: string,
toolName = "display_diagram",
) {
const messageId = `msg_${Date.now()}`
const toolCallId = `call_${Date.now()}`
const textId = `text_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: text },
{ type: "text-end", id: textId },
{ type: "tool-input-start", toolCallId, toolName },
{ type: "tool-input-available", toolCallId, toolName, input: { xml } },
{
type: "tool-output-available",
toolCallId,
output: "Successfully displayed the diagram",
},
{ type: "finish" },
]
return (
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
"data: [DONE]\n\n"
)
}
/**
* Creates a text-only SSE response (no tool call)
*/
export function createTextOnlyResponse(text: string) {
const messageId = `msg_${Date.now()}`
const textId = `text_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: text },
{ type: "text-end", id: textId },
{ type: "finish" },
]
return (
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
"data: [DONE]\n\n"
)
}
/**
* Creates a mock SSE response with a tool error
*/
export function createToolErrorResponse(text: string, errorMessage: string) {
const messageId = `msg_${Date.now()}`
const toolCallId = `call_${Date.now()}`
const textId = `text_${Date.now()}`
const events = [
{ type: "start", messageId },
{ type: "text-start", id: textId },
{ type: "text-delta", id: textId, delta: text },
{ type: "text-end", id: textId },
{ type: "tool-input-start", toolCallId, toolName: "display_diagram" },
{
type: "tool-input-available",
toolCallId,
toolName: "display_diagram",
input: { xml: "<invalid>" },
},
{ type: "tool-output-error", toolCallId, error: errorMessage },
{ type: "finish" },
]
return (
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("") +
"data: [DONE]\n\n"
)
}

View File

@@ -0,0 +1,19 @@
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
test.describe("Model Configuration", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("settings dialog opens and shows configuration options", async ({
page,
}) => {
await openSettings(page)
const dialog = page.locator('[role="dialog"]')
const buttons = dialog.locator("button")
const buttonCount = await buttons.count()
expect(buttonCount).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,113 @@
import { ARCHITECTURE_XML, createBoxXml } from "./fixtures/diagrams"
import {
createMixedMock,
createMultiTurnMock,
expect,
getChatInput,
sendMessage,
test,
waitForComplete,
waitForText,
} from "./lib/fixtures"
import { createTextOnlyResponse } from "./lib/helpers"
test.describe("Multi-turn Conversation", () => {
test("handles multiple diagram requests in sequence", async ({ page }) => {
await page.route(
"**/api/chat",
createMultiTurnMock([
{
xml: createBoxXml("box1", "First"),
text: "Creating diagram 1...",
},
{
xml: createBoxXml("box2", "Second", 200),
text: "Creating diagram 2...",
},
]),
)
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
// First request
await sendMessage(page, "Draw first box")
await waitForText(page, "Creating diagram 1...")
// Second request
await sendMessage(page, "Draw second box")
await waitForText(page, "Creating diagram 2...")
// Both messages should be visible
await expect(page.locator('text="Draw first box"')).toBeVisible()
await expect(page.locator('text="Draw second box"')).toBeVisible()
})
test("preserves conversation history", async ({ page }) => {
let requestCount = 0
await page.route("**/api/chat", async (route) => {
requestCount++
const request = route.request()
const body = JSON.parse(request.postData() || "{}")
// Verify messages array grows with each request
if (requestCount === 2) {
expect(body.messages?.length).toBeGreaterThan(1)
}
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: createTextOnlyResponse(`Response ${requestCount}`),
})
})
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
// First message
await sendMessage(page, "Hello")
await waitForText(page, "Response 1")
// Second message (should include history)
await sendMessage(page, "Follow up question")
await waitForText(page, "Response 2")
})
test("can continue after a text-only response", async ({ page }) => {
await page.route(
"**/api/chat",
createMixedMock([
{
type: "text",
text: "I understand. Let me explain the architecture first.",
},
{
type: "diagram",
xml: ARCHITECTURE_XML,
text: "Here is the diagram:",
},
]),
)
await page.goto("/", { waitUntil: "networkidle" })
await page
.locator("iframe")
.waitFor({ state: "visible", timeout: 30000 })
// Ask for explanation first
await sendMessage(page, "Explain the architecture")
await waitForText(
page,
"I understand. Let me explain the architecture first.",
)
// Then ask for diagram
await sendMessage(page, "Now show it as a diagram")
await waitForComplete(page)
})
})

16
tests/e2e/save.spec.ts Normal file
View File

@@ -0,0 +1,16 @@
import { expect, getIframe, test } from "./lib/fixtures"
test.describe("Save Dialog", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("save/download buttons exist", async ({ page }) => {
const buttons = page
.locator("button")
.filter({ has: page.locator("svg") })
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,34 @@
import {
expect,
getIframe,
getSettingsButton,
openSettings,
test,
} from "./lib/fixtures"
test.describe("Settings", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("settings dialog opens", async ({ page }) => {
await openSettings(page)
// openSettings already verifies dialog is visible
})
test("language selection is available", async ({ page }) => {
await openSettings(page)
const dialog = page.locator('[role="dialog"]')
await expect(dialog.locator('text="English"')).toBeVisible()
})
test("draw.io theme toggle exists", async ({ page }) => {
await openSettings(page)
const dialog = page.locator('[role="dialog"]')
const themeText = dialog.locator("text=/sketch|minimal/i")
await expect(themeText.first()).toBeVisible()
})
})

36
tests/e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,36 @@
import { expect, getIframe, openSettings, test } from "./lib/fixtures"
test.describe("Smoke Tests", () => {
test("homepage loads without errors", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (err) => errors.push(err.message))
await page.goto("/", { waitUntil: "networkidle" })
await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 })
const iframe = getIframe(page)
await expect(iframe).toBeVisible({ timeout: 30000 })
expect(errors).toEqual([])
})
test("Japanese locale page loads", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (err) => errors.push(err.message))
await page.goto("/ja", { waitUntil: "networkidle" })
await expect(page).toHaveTitle(/Draw\.io/i, { timeout: 10000 })
const iframe = getIframe(page)
await expect(iframe).toBeVisible({ timeout: 30000 })
expect(errors).toEqual([])
})
test("settings dialog opens", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
})
})

88
tests/e2e/theme.spec.ts Normal file
View File

@@ -0,0 +1,88 @@
import { expect, getIframe, openSettings, sleep, test } from "./lib/fixtures"
test.describe("Theme Switching", () => {
test("can toggle app dark mode", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
const html = page.locator("html")
const initialClass = await html.getAttribute("class")
const themeButton = page.locator(
"button:has(svg.lucide-sun), button:has(svg.lucide-moon)",
)
if ((await themeButton.count()) > 0) {
await test.step("toggle theme", async () => {
await themeButton.first().click()
await sleep(500)
})
await test.step("verify theme changed", async () => {
const newClass = await html.getAttribute("class")
expect(newClass).not.toBe(initialClass)
})
}
})
test("theme persists after page reload", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
const themeButton = page.locator(
"button:has(svg.lucide-sun), button:has(svg.lucide-moon)",
)
if ((await themeButton.count()) > 0) {
let themeClass: string | null
await test.step("change theme", async () => {
await themeButton.first().click()
await sleep(300)
themeClass = await page.locator("html").getAttribute("class")
await page.keyboard.press("Escape")
})
await test.step("reload page", async () => {
await page.reload({ waitUntil: "networkidle" })
await getIframe(page).waitFor({
state: "visible",
timeout: 30000,
})
})
await test.step("verify theme persisted", async () => {
const reloadedClass = await page
.locator("html")
.getAttribute("class")
expect(reloadedClass).toBe(themeClass)
})
}
})
test("draw.io theme toggle exists", async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
await openSettings(page)
await expect(
page.locator('[role="dialog"], [role="menu"], form').first(),
).toBeVisible({ timeout: 5000 })
})
test("system theme preference is respected", async ({ page }) => {
await page.emulateMedia({ colorScheme: "dark" })
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
const html = page.locator("html")
const classes = await html.getAttribute("class")
expect(classes).toBeDefined()
})
})

20
tests/e2e/upload.spec.ts Normal file
View File

@@ -0,0 +1,20 @@
import { expect, getIframe, test } from "./lib/fixtures"
test.describe("File Upload Area", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/", { waitUntil: "networkidle" })
await getIframe(page).waitFor({ state: "visible", timeout: 30000 })
})
test("page loads without console errors", async ({ page }) => {
const errors: string[] = []
page.on("pageerror", (err) => errors.push(err.message))
await page.waitForTimeout(1000)
const criticalErrors = errors.filter(
(e) => !e.includes("ResizeObserver") && !e.includes("Script error"),
)
expect(criticalErrors).toEqual([])
})
})

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest"
import { supportsImageInput, supportsPromptCaching } from "@/lib/ai-providers"
describe("supportsPromptCaching", () => {
it("returns true for Claude models", () => {
expect(supportsPromptCaching("claude-sonnet-4-5")).toBe(true)
expect(supportsPromptCaching("anthropic.claude-3-5-sonnet")).toBe(true)
expect(supportsPromptCaching("us.anthropic.claude-3-5-sonnet")).toBe(
true,
)
expect(supportsPromptCaching("eu.anthropic.claude-3-5-sonnet")).toBe(
true,
)
})
it("returns false for non-Claude models", () => {
expect(supportsPromptCaching("gpt-4o")).toBe(false)
expect(supportsPromptCaching("gemini-pro")).toBe(false)
expect(supportsPromptCaching("deepseek-chat")).toBe(false)
})
})
describe("supportsImageInput", () => {
it("returns true for models with vision capability", () => {
expect(supportsImageInput("gpt-4-vision")).toBe(true)
expect(supportsImageInput("qwen-vl")).toBe(true)
expect(supportsImageInput("deepseek-vl")).toBe(true)
})
it("returns false for Kimi K2 models without vision", () => {
expect(supportsImageInput("kimi-k2")).toBe(false)
expect(supportsImageInput("moonshot/kimi-k2")).toBe(false)
})
it("returns false for DeepSeek text models", () => {
expect(supportsImageInput("deepseek-chat")).toBe(false)
expect(supportsImageInput("deepseek-coder")).toBe(false)
})
it("returns false for Qwen text models", () => {
expect(supportsImageInput("qwen-turbo")).toBe(false)
expect(supportsImageInput("qwen-plus")).toBe(false)
})
it("returns true for Claude and GPT models by default", () => {
expect(supportsImageInput("claude-sonnet-4-5")).toBe(true)
expect(supportsImageInput("gpt-4o")).toBe(true)
expect(supportsImageInput("gemini-pro")).toBe(true)
})
})

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest"
import {
CACHED_EXAMPLE_RESPONSES,
findCachedResponse,
} from "@/lib/cached-responses"
describe("findCachedResponse", () => {
it("returns cached response for exact match without image", () => {
const result = findCachedResponse(
"Give me a **animated connector** diagram of transformer's architecture",
false,
)
expect(result).toBeDefined()
expect(result?.xml).toContain("Transformer Architecture")
})
it("returns cached response for exact match with image", () => {
const result = findCachedResponse("Replicate this in aws style", true)
expect(result).toBeDefined()
expect(result?.xml).toContain("AWS")
})
it("returns undefined for non-matching prompt", () => {
const result = findCachedResponse(
"random prompt that doesn't exist",
false,
)
expect(result).toBeUndefined()
})
it("returns undefined when hasImage doesn't match", () => {
// This prompt exists but requires hasImage=true
const result = findCachedResponse("Replicate this in aws style", false)
expect(result).toBeUndefined()
})
it("returns undefined for partial match", () => {
const result = findCachedResponse("Give me a diagram", false)
expect(result).toBeUndefined()
})
it("returns response for Draw a cat prompt", () => {
const result = findCachedResponse("Draw a cat for me", false)
expect(result).toBeDefined()
expect(result?.xml).toContain("ellipse")
})
it("all cached responses have non-empty xml", () => {
for (const response of CACHED_EXAMPLE_RESPONSES) {
expect(response.xml).not.toBe("")
expect(response.xml.length).toBeGreaterThan(0)
}
})
})

View File

@@ -0,0 +1,171 @@
// @vitest-environment node
import { describe, expect, it } from "vitest"
import {
isMinimalDiagram,
replaceHistoricalToolInputs,
validateFileParts,
} from "@/lib/chat-helpers"
describe("validateFileParts", () => {
it("returns valid for no files", () => {
const messages = [
{ role: "user", parts: [{ type: "text", text: "hello" }] },
]
expect(validateFileParts(messages)).toEqual({ valid: true })
})
it("returns valid for files under limit", () => {
const smallBase64 = btoa("x".repeat(100))
const messages = [
{
role: "user",
parts: [
{
type: "file",
url: `data:image/png;base64,${smallBase64}`,
},
],
},
]
expect(validateFileParts(messages)).toEqual({ valid: true })
})
it("returns error for too many files", () => {
const messages = [
{
role: "user",
parts: Array(6)
.fill(null)
.map(() => ({
type: "file",
url: "data:image/png;base64,abc",
})),
},
]
const result = validateFileParts(messages)
expect(result.valid).toBe(false)
expect(result.error).toContain("Too many files")
})
it("returns error for file exceeding size limit", () => {
// Create base64 that decodes to > 2MB
const largeBase64 = btoa("x".repeat(3 * 1024 * 1024))
const messages = [
{
role: "user",
parts: [
{
type: "file",
url: `data:image/png;base64,${largeBase64}`,
},
],
},
]
const result = validateFileParts(messages)
expect(result.valid).toBe(false)
expect(result.error).toContain("exceeds")
})
})
describe("isMinimalDiagram", () => {
it("returns true for empty diagram", () => {
const xml = '<mxCell id="0"/><mxCell id="1" parent="0"/>'
expect(isMinimalDiagram(xml)).toBe(true)
})
it("returns false for diagram with content", () => {
const xml =
'<mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="Hello"/>'
expect(isMinimalDiagram(xml)).toBe(false)
})
it("handles whitespace correctly", () => {
const xml = ' <mxCell id="0"/> <mxCell id="1" parent="0"/> '
expect(isMinimalDiagram(xml)).toBe(true)
})
})
describe("replaceHistoricalToolInputs", () => {
it("replaces display_diagram tool inputs with placeholder", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "display_diagram",
input: { xml: "<mxCell...>" },
},
],
},
]
const result = replaceHistoricalToolInputs(messages)
expect(result[0].content[0].input.placeholder).toContain(
"XML content replaced",
)
})
it("replaces edit_diagram tool inputs with placeholder", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "edit_diagram",
input: { operations: [] },
},
],
},
]
const result = replaceHistoricalToolInputs(messages)
expect(result[0].content[0].input.placeholder).toContain(
"XML content replaced",
)
})
it("removes tool calls with invalid inputs", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "display_diagram",
input: {},
},
{
type: "tool-call",
toolName: "display_diagram",
input: null,
},
],
},
]
const result = replaceHistoricalToolInputs(messages)
expect(result[0].content).toHaveLength(0)
})
it("preserves non-assistant messages", () => {
const messages = [{ role: "user", content: "hello" }]
const result = replaceHistoricalToolInputs(messages)
expect(result).toEqual(messages)
})
it("preserves other tool calls", () => {
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolName: "other_tool",
input: { foo: "bar" },
},
],
},
]
const result = replaceHistoricalToolInputs(messages)
expect(result[0].content[0].input).toEqual({ foo: "bar" })
})
})

86
tests/unit/utils.test.ts Normal file
View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest"
import { cn, isMxCellXmlComplete, wrapWithMxFile } from "@/lib/utils"
describe("isMxCellXmlComplete", () => {
it("returns false for empty/null input", () => {
expect(isMxCellXmlComplete("")).toBe(false)
expect(isMxCellXmlComplete(null)).toBe(false)
expect(isMxCellXmlComplete(undefined)).toBe(false)
})
it("returns true for self-closing mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent="1"/>'
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns true for mxCell with closing tag", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns false for truncated mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent'
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false for mxCell with unclosed geometry", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120"`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns true for multiple complete mxCells", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1"/>
<mxCell id="3" value="B" vertex="1" parent="1"/>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
})
describe("wrapWithMxFile", () => {
it("wraps empty string with default structure", () => {
const result = wrapWithMxFile("")
expect(result).toContain("<mxfile>")
expect(result).toContain("<mxGraphModel>")
expect(result).toContain('<mxCell id="0"/>')
expect(result).toContain('<mxCell id="1" parent="0"/>')
})
it("wraps raw mxCell content", () => {
const xml = '<mxCell id="2" value="Hello"/>'
const result = wrapWithMxFile(xml)
expect(result).toContain("<mxfile>")
expect(result).toContain(xml)
expect(result).toContain("</mxfile>")
})
it("returns full mxfile unchanged", () => {
const fullXml =
'<mxfile><diagram name="Page-1"><mxGraphModel></mxGraphModel></diagram></mxfile>'
const result = wrapWithMxFile(fullXml)
expect(result).toBe(fullXml)
})
it("handles whitespace in input", () => {
const result = wrapWithMxFile(" ")
expect(result).toContain("<mxfile>")
})
})
describe("cn (class name utility)", () => {
it("merges class names", () => {
expect(cn("foo", "bar")).toBe("foo bar")
})
it("handles conditional classes", () => {
expect(cn("foo", false && "bar", "baz")).toBe("foo baz")
})
it("merges tailwind classes correctly", () => {
expect(cn("px-2", "px-4")).toBe("px-4")
expect(cn("text-red-500", "text-blue-500")).toBe("text-blue-500")
})
})

17
vitest.config.mts Normal file
View File

@@ -0,0 +1,17 @@
import react from "@vitejs/plugin-react"
import tsconfigPaths from "vite-tsconfig-paths"
import { defineConfig } from "vitest/config"
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: "jsdom",
include: ["tests/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
include: ["lib/**/*.ts", "app/**/*.ts", "app/**/*.tsx"],
exclude: ["**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"],
},
},
})