test: add real filesystem vault integration tests

Replace mock vault approach with real filesystem I/O for integration
tests. Each test copies tests/fixtures/test-vault/ to a temp directory,
overrides mock handlers to point at it, and verifies actual file
operations through the vite dev server middleware.

Tests cover: vault loading, archive/trash filtering, note creation,
rename with filesystem update, wikilink cascade on rename, relationship
display, and real file content loading.

Also extends vite.config.ts vault API middleware with write endpoints
(save, rename, delete, search, entry) and fixes getBool to handle
YAML 1.2 string values like "Yes"/"yes" (js-yaml 4.x compatibility).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-11 20:46:45 +01:00
parent 891e298f3a
commit 5107cd13f4
14 changed files with 517 additions and 16 deletions

View File

@@ -14,6 +14,7 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",
"prepare": "husky"
},

View File

@@ -0,0 +1,18 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/integration',
timeout: 30_000,
retries: 1,
workers: 1,
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5365',
headless: true,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
webServer: {
command: `pnpm dev --port ${process.env.BASE_URL?.match(/:(\d+)/)?.[1] || '5365'}`,
url: process.env.BASE_URL || 'http://localhost:5365',
reuseExistingServer: true,
},
})

View File

@@ -1,7 +1,7 @@
/**
* Vault API detection and proxy for browser dev mode.
* When a local vault API server is running, routes read commands through it
* instead of returning hardcoded mock data.
* When a local vault API server is running, routes read and write commands
* through it instead of returning hardcoded mock data.
*/
let vaultApiAvailable: boolean | null = null
@@ -18,25 +18,60 @@ async function checkVaultApi(): Promise<boolean> {
return vaultApiAvailable
}
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => string | null> = {
list_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}` : null,
reload_vault: (args) => args.path ? `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` : null,
get_note_content: (args) => args.path ? `/api/vault/content?path=${encodeURIComponent(args.path as string)}` : null,
get_all_content: (args) => args.path ? `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` : null,
interface VaultApiRequest {
url: string
method?: string
body?: unknown
}
/** Tracks last vault path for commands that don't receive it as an argument. */
let lastVaultPath: string | null = null
const VAULT_API_COMMANDS: Record<string, (args: Record<string, unknown>) => VaultApiRequest | null> = {
list_vault: (args) => {
if (args.path) lastVaultPath = args.path as string
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}` } : null
},
reload_vault: (args) => {
if (args.path) lastVaultPath = args.path as string
return args.path ? { url: `/api/vault/list?path=${encodeURIComponent(args.path as string)}&reload=1` } : null
},
reload_vault_entry: (args) =>
args.path ? { url: `/api/vault/entry?path=${encodeURIComponent(args.path as string)}` } : null,
get_note_content: (args) =>
args.path ? { url: `/api/vault/content?path=${encodeURIComponent(args.path as string)}` } : null,
get_all_content: (args) =>
args.path ? { url: `/api/vault/all-content?path=${encodeURIComponent(args.path as string)}` } : null,
save_note_content: (args) =>
args.path ? { url: '/api/vault/save', method: 'POST', body: { path: args.path, content: args.content } } : null,
rename_note: (args) =>
args.old_path ? { url: '/api/vault/rename', method: 'POST', body: { vault_path: args.vault_path, old_path: args.old_path, new_title: args.new_title } } : null,
delete_note: (args) =>
args.path ? { url: '/api/vault/delete', method: 'POST', body: { path: args.path } } : null,
search_vault: (args) => {
const q = args.query as string
if (!q || !lastVaultPath) return null
return { url: `/api/vault/search?vault_path=${encodeURIComponent(lastVaultPath)}&query=${encodeURIComponent(q)}&mode=${encodeURIComponent((args.mode as string) || 'all')}` }
},
}
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const available = await checkVaultApi()
if (!available) return undefined
const urlBuilder = VAULT_API_COMMANDS[cmd]
if (!urlBuilder || !args) return undefined
const requestBuilder = VAULT_API_COMMANDS[cmd]
if (!requestBuilder || !args) return undefined
const url = urlBuilder(args)
if (!url) return undefined
const request = requestBuilder(args)
if (!request) return undefined
try {
const res = await fetch(url)
const fetchOpts: RequestInit = { method: request.method || 'GET' }
if (request.body) {
fetchOpts.headers = { 'Content-Type': 'application/json' }
fetchOpts.body = JSON.stringify(request.body)
}
const res = await fetch(request.url, fetchOpts)
if (!res.ok) return undefined
const data = await res.json()
return (cmd === 'get_note_content' ? data.content : data) as T

View File

@@ -0,0 +1,12 @@
---
Is A: Event
Status: Active
Belongs to:
- "[[Alpha Project]]"
Attendees:
- "[[Test User]]"
---
# Team Meeting
Weekly team meeting for the Alpha Project.

View File

@@ -0,0 +1,8 @@
---
Is A: Note
Archived: Yes
---
# Archived Note
This note is archived and should not appear in the main sidebar.

View File

@@ -0,0 +1,10 @@
---
Is A: Note
Status: Active
---
# Note B
This is Note B, referenced by Alpha Project.
It also links to [[Note C]].

View File

@@ -0,0 +1,12 @@
---
Is A: Note
Status: Active
Related to:
- "[[Alpha Project]]"
Topics:
- "[[design]]"
---
# Note C
This is Note C with relationships to Alpha Project.

View File

@@ -0,0 +1,9 @@
---
Is A: Note
Trashed: true
Trashed at: 2026-03-01
---
# Trashed Note
This note is trashed and should not appear in search results or the main sidebar.

View File

@@ -0,0 +1,16 @@
---
Is A: Project
Status: Active
Owner: "Test User"
Related to:
- "[[Note B]]"
- "[[Note C]]"
---
# Alpha Project
This is a test project that references other notes.
## Notes
See [[Note B]] for details and [[Note C]] for additional context.

View File

@@ -0,0 +1,9 @@
---
Is A: Type
icon: calendar
color: orange
order: 2
sidebarLabel: Events
---
# Event

View File

@@ -0,0 +1,9 @@
---
Is A: Type
icon: file-text
color: green
order: 1
sidebarLabel: Notes
---
# Note

View File

@@ -0,0 +1,9 @@
---
Is A: Type
icon: folder
color: blue
order: 0
sidebarLabel: Projects
---
# Project

View File

@@ -0,0 +1,230 @@
/**
* Integration tests against a real filesystem vault.
*
* Each test copies tests/fixtures/test-vault/ to a temp directory,
* points the app at it, and verifies real file I/O: creation, rename,
* wikilink updates, archive/trash filtering, and relationship display.
*/
import { test, expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import os from 'os'
const FIXTURE_VAULT = path.resolve('tests/fixtures/test-vault')
function copyDirSync(src: string, dest: string): void {
fs.mkdirSync(dest, { recursive: true })
for (const item of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, item.name)
const d = path.join(dest, item.name)
if (item.isDirectory()) copyDirSync(s, d)
else fs.copyFileSync(s, d)
}
}
let tempVaultDir: string
test.beforeEach(async ({ page }) => {
// Fresh vault copy for each test
tempVaultDir = fs.mkdtempSync(path.join(os.tmpdir(), 'laputa-test-vault-'))
copyDirSync(FIXTURE_VAULT, tempVaultDir)
// Intercept window.__mockHandlers assignment to override vault path handlers.
// The Object.defineProperty setter fires when mock-tauri/index.ts sets
// window.__mockHandlers = mockHandlers, letting us patch the same object
// reference that mockInvoke reads from.
await page.addInitScript((vaultPath: string) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let ref: any = null
Object.defineProperty(window, '__mockHandlers', {
set(val) {
ref = val
ref.load_vault_list = () => ({
vaults: [{ label: 'Test Vault', path: vaultPath }],
active_vault: vaultPath,
})
ref.check_vault_exists = () => true
ref.get_last_vault_path = () => vaultPath
ref.get_default_vault_path = () => vaultPath
ref.save_vault_list = () => null
},
get() { return ref },
configurable: true,
})
}, tempVaultDir)
await page.goto('/')
// Wait until the real vault entries are loaded in the sidebar
await page.getByText('Alpha Project', { exact: true }).first().waitFor({ timeout: 10_000 })
})
test.afterEach(async () => {
fs.rmSync(tempVaultDir, { recursive: true, force: true })
})
/** Helper: locate the note-list container. */
function noteList(page: import('@playwright/test').Page) {
return page.locator('[data-testid="note-list-container"]')
}
/** Helper: click a note item by title text in the sidebar. */
async function openNote(page: import('@playwright/test').Page, title: string) {
await noteList(page).getByText(title, { exact: true }).click()
await page.waitForTimeout(300)
}
// ---------------------------------------------------------------------------
// 1. Vault loads entries from real fixture files
// ---------------------------------------------------------------------------
test('vault loads entries from fixture files', async ({ page }) => {
const list = noteList(page)
await expect(list.getByText('Alpha Project', { exact: true })).toBeVisible()
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
await expect(list.getByText('Note C', { exact: true })).toBeVisible()
await expect(list.getByText('Team Meeting', { exact: true })).toBeVisible()
// Open a note and verify editor shows its content from disk
await openNote(page, 'Alpha Project')
// The WYSIWYG editor renders the title as an H1 heading
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
})
// ---------------------------------------------------------------------------
// 2. Archived note hidden from main sidebar
// ---------------------------------------------------------------------------
test('archived note does not appear in All Notes', async ({ page }) => {
const list = noteList(page)
// "Archived Note" has Archived: Yes — should be hidden from default view
await expect(list.getByText('Archived Note', { exact: true })).not.toBeVisible()
// But regular notes are visible
await expect(list.getByText('Note B', { exact: true })).toBeVisible()
})
// ---------------------------------------------------------------------------
// 3. Trashed note hidden from note list
// ---------------------------------------------------------------------------
test('trashed note does not appear in All Notes', async ({ page }) => {
const list = noteList(page)
// "Trashed Note" has Trashed: true — should be hidden
await expect(list.getByText('Trashed Note', { exact: true })).not.toBeVisible()
// Regular notes are visible
await expect(list.getByText('Note C', { exact: true })).toBeVisible()
})
// ---------------------------------------------------------------------------
// 4. Create note saves file to disk with correct slug
// ---------------------------------------------------------------------------
test('create note saves file to disk with correct slug', async ({ page }) => {
// "Create new note" instantly creates "Untitled note" and opens in editor
await page.locator('button[title="Create new note"]').click()
await page.waitForTimeout(500)
// Verify the new note opens — H1 heading should appear in the WYSIWYG editor
await expect(page.getByRole('heading', { name: /Untitled note/i, level: 1 })).toBeVisible({ timeout: 5_000 })
// Save to disk via Cmd+S
await page.keyboard.press('Meta+s')
// Poll for the file to appear on disk
const expectedPath = path.join(tempVaultDir, 'note', 'untitled-note.md')
await expect(async () => {
expect(fs.existsSync(expectedPath)).toBe(true)
}).toPass({ timeout: 5_000 })
const content = fs.readFileSync(expectedPath, 'utf-8')
expect(content).toContain('# Untitled note')
expect(content).toContain('type: Note')
})
// ---------------------------------------------------------------------------
// 5. Rename note updates filename on disk
// ---------------------------------------------------------------------------
test('rename note updates filename on disk', async ({ page }) => {
// Open Note B
await openNote(page, 'Note B')
// Double-click the tab title — scope to the draggable tab element to avoid
// matching the breadcrumb span that also has class "truncate".
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
await page.waitForTimeout(200)
// In rename mode, draggable becomes false and an <input> appears in the tab.
// It's the only <input> element on the page at this point.
const input = page.locator('input').first()
await expect(input).toBeVisible({ timeout: 2_000 })
await input.fill('Note B Renamed')
await input.press('Enter')
await page.waitForTimeout(500)
// Verify filesystem: old file gone, new file exists
const oldPath = path.join(tempVaultDir, 'note', 'note-b.md')
const newPath = path.join(tempVaultDir, 'note', 'note-b-renamed.md')
await expect(async () => {
expect(fs.existsSync(oldPath)).toBe(false)
expect(fs.existsSync(newPath)).toBe(true)
}).toPass({ timeout: 5_000 })
const newContent = fs.readFileSync(newPath, 'utf-8')
expect(newContent).toContain('# Note B Renamed')
})
// ---------------------------------------------------------------------------
// 6. Wikilink update on rename — other files' [[Note B]] updated
// ---------------------------------------------------------------------------
test('rename note updates wikilinks in other files', async ({ page }) => {
// Open Note B and rename it
await openNote(page, 'Note B')
await page.locator('[draggable="true"] span.truncate', { hasText: 'Note B' }).dblclick()
await page.waitForTimeout(200)
const input = page.locator('input').first()
await expect(input).toBeVisible({ timeout: 2_000 })
await input.fill('Note B Updated')
await input.press('Enter')
// Wait for rename to complete (file to be moved)
const newPath = path.join(tempVaultDir, 'note', 'note-b-updated.md')
await expect(async () => {
expect(fs.existsSync(newPath)).toBe(true)
}).toPass({ timeout: 5_000 })
// Verify alpha-project.md now references [[Note B Updated]] instead of [[Note B]]
const alphaContent = fs.readFileSync(path.join(tempVaultDir, 'project', 'alpha-project.md'), 'utf-8')
expect(alphaContent).toContain('[[Note B Updated]]')
expect(alphaContent).not.toContain('[[Note B]]')
})
// ---------------------------------------------------------------------------
// 7. Relationship display in inspector
// ---------------------------------------------------------------------------
test('inspector shows relationships for note with wikilink fields', async ({ page }) => {
// Open Note C which has Related to: [[Alpha Project]] and Topics: [[design]]
await openNote(page, 'Note C')
// The DynamicRelationshipsPanel renders field labels like "Related to", "Topics"
// as <span> elements. Verify that the relationship labels are visible in the inspector.
await expect(page.getByText('Related to').first()).toBeVisible({ timeout: 5_000 })
})
// ---------------------------------------------------------------------------
// 8. Opening a note loads real file content from disk
// ---------------------------------------------------------------------------
test('editor shows real file content from disk', async ({ page }) => {
// Open Team Meeting
await openNote(page, 'Team Meeting')
// Verify editor shows the actual file content — H1 heading from the file
await expect(page.getByRole('heading', { name: 'Team Meeting', level: 1 })).toBeVisible({ timeout: 5_000 })
})

