diff --git a/package.json b/package.json index d45346d1..f590854b 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/playwright.integration.config.ts b/playwright.integration.config.ts new file mode 100644 index 00000000..d46fcecb --- /dev/null +++ b/playwright.integration.config.ts @@ -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, + }, +}) diff --git a/src/mock-tauri/vault-api.ts b/src/mock-tauri/vault-api.ts index 5f2439f3..fef5c5af 100644 --- a/src/mock-tauri/vault-api.ts +++ b/src/mock-tauri/vault-api.ts @@ -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 { return vaultApiAvailable } -const VAULT_API_COMMANDS: Record) => 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) => 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(cmd: string, args?: Record): Promise { 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 diff --git a/tests/fixtures/test-vault/event/team-meeting.md b/tests/fixtures/test-vault/event/team-meeting.md new file mode 100644 index 00000000..306511ff --- /dev/null +++ b/tests/fixtures/test-vault/event/team-meeting.md @@ -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. diff --git a/tests/fixtures/test-vault/note/archived-note.md b/tests/fixtures/test-vault/note/archived-note.md new file mode 100644 index 00000000..3e118abf --- /dev/null +++ b/tests/fixtures/test-vault/note/archived-note.md @@ -0,0 +1,8 @@ +--- +Is A: Note +Archived: Yes +--- + +# Archived Note + +This note is archived and should not appear in the main sidebar. diff --git a/tests/fixtures/test-vault/note/note-b.md b/tests/fixtures/test-vault/note/note-b.md new file mode 100644 index 00000000..58c0aa62 --- /dev/null +++ b/tests/fixtures/test-vault/note/note-b.md @@ -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]]. diff --git a/tests/fixtures/test-vault/note/note-c.md b/tests/fixtures/test-vault/note/note-c.md new file mode 100644 index 00000000..a6e57591 --- /dev/null +++ b/tests/fixtures/test-vault/note/note-c.md @@ -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. diff --git a/tests/fixtures/test-vault/note/trashed-note.md b/tests/fixtures/test-vault/note/trashed-note.md new file mode 100644 index 00000000..7a6f425b --- /dev/null +++ b/tests/fixtures/test-vault/note/trashed-note.md @@ -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. diff --git a/tests/fixtures/test-vault/project/alpha-project.md b/tests/fixtures/test-vault/project/alpha-project.md new file mode 100644 index 00000000..39fc3c92 --- /dev/null +++ b/tests/fixtures/test-vault/project/alpha-project.md @@ -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. diff --git a/tests/fixtures/test-vault/type/event.md b/tests/fixtures/test-vault/type/event.md new file mode 100644 index 00000000..ad1f3541 --- /dev/null +++ b/tests/fixtures/test-vault/type/event.md @@ -0,0 +1,9 @@ +--- +Is A: Type +icon: calendar +color: orange +order: 2 +sidebarLabel: Events +--- + +# Event diff --git a/tests/fixtures/test-vault/type/note.md b/tests/fixtures/test-vault/type/note.md new file mode 100644 index 00000000..57ec6090 --- /dev/null +++ b/tests/fixtures/test-vault/type/note.md @@ -0,0 +1,9 @@ +--- +Is A: Type +icon: file-text +color: green +order: 1 +sidebarLabel: Notes +--- + +# Note diff --git a/tests/fixtures/test-vault/type/project.md b/tests/fixtures/test-vault/type/project.md new file mode 100644 index 00000000..794eb899 --- /dev/null +++ b/tests/fixtures/test-vault/type/project.md @@ -0,0 +1,9 @@ +--- +Is A: Type +icon: folder +color: blue +order: 0 +sidebarLabel: Projects +--- + +# Project diff --git a/tests/integration/vault-workflows.spec.ts b/tests/integration/vault-workflows.spec.ts new file mode 100644 index 00000000..58f4e343 --- /dev/null +++ b/tests/integration/vault-workflows.spec.ts @@ -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 appears in the tab. + // It's the only 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 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 }) +}) diff --git a/vite.config.ts b/vite.config.ts index 3bcb5e6d..b8a60b9c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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() }) },