mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-08 01:02:29 +08:00
Compare commits
1 Commits
test/add-t
...
fix/save-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fd7d4364f |
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
@@ -20,42 +20,15 @@ npm run lint # Check lint errors
|
|||||||
npm run check # Run all checks (CI)
|
npm run check # Run all checks (CI)
|
||||||
```
|
```
|
||||||
|
|
||||||
Git hooks via Husky run automatically:
|
Pre-commit hooks via Husky will run Biome automatically on staged files.
|
||||||
- **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.
|
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
|
## Pull Requests
|
||||||
|
|
||||||
1. Create a feature branch
|
1. Create a feature branch
|
||||||
2. Make changes (pre-commit runs lint + type check automatically)
|
2. Make changes and ensure `npm run check` passes
|
||||||
3. Run E2E tests with `npm run test:e2e`
|
3. Submit PR against `main` with a clear description
|
||||||
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
|
## Issues
|
||||||
|
|
||||||
|
|||||||
75
.github/workflows/test.yml
vendored
75
.github/workflows/test.yml
vendored
@@ -1,75 +0,0 @@
|
|||||||
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
2
.gitignore
vendored
@@ -14,8 +14,6 @@ packages/*/dist
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
/playwright-report/
|
|
||||||
/test-results/
|
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
npx lint-staged
|
npx lint-staged
|
||||||
npx tsc --noEmit
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# Skip if node_modules not installed (e.g., on EC2 push server)
|
|
||||||
if [ -d "node_modules" ]; then
|
|
||||||
npm run test -- --run
|
|
||||||
fi
|
|
||||||
@@ -17,8 +17,14 @@ const drawioBaseUrl =
|
|||||||
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
process.env.NEXT_PUBLIC_DRAWIO_BASE_URL || "https://embed.diagrams.net"
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { drawioRef, handleDiagramExport, onDrawioLoad, resetDrawioReady } =
|
const {
|
||||||
useDiagram()
|
drawioRef,
|
||||||
|
handleDiagramExport,
|
||||||
|
onDrawioLoad,
|
||||||
|
resetDrawioReady,
|
||||||
|
showSaveDialog,
|
||||||
|
setShowSaveDialog,
|
||||||
|
} = useDiagram()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
// Extract current language from pathname (e.g., "/zh/about" → "zh")
|
||||||
@@ -32,8 +38,30 @@ export default function Home() {
|
|||||||
const [closeProtection, setCloseProtection] = useState(false)
|
const [closeProtection, setCloseProtection] = useState(false)
|
||||||
|
|
||||||
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
const chatPanelRef = useRef<ImperativePanelHandle>(null)
|
||||||
|
const isSavingRef = useRef(false)
|
||||||
|
const mouseOverDrawioRef = useRef(false)
|
||||||
const isMobileRef = useRef(false)
|
const isMobileRef = useRef(false)
|
||||||
|
|
||||||
|
// Reset saving flag when dialog closes (with delay to ignore lingering save events from draw.io)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSaveDialog) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
isSavingRef.current = false
|
||||||
|
}, 1000)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [showSaveDialog])
|
||||||
|
|
||||||
|
// Handle save from draw.io's built-in save button
|
||||||
|
// Note: draw.io sends save events for various reasons (focus changes, etc.)
|
||||||
|
// We use mouse position to determine if the user is interacting with draw.io
|
||||||
|
const handleDrawioSave = useCallback(() => {
|
||||||
|
if (!mouseOverDrawioRef.current) return
|
||||||
|
if (isSavingRef.current) return
|
||||||
|
isSavingRef.current = true
|
||||||
|
setShowSaveDialog(true)
|
||||||
|
}, [setShowSaveDialog])
|
||||||
|
|
||||||
// Load preferences from localStorage after mount
|
// Load preferences from localStorage after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Restore saved locale and redirect if needed
|
// Restore saved locale and redirect if needed
|
||||||
@@ -176,6 +204,12 @@ export default function Home() {
|
|||||||
className={`h-full relative ${
|
className={`h-full relative ${
|
||||||
isMobile ? "p-1" : "p-2"
|
isMobile ? "p-1" : "p-2"
|
||||||
}`}
|
}`}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
mouseOverDrawioRef.current = true
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
mouseOverDrawioRef.current = false
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
<div className="h-full rounded-xl overflow-hidden shadow-soft-lg border border-border/30 relative">
|
||||||
{isLoaded && (
|
{isLoaded && (
|
||||||
@@ -187,13 +221,13 @@ export default function Home() {
|
|||||||
ref={drawioRef}
|
ref={drawioRef}
|
||||||
onExport={handleDiagramExport}
|
onExport={handleDiagramExport}
|
||||||
onLoad={handleDrawioLoad}
|
onLoad={handleDrawioLoad}
|
||||||
|
onSave={handleDrawioSave}
|
||||||
baseUrl={drawioBaseUrl}
|
baseUrl={drawioBaseUrl}
|
||||||
urlParameters={{
|
urlParameters={{
|
||||||
ui: drawioUi,
|
ui: drawioUi,
|
||||||
spin: false,
|
spin: false,
|
||||||
libraries: false,
|
libraries: false,
|
||||||
saveAndExit: false,
|
saveAndExit: false,
|
||||||
noSaveBtn: true,
|
|
||||||
noExitBtn: true,
|
noExitBtn: true,
|
||||||
dark: darkMode,
|
dark: darkMode,
|
||||||
lang: currentLang,
|
lang: currentLang,
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ import {
|
|||||||
supportsPromptCaching,
|
supportsPromptCaching,
|
||||||
} from "@/lib/ai-providers"
|
} from "@/lib/ai-providers"
|
||||||
import { findCachedResponse } from "@/lib/cached-responses"
|
import { findCachedResponse } from "@/lib/cached-responses"
|
||||||
import {
|
|
||||||
isMinimalDiagram,
|
|
||||||
replaceHistoricalToolInputs,
|
|
||||||
validateFileParts,
|
|
||||||
} from "@/lib/chat-helpers"
|
|
||||||
import {
|
import {
|
||||||
checkAndIncrementRequest,
|
checkAndIncrementRequest,
|
||||||
isQuotaEnabled,
|
isQuotaEnabled,
|
||||||
@@ -39,6 +34,93 @@ import { getUserIdFromRequest } from "@/lib/user-id"
|
|||||||
|
|
||||||
export const maxDuration = 120
|
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
|
// Helper function to create cached stream response
|
||||||
function createCachedStreamResponse(xml: string): Response {
|
function createCachedStreamResponse(xml: string): Response {
|
||||||
const toolCallId = `cached-${Date.now()}`
|
const toolCallId = `cached-${Date.now()}`
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ export const ModelSelectorLogo = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
|
||||||
<img
|
<img
|
||||||
{...props}
|
{...props}
|
||||||
alt={`${provider} logo`}
|
alt={`${provider} logo`}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
replaceNodes,
|
replaceNodes,
|
||||||
validateAndFixXml,
|
validateAndFixXml,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
|
import ExamplePanel from "./chat-example-panel"
|
||||||
|
|
||||||
// Helper to extract complete operations from streaming input
|
// Helper to extract complete operations from streaming input
|
||||||
function getCompleteOperations(
|
function getCompleteOperations(
|
||||||
|
|||||||
@@ -1185,7 +1185,6 @@ export default function ChatPanel({
|
|||||||
status === "streaming" || status === "submitted"
|
status === "streaming" || status === "submitted"
|
||||||
}
|
}
|
||||||
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
className="hover:bg-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
data-testid="new-chat-button"
|
|
||||||
>
|
>
|
||||||
<MessageSquarePlus
|
<MessageSquarePlus
|
||||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
||||||
@@ -1198,7 +1197,6 @@ export default function ChatPanel({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowSettingsDialog(true)}
|
onClick={() => setShowSettingsDialog(true)}
|
||||||
className="hover:bg-accent"
|
className="hover:bg-accent"
|
||||||
data-testid="settings-button"
|
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
className={`${isMobile ? "h-4 w-4" : "h-5 w-5"} text-muted-foreground`}
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ function ProviderLogo({
|
|||||||
|
|
||||||
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
const logoName = PROVIDER_LOGO_MAP[provider] || provider
|
||||||
return (
|
return (
|
||||||
// biome-ignore lint/performance/noImgElement: External URL from models.dev
|
|
||||||
<img
|
<img
|
||||||
alt={`${provider} logo`}
|
alt={`${provider} logo`}
|
||||||
className={cn("size-4 dark:invert", className)}
|
className={cn("size-4 dark:invert", className)}
|
||||||
@@ -274,7 +273,7 @@ export function ModelConfigDialog({
|
|||||||
|
|
||||||
// Validate all models
|
// Validate all models
|
||||||
const handleValidate = useCallback(async () => {
|
const handleValidate = useCallback(async () => {
|
||||||
if (!selectedProvider || !selectedProviderId) return
|
if (!selectedProvider) return
|
||||||
|
|
||||||
// Check credentials based on provider type
|
// Check credentials based on provider type
|
||||||
const isBedrock = selectedProvider.provider === "bedrock"
|
const isBedrock = selectedProvider.provider === "bedrock"
|
||||||
@@ -332,14 +331,14 @@ export function ModelConfigDialog({
|
|||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
if (data.valid) {
|
if (data.valid) {
|
||||||
updateModel(selectedProviderId, model.id, {
|
updateModel(selectedProviderId!, model.id, {
|
||||||
validated: true,
|
validated: true,
|
||||||
validationError: undefined,
|
validationError: undefined,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
allValid = false
|
allValid = false
|
||||||
errorCount++
|
errorCount++
|
||||||
updateModel(selectedProviderId, model.id, {
|
updateModel(selectedProviderId!, model.id, {
|
||||||
validated: false,
|
validated: false,
|
||||||
validationError: data.error || "Validation failed",
|
validationError: data.error || "Validation failed",
|
||||||
})
|
})
|
||||||
@@ -347,7 +346,7 @@ export function ModelConfigDialog({
|
|||||||
} catch {
|
} catch {
|
||||||
allValid = false
|
allValid = false
|
||||||
errorCount++
|
errorCount++
|
||||||
updateModel(selectedProviderId, model.id, {
|
updateModel(selectedProviderId!, model.id, {
|
||||||
validated: false,
|
validated: false,
|
||||||
validationError: "Network error",
|
validationError: "Network error",
|
||||||
})
|
})
|
||||||
@@ -358,7 +357,7 @@ export function ModelConfigDialog({
|
|||||||
|
|
||||||
if (allValid) {
|
if (allValid) {
|
||||||
setValidationStatus("success")
|
setValidationStatus("success")
|
||||||
updateProvider(selectedProviderId, { validated: true })
|
updateProvider(selectedProviderId!, { validated: true })
|
||||||
// Reset to idle after showing success briefly (with cleanup)
|
// Reset to idle after showing success briefly (with cleanup)
|
||||||
if (validationResetTimeoutRef.current) {
|
if (validationResetTimeoutRef.current) {
|
||||||
clearTimeout(validationResetTimeoutRef.current)
|
clearTimeout(validationResetTimeoutRef.current)
|
||||||
@@ -1299,24 +1298,20 @@ export function ModelConfigDialog({
|
|||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (
|
updateModel(
|
||||||
selectedProviderId
|
selectedProviderId!,
|
||||||
) {
|
model.id,
|
||||||
updateModel(
|
{
|
||||||
selectedProviderId,
|
modelId:
|
||||||
model.id,
|
e
|
||||||
{
|
.target
|
||||||
modelId:
|
.value,
|
||||||
e
|
validated:
|
||||||
.target
|
undefined,
|
||||||
.value,
|
validationError:
|
||||||
validated:
|
undefined,
|
||||||
undefined,
|
},
|
||||||
validationError:
|
)
|
||||||
undefined,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(
|
onKeyDown={(
|
||||||
e,
|
e,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ function handleOptionsRequest(): Response {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function onRequest({ request, env: _env }: any) {
|
export async function onRequest({ request, env }: any) {
|
||||||
if (request.method === "OPTIONS") {
|
if (request.method === "OPTIONS") {
|
||||||
return handleOptionsRequest()
|
return handleOptionsRequest()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -573,8 +573,8 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
|||||||
const bedrockProvider = hasClientCredentials
|
const bedrockProvider = hasClientCredentials
|
||||||
? createAmazonBedrock({
|
? createAmazonBedrock({
|
||||||
region: bedrockRegion,
|
region: bedrockRegion,
|
||||||
accessKeyId: overrides.awsAccessKeyId as string,
|
accessKeyId: overrides.awsAccessKeyId!,
|
||||||
secretAccessKey: overrides.awsSecretAccessKey as string,
|
secretAccessKey: overrides.awsSecretAccessKey!,
|
||||||
...(overrides?.awsSessionToken && {
|
...(overrides?.awsSessionToken && {
|
||||||
sessionToken: overrides.awsSessionToken,
|
sessionToken: overrides.awsSessionToken,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
// 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
1400
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-ai-draw-io",
|
"name": "next-ai-draw-io",
|
||||||
"version": "0.4.9",
|
"version": "0.4.8",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main/index.js",
|
"main": "dist-electron/main/index.js",
|
||||||
@@ -25,9 +25,7 @@
|
|||||||
"dist:mac": "npm run electron:build && npm run electron:prepare && npx electron-builder --config electron/electron-builder.yml --mac",
|
"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: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: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": {
|
"dependencies": {
|
||||||
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
"@ai-sdk/amazon-bedrock": "^4.0.1",
|
||||||
@@ -68,6 +66,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"js-tiktoken": "^1.0.21",
|
"js-tiktoken": "^1.0.21",
|
||||||
|
"jsdom": "^27.0.0",
|
||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
@@ -104,19 +103,13 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/tokenizer": "^0.0.4",
|
"@anthropic-ai/tokenizer": "^0.0.4",
|
||||||
"@biomejs/biome": "^2.3.10",
|
"@biomejs/biome": "^2.3.10",
|
||||||
"@playwright/test": "^1.57.0",
|
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@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/negotiator": "^0.6.4",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/pako": "^2.0.3",
|
"@types/pako": "^2.0.3",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
@@ -125,13 +118,10 @@
|
|||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-next": "16.1.1",
|
"eslint-config-next": "16.1.1",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vite-tsconfig-paths": "^6.0.3",
|
|
||||||
"vitest": "^4.0.16",
|
|
||||||
"wait-on": "^9.0.3",
|
"wait-on": "^9.0.3",
|
||||||
"wrangler": "4.54.0"
|
"wrangler": "4.54.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
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 })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>`
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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))
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// @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" })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user