583 lines
20 KiB
TypeScript
583 lines
20 KiB
TypeScript
/// <reference types="vitest/config" />
|
|
import type { IncomingMessage, ServerResponse } from 'http'
|
|
import path from 'path'
|
|
import fs from 'fs'
|
|
import os from 'os'
|
|
import { defineConfig, type Plugin } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
import tailwindcss from '@tailwindcss/vite'
|
|
import matter from 'gray-matter'
|
|
|
|
// --- Vault API middleware (dev only) ---
|
|
|
|
interface VaultEntry {
|
|
path: string
|
|
filename: string
|
|
title: string
|
|
isA: string | null
|
|
aliases: string[]
|
|
belongsTo: string[]
|
|
relatedTo: string[]
|
|
status: string | null
|
|
archived: boolean
|
|
trashed: boolean
|
|
trashedAt: number | null
|
|
modifiedAt: number | null
|
|
createdAt: number | null
|
|
fileSize: number
|
|
snippet: string
|
|
wordCount: number
|
|
relationships: Record<string, string[]>
|
|
icon: string | null
|
|
color: string | null
|
|
order: number | null
|
|
sidebarLabel: string | null
|
|
template: string | null
|
|
sort: string | null
|
|
view: string | null
|
|
visible: boolean | null
|
|
outgoingLinks: string[]
|
|
properties: Record<string, string | number | boolean | null>
|
|
}
|
|
|
|
/** Extract all [[wiki-links]] from a string. */
|
|
function extractWikiLinks(value: string): string[] {
|
|
const matches = value.match(/\[\[[^\]]+\]\]/g)
|
|
return matches ?? []
|
|
}
|
|
|
|
/** Extract wiki-links from a frontmatter value (string or array of strings). */
|
|
function wikiLinksFromValue(value: unknown): string[] {
|
|
if (typeof value === 'string') return extractWikiLinks(value)
|
|
if (Array.isArray(value)) {
|
|
return value.flatMap((v) => (typeof v === 'string' ? extractWikiLinks(v) : []))
|
|
}
|
|
return []
|
|
}
|
|
|
|
// Frontmatter keys that map to dedicated VaultEntry fields (skip in generic relationships)
|
|
const DEDICATED_KEYS = new Set([
|
|
'aliases', 'is_a', 'is a', 'belongs_to', 'belongs to',
|
|
'related_to', 'related to', 'status', 'title',
|
|
])
|
|
|
|
function getFrontmatterValue(
|
|
frontmatter: Record<string, unknown>,
|
|
keys: string[],
|
|
): unknown {
|
|
const normalizedKeys = new Set(keys.map((key) => key.toLowerCase()))
|
|
return Object.entries(frontmatter).find(([key]) => normalizedKeys.has(key.toLowerCase()))?.[1]
|
|
}
|
|
|
|
function parseYamlBool(value: unknown): boolean | null {
|
|
if (typeof value === 'boolean') return value
|
|
if (typeof value !== 'string') return null
|
|
|
|
switch (value.toLowerCase()) {
|
|
case 'true':
|
|
case 'yes':
|
|
return true
|
|
case 'false':
|
|
case 'no':
|
|
return false
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
const vitestCoverageDirectory = process.env.VITEST_COVERAGE_DIR
|
|
?? path.join(os.tmpdir(), 'tolaria-vitest-coverage', String(process.pid))
|
|
|
|
const devServerWatchIgnored = [
|
|
'**/coverage/**',
|
|
'**/test-results/**',
|
|
'**/playwright-report/**',
|
|
'**/dist/**',
|
|
'**/src-tauri/target/**',
|
|
]
|
|
|
|
function frontmatterString(frontmatter: Record<string, unknown>, ...keys: string[]): string | null {
|
|
const value = getFrontmatterValue(frontmatter, keys)
|
|
return typeof value === 'string' ? value : null
|
|
}
|
|
|
|
function frontmatterStringArray(frontmatter: Record<string, unknown>, ...keys: string[]): string[] {
|
|
const value = getFrontmatterValue(frontmatter, keys)
|
|
if (Array.isArray(value)) return value.map(String)
|
|
if (typeof value === 'string') return [value]
|
|
return []
|
|
}
|
|
|
|
function frontmatterBool(frontmatter: Record<string, unknown>, ...keys: string[]): boolean | null {
|
|
return parseYamlBool(getFrontmatterValue(frontmatter, keys))
|
|
}
|
|
|
|
function markdownTitle(content: string, frontmatter: Record<string, unknown>, fallback: string): string {
|
|
const title = frontmatterString(frontmatter, 'title')
|
|
if (title) return title
|
|
|
|
const h1Match = content.match(/^#\s+(.+)$/m)
|
|
return h1Match ? h1Match[1].trim() : fallback
|
|
}
|
|
|
|
function markdownBodyText(content: string): string {
|
|
return content.replace(/^#+\s+.+$/gm, '').replace(/[\n\r]+/g, ' ').trim()
|
|
}
|
|
|
|
function frontmatterWikiLinks(frontmatter: Record<string, unknown>, ...keys: string[]): string[] {
|
|
return frontmatterStringArray(frontmatter, ...keys).flatMap((value) => extractWikiLinks(value))
|
|
}
|
|
|
|
function frontmatterRelationships(frontmatter: Record<string, unknown>): Record<string, string[]> {
|
|
const relationships: Record<string, string[]> = {}
|
|
for (const [key, value] of Object.entries(frontmatter)) {
|
|
if (DEDICATED_KEYS.has(key.toLowerCase())) continue
|
|
const links = wikiLinksFromValue(value)
|
|
if (links.length > 0) relationships[key] = links
|
|
}
|
|
return relationships
|
|
}
|
|
|
|
function parseMarkdownFile(filePath: string): VaultEntry | null {
|
|
try {
|
|
const raw = fs.readFileSync(filePath, 'utf-8')
|
|
const stats = fs.statSync(filePath)
|
|
const { data, content } = matter(raw)
|
|
const fm = data as Record<string, unknown>
|
|
|
|
const filename = path.basename(filePath)
|
|
const basename = filename.replace(/\.md$/, '')
|
|
|
|
const title = markdownTitle(content, fm, basename)
|
|
const bodyText = markdownBodyText(content)
|
|
const snippet = bodyText.slice(0, 200)
|
|
|
|
return {
|
|
path: filePath,
|
|
filename,
|
|
title,
|
|
isA: frontmatterString(fm, 'is_a', 'is a', 'type'),
|
|
aliases: frontmatterStringArray(fm, 'aliases'),
|
|
belongsTo: frontmatterWikiLinks(fm, 'belongs_to', 'belongs to'),
|
|
relatedTo: frontmatterWikiLinks(fm, 'related_to', 'related to'),
|
|
status: frontmatterString(fm, 'status'),
|
|
archived: frontmatterBool(fm, 'archived') ?? false,
|
|
trashed: frontmatterBool(fm, 'trashed') ?? false,
|
|
trashedAt: null,
|
|
modifiedAt: stats.mtimeMs,
|
|
createdAt: stats.birthtimeMs,
|
|
fileSize: stats.size,
|
|
snippet,
|
|
wordCount: bodyText.split(/\s+/).filter(Boolean).length,
|
|
relationships: frontmatterRelationships(fm),
|
|
icon: frontmatterString(fm, 'icon'),
|
|
color: frontmatterString(fm, 'color'),
|
|
order: fm.order != null ? Number(fm.order) : null,
|
|
sidebarLabel: frontmatterString(fm, 'sidebar label', 'sidebar_label'),
|
|
template: frontmatterString(fm, 'template'),
|
|
sort: frontmatterString(fm, 'sort'),
|
|
view: frontmatterString(fm, 'view'),
|
|
visible: frontmatterBool(fm, 'visible'),
|
|
outgoingLinks: [],
|
|
properties: {},
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Recursively find all .md files under a directory. */
|
|
function findMarkdownFiles(dir: string): string[] {
|
|
const results: string[] = []
|
|
try {
|
|
const items = fs.readdirSync(dir, { withFileTypes: true })
|
|
for (const item of items) {
|
|
if (item.name.startsWith('.')) continue
|
|
const full = path.join(dir, item.name)
|
|
if (item.isDirectory()) {
|
|
results.push(...findMarkdownFiles(full))
|
|
} else if (item.name.endsWith('.md')) {
|
|
results.push(full)
|
|
}
|
|
}
|
|
} catch {
|
|
// skip unreadable dirs
|
|
}
|
|
return results
|
|
}
|
|
|
|
function sendJson(res: ServerResponse, payload: unknown, statusCode = 200): void {
|
|
res.statusCode = statusCode
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify(payload))
|
|
}
|
|
|
|
function readExistingQueryPath(url: URL, res: ServerResponse, key: string): string | null {
|
|
const filePath = url.searchParams.get(key)
|
|
if (!filePath || !fs.existsSync(filePath)) {
|
|
sendJson(res, { error: 'Invalid or missing path' }, 400)
|
|
return null
|
|
}
|
|
return filePath
|
|
}
|
|
|
|
function updateTitleWikilinks(vaultPath: string, oldTitle: string, _newTitle: string, excludePath: string): number {
|
|
const newPathStem = path.relative(vaultPath, excludePath).replace(/\.md$/i, '')
|
|
const oldTargets = collectLegacyWikilinkTargets(oldTitle, excludePath, vaultPath)
|
|
return updateWikilinksForTargets(vaultPath, oldTargets, newPathStem, excludePath)
|
|
}
|
|
|
|
function collectLegacyWikilinkTargets(oldTitle: string, oldPath: string, vaultPath: string): string[] {
|
|
const oldRelativeStem = path.relative(vaultPath, oldPath).replace(/\.md$/i, '')
|
|
const oldFilenameStem = path.basename(oldPath, '.md')
|
|
return [...new Set([oldTitle, oldRelativeStem, oldFilenameStem].filter(Boolean))]
|
|
}
|
|
|
|
function updateWikilinksForTargets(vaultPath: string, oldTargets: string[], newTarget: string, excludePath: string): number {
|
|
if (oldTargets.length === 0) return 0
|
|
const allFiles = findMarkdownFiles(vaultPath)
|
|
const escaped = oldTargets.map(target => target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
const pattern = new RegExp(`\\[\\[(?:${escaped.join('|')})(\\|[^\\]]*?)?\\]\\]`, 'g')
|
|
let updatedFiles = 0
|
|
for (const filePath of allFiles) {
|
|
if (filePath === excludePath) continue
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf-8')
|
|
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
|
|
pipe ? `[[${newTarget}${pipe}]]` : `[[${newTarget}]]`
|
|
)
|
|
if (replaced !== content) {
|
|
fs.writeFileSync(filePath, replaced, 'utf-8')
|
|
updatedFiles++
|
|
}
|
|
} catch {
|
|
// Skip unreadable files in the dev vault API.
|
|
}
|
|
}
|
|
return updatedFiles
|
|
}
|
|
|
|
function updatePathWikilinks(vaultPath: string, oldPath: string, newPath: string, oldTitle: string): number {
|
|
const newRelativeStem = path.relative(vaultPath, newPath).replace(/\.md$/i, '')
|
|
const oldTargets = collectLegacyWikilinkTargets(oldTitle, oldPath, vaultPath)
|
|
return updateWikilinksForTargets(vaultPath, oldTargets, newRelativeStem, newPath)
|
|
}
|
|
|
|
function handleVaultPing(url: URL, res: ServerResponse): boolean {
|
|
if (url.pathname !== '/api/vault/ping') return false
|
|
sendJson(res, { ok: true })
|
|
return true
|
|
}
|
|
|
|
function handleVaultList(url: URL, res: ServerResponse): boolean {
|
|
if (url.pathname !== '/api/vault/list') return false
|
|
const dirPath = readExistingQueryPath(url, res, 'path')
|
|
if (!dirPath) return true
|
|
const entries = findMarkdownFiles(dirPath).map(parseMarkdownFile).filter(Boolean)
|
|
sendJson(res, entries)
|
|
return true
|
|
}
|
|
|
|
function handleVaultContent(url: URL, res: ServerResponse): boolean {
|
|
if (url.pathname !== '/api/vault/content') return false
|
|
const filePath = readExistingQueryPath(url, res, 'path')
|
|
if (!filePath) return true
|
|
sendJson(res, { content: fs.readFileSync(filePath, 'utf-8') })
|
|
return true
|
|
}
|
|
|
|
function handleVaultAllContent(url: URL, res: ServerResponse): boolean {
|
|
if (url.pathname !== '/api/vault/all-content') return false
|
|
const dirPath = readExistingQueryPath(url, res, 'path')
|
|
if (!dirPath) return true
|
|
const contentMap: Record<string, string> = {}
|
|
for (const filePath of findMarkdownFiles(dirPath)) {
|
|
try {
|
|
contentMap[filePath] = fs.readFileSync(filePath, 'utf-8')
|
|
} catch {
|
|
// Skip unreadable files.
|
|
}
|
|
}
|
|
sendJson(res, contentMap)
|
|
return true
|
|
}
|
|
|
|
function handleVaultEntry(url: URL, res: ServerResponse): boolean {
|
|
if (url.pathname !== '/api/vault/entry') return false
|
|
const filePath = readExistingQueryPath(url, res, 'path')
|
|
if (!filePath) return true
|
|
sendJson(res, parseMarkdownFile(filePath))
|
|
return true
|
|
}
|
|
|
|
function handleVaultSearch(url: URL, res: ServerResponse): boolean {
|
|
if (url.pathname !== '/api/vault/search') return false
|
|
const vaultPath = url.searchParams.get('vault_path')
|
|
const query = (url.searchParams.get('query') ?? '').toLowerCase()
|
|
const mode = url.searchParams.get('mode') ?? 'all'
|
|
if (!vaultPath || !query) {
|
|
sendJson(res, { results: [], elapsed_ms: 0, query, mode })
|
|
return true
|
|
}
|
|
|
|
const results: { title: string; path: string; snippet: string; score: number; note_type: string | null }[] = []
|
|
for (const filePath of findMarkdownFiles(vaultPath)) {
|
|
const entry = parseMarkdownFile(filePath)
|
|
if (!entry || entry.trashed) continue
|
|
const raw = fs.readFileSync(filePath, '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 })
|
|
}
|
|
}
|
|
sendJson(res, { results: results.slice(0, 20), elapsed_ms: 1, query, mode })
|
|
return true
|
|
}
|
|
|
|
async function handleVaultSave(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
if (url.pathname !== '/api/vault/save' || req.method !== 'POST') return false
|
|
try {
|
|
const body = await readRequestBody(req)
|
|
const { path: filePath, content } = JSON.parse(body)
|
|
if (!filePath || content === undefined) {
|
|
sendJson(res, { error: 'Missing path or content' }, 400)
|
|
return true
|
|
}
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
fs.writeFileSync(filePath, content, 'utf-8')
|
|
sendJson(res, null)
|
|
} catch (err: unknown) {
|
|
sendJson(res, { error: err instanceof Error ? err.message : 'Save failed' }, 500)
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function handleVaultRename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
if (url.pathname !== '/api/vault/rename' || req.method !== 'POST') return false
|
|
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 oldTitle = oldContent.match(/^# (.+)$/m)?.[1]?.trim() ?? ''
|
|
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
|
const newPath = path.join(path.dirname(oldPath), `${slug}.md`)
|
|
const newContent = oldContent.replace(/^# .+$/m, `# ${newTitle}`)
|
|
|
|
fs.writeFileSync(newPath, newContent, 'utf-8')
|
|
if (newPath !== oldPath) fs.unlinkSync(oldPath)
|
|
|
|
const updatedFiles = vaultPath ? updateTitleWikilinks(vaultPath, oldTitle, newTitle, newPath) : 0
|
|
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
|
|
} catch (err: unknown) {
|
|
sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)
|
|
}
|
|
return true
|
|
}
|
|
|
|
type FilenameStemValidation =
|
|
| { ok: true; stem: string }
|
|
| { ok: false; error: string }
|
|
|
|
function validateMarkdownFilenameStem(value: unknown): FilenameStemValidation {
|
|
const stem = String(value ?? '').trim().replace(/\.md$/i, '').trim()
|
|
if (!stem) return { ok: false, error: 'New filename cannot be empty' }
|
|
if (isUnsafeMarkdownFilenameStem(stem)) return { ok: false, error: 'Invalid filename' }
|
|
return { ok: true, stem }
|
|
}
|
|
|
|
function isUnsafeMarkdownFilenameStem(stem: string): boolean {
|
|
return stem === '.' || stem === '..' || stem.includes('/') || stem.includes('\\')
|
|
}
|
|
|
|
async function handleVaultRenameFilename(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
if (url.pathname !== '/api/vault/rename-filename' || req.method !== 'POST') return false
|
|
try {
|
|
const body = await readRequestBody(req)
|
|
const {
|
|
vault_path: vaultPath,
|
|
old_path: oldPath,
|
|
new_filename_stem: newFilenameStem,
|
|
} = JSON.parse(body)
|
|
const filename = validateMarkdownFilenameStem(newFilenameStem)
|
|
if (!filename.ok) {
|
|
sendJson(res, { error: filename.error }, 400)
|
|
return true
|
|
}
|
|
|
|
const newPath = path.join(path.dirname(oldPath), `${filename.stem}.md`)
|
|
const oldTitle = parseMarkdownFile(oldPath)?.title ?? path.basename(oldPath, '.md')
|
|
if (newPath !== oldPath && fs.existsSync(newPath)) {
|
|
sendJson(res, { error: 'A note with that name already exists' }, 409)
|
|
return true
|
|
}
|
|
|
|
fs.renameSync(oldPath, newPath)
|
|
const updatedFiles = vaultPath ? updatePathWikilinks(vaultPath, oldPath, newPath, oldTitle) : 0
|
|
sendJson(res, { new_path: newPath, updated_files: updatedFiles })
|
|
} catch (err: unknown) {
|
|
sendJson(res, { error: err instanceof Error ? err.message : 'Rename failed' }, 500)
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function handleVaultDelete(url: URL, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
if (url.pathname !== '/api/vault/delete' || req.method !== 'POST') return false
|
|
try {
|
|
const body = await readRequestBody(req)
|
|
const { path: filePath } = JSON.parse(body)
|
|
if (!filePath) {
|
|
sendJson(res, { error: 'Missing path' }, 400)
|
|
return true
|
|
}
|
|
fs.unlinkSync(filePath)
|
|
sendJson(res, filePath)
|
|
} catch (err: unknown) {
|
|
sendJson(res, { error: err instanceof Error ? err.message : 'Delete failed' }, 500)
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function handleVaultApiRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
|
|
const handlers = [
|
|
() => Promise.resolve(handleVaultPing(url, res)),
|
|
() => Promise.resolve(handleVaultList(url, res)),
|
|
() => Promise.resolve(handleVaultContent(url, res)),
|
|
() => Promise.resolve(handleVaultAllContent(url, res)),
|
|
() => Promise.resolve(handleVaultEntry(url, res)),
|
|
() => Promise.resolve(handleVaultSearch(url, res)),
|
|
() => handleVaultSave(url, req, res),
|
|
() => handleVaultRename(url, req, res),
|
|
() => handleVaultRenameFilename(url, req, res),
|
|
() => handleVaultDelete(url, req, res),
|
|
]
|
|
|
|
for (const handler of handlers) {
|
|
if (await handler()) return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
function vaultApiPlugin(): Plugin {
|
|
return {
|
|
name: 'vault-api',
|
|
configureServer(server) {
|
|
server.middlewares.use(async (req, res, next) => {
|
|
if (await handleVaultApiRequest(req, res)) return
|
|
next()
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- Proxy helpers ---
|
|
|
|
function readRequestBody(req: IncomingMessage): Promise<string> {
|
|
return new Promise((resolve) => {
|
|
let body = ''
|
|
req.on('data', (chunk: Buffer) => { body += chunk.toString() })
|
|
req.on('end', () => resolve(body))
|
|
})
|
|
}
|
|
|
|
/** WebSocket proxy info endpoint — tells the frontend where the MCP bridge is */
|
|
function mcpBridgeInfoPlugin(): Plugin {
|
|
return {
|
|
name: 'mcp-bridge-info',
|
|
configureServer(server) {
|
|
server.middlewares.use((req, res, next) => {
|
|
if (req.url !== '/api/mcp/info') return next()
|
|
res.setHeader('Content-Type', 'application/json')
|
|
res.end(JSON.stringify({
|
|
wsUrl: `ws://localhost:${process.env.MCP_WS_PORT || 9710}`,
|
|
available: true,
|
|
}))
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
// https://vite.dev/config/
|
|
export default defineConfig({
|
|
plugins: [react(), tailwindcss(), vaultApiPlugin(), mcpBridgeInfoPlugin()],
|
|
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
},
|
|
},
|
|
|
|
// Inject the demo-vault-v2 path in local dev only — production Tauri builds and
|
|
// CI must resolve the default vault path at runtime via the backend to avoid
|
|
// baking the CI runner's absolute path into the distributed bundle.
|
|
define: {
|
|
...(process.env.CI || (process.env.TAURI_PLATFORM && !process.env.TAURI_DEBUG)
|
|
? {}
|
|
: { __DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')) }),
|
|
},
|
|
|
|
// Prevent vite from obscuring Rust errors
|
|
clearScreen: false,
|
|
|
|
// Tauri expects a fixed port
|
|
server: {
|
|
port: 5202,
|
|
strictPort: true,
|
|
allowedHosts: true,
|
|
watch: {
|
|
ignored: devServerWatchIgnored,
|
|
},
|
|
},
|
|
|
|
// Env variables starting with TAURI_ are exposed to the frontend
|
|
envPrefix: ['VITE_', 'TAURI_'],
|
|
|
|
build: {
|
|
// Tauri uses Chromium on Windows and WebKit on macOS/Linux
|
|
target: process.env.TAURI_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
|
|
// Don't minify for debug builds
|
|
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
|
// Produce sourcemaps for debug builds
|
|
sourcemap: !!process.env.TAURI_DEBUG,
|
|
},
|
|
|
|
test: {
|
|
globals: true,
|
|
environment: 'jsdom',
|
|
setupFiles: ['./src/test/setup.ts'],
|
|
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
|
coverage: {
|
|
provider: 'v8',
|
|
reporter: ['text', 'json', 'html', 'lcov'],
|
|
// Keep coverage temp files off the mounted workspace to avoid flaky
|
|
// read-after-write races when Vitest re-reads its own coverage shards.
|
|
reportsDirectory: vitestCoverageDirectory,
|
|
include: ['src/**/*.{ts,tsx}'],
|
|
exclude: [
|
|
'src/**/*.{test,spec}.{ts,tsx}',
|
|
'src/test/**',
|
|
'src/mock-tauri.ts',
|
|
'src/main.tsx',
|
|
'src/types.ts',
|
|
'src/hooks/useMcpBridge.ts',
|
|
'src/hooks/useAiAgent.ts',
|
|
'src/utils/ai-chat.ts',
|
|
'src/utils/ai-agent.ts',
|
|
'src/components/ui/dropdown-menu.tsx',
|
|
'src/components/ui/scroll-area.tsx',
|
|
'src/components/ui/select.tsx',
|
|
'src/components/ui/separator.tsx',
|
|
'src/components/ui/tabs.tsx',
|
|
'src/components/ui/tooltip.tsx',
|
|
'src/components/ui/card.tsx',
|
|
],
|
|
thresholds: {
|
|
lines: 70,
|
|
functions: 70,
|
|
branches: 70,
|
|
statements: 70,
|
|
},
|
|
},
|
|
},
|
|
})
|