View File

@@ -129,12 +129,19 @@ function parseMarkdownFile(filePath: string): VaultEntry | null {
}
}
// Boolean field helper
// Boolean field helper — handles both real booleans and YAML 1.1 string
// variants ("Yes"/"yes"/"True"/"true") that js-yaml 4.x (YAML 1.2) leaves as strings.
const getBool = (...keys: string[]): boolean | null => {
for (const k of keys) {
for (const fk of Object.keys(fm)) {
if (fk.toLowerCase() === k.toLowerCase() && typeof fm[fk] === 'boolean') {
return fm[fk]
if (fk.toLowerCase() === k.toLowerCase()) {
const v = fm[fk]
if (typeof v === 'boolean') return v
if (typeof v === 'string') {
const lc = v.toLowerCase()
if (lc === 'true' || lc === 'yes') return true
if (lc === 'false' || lc === 'no') return false
}
}
}
}
@@ -201,7 +208,7 @@ function vaultApiPlugin(): Plugin {
return {
name: 'vault-api',
configureServer(server) {
server.middlewares.use((req, res, next) => {
server.middlewares.use(async (req, res, next) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (url.pathname === '/api/vault/ping') {
@@ -258,6 +265,122 @@ function vaultApiPlugin(): Plugin {
return
}
if (url.pathname === '/api/vault/entry') {
const filePath = url.searchParams.get('path')
if (!filePath || !fs.existsSync(filePath)) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Invalid or missing path' }))
return
}
const entry = parseMarkdownFile(filePath)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(entry))
return
}
if (url.pathname === '/api/vault/search') {
const vaultPath = url.searchParams.get('vault_path')
const query = (url.searchParams.get('query') ?? '').toLowerCase()
const mode = url.searchParams.get('mode') ?? 'all'
if (!vaultPath || !query) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ results: [], elapsed_ms: 0, query, mode }))
return
}
const files = findMarkdownFiles(vaultPath)
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
for (const f of files) {
const entry = parseMarkdownFile(f)
if (!entry || entry.trashed) continue
const raw = fs.readFileSync(f, 'utf-8')
if (entry.title.toLowerCase().includes(query) || raw.toLowerCase().includes(query)) {
results.push({ title: entry.title, path: entry.path, snippet: entry.snippet, score: 1.0, note_type: entry.isA })
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ results: results.slice(0, 20), elapsed_ms: 1, query, mode }))
return
}
// --- POST endpoints for write operations ---
if (url.pathname === '/api/vault/save' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { path: filePath, content } = JSON.parse(body)
if (!filePath || content === undefined) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path or content' }))
return
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, content, 'utf-8')
res.setHeader('Content-Type', 'application/json')
res.end('null')
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Save failed' }))
}
return
}
if (url.pathname === '/api/vault/rename' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { vault_path: vaultPath, old_path: oldPath, new_title: newTitle } = JSON.parse(body)
const oldContent = fs.readFileSync(oldPath, 'utf-8')
const h1Match = oldContent.match(/^# (.+)$/m)
const oldTitle = h1Match ? h1Match[1].trim() : ''
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const parentDir = path.dirname(oldPath)
const newPath = path.join(parentDir, `${slug}.md`)
const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`)
fs.writeFileSync(newPath, newContent, 'utf-8')
if (newPath !== oldPath) fs.unlinkSync(oldPath)
let updatedFiles = 0
if (oldTitle && vaultPath) {
const allFiles = findMarkdownFiles(vaultPath)
const escaped = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`\\[\\[${escaped}(\\|[^\\]]*?)?\\]\\]`, 'g')
for (const f of allFiles) {
if (f === newPath) continue
try {
const c = fs.readFileSync(f, 'utf-8')
const replaced = c.replace(pattern, (_m: string, pipe: string | undefined) =>
pipe ? `[[${newTitle}${pipe}]]` : `[[${newTitle}]]`
)
if (replaced !== c) { fs.writeFileSync(f, replaced, 'utf-8'); updatedFiles++ }
} catch { /* skip */ }
}
}
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ new_path: newPath, updated_files: updatedFiles }))
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Rename failed' }))
}
return
}
if (url.pathname === '/api/vault/delete' && req.method === 'POST') {
try {
const body = await readRequestBody(req)
const { path: filePath } = JSON.parse(body)
if (!filePath) {
res.statusCode = 400
res.end(JSON.stringify({ error: 'Missing path' }))
return
}
fs.unlinkSync(filePath)
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(filePath))
} catch (err: unknown) {
res.statusCode = 500
res.end(JSON.stringify({ error: err instanceof Error ? err.message : 'Delete failed' }))
}
return
}
next()
})
},