Compare commits
6 Commits
v0.2026030
...
v0.2026031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af18655de | ||
|
|
90bf73524c | ||
|
|
c1f7f7ec6f | ||
|
|
d1021b9131 | ||
|
|
b9139e2d57 | ||
|
|
c692c5d067 |
@@ -8,7 +8,7 @@ use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 5;
|
||||
const CACHE_VERSION: u32 = 6;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -870,4 +870,80 @@ mod tests {
|
||||
"note must be trashed after invalidate + rescan"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: a note with `Archived: Yes` (string, not boolean)
|
||||
/// must be recognized as archived through the full cached vault load path.
|
||||
/// This catches the scenario where a stale cache stores `archived: false`
|
||||
/// and the cache version bump forces a correct re-parse.
|
||||
#[test]
|
||||
fn test_cached_vault_archived_yes_string() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(
|
||||
vault,
|
||||
"archived-note.md",
|
||||
"---\nArchived: Yes\n---\n# Old Note\n",
|
||||
);
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].archived,
|
||||
"'Archived: Yes' must be parsed as true through the cached vault path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: `Trashed: Yes` (string) through full cached path.
|
||||
#[test]
|
||||
fn test_cached_vault_trashed_yes_string() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "trashed-note.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].trashed,
|
||||
"'Trashed: Yes' must be parsed as true through the cached vault path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: stale cache with old version is invalidated and
|
||||
/// re-parses `Archived: Yes` correctly after cache version bump.
|
||||
#[test]
|
||||
fn test_stale_cache_version_forces_rescan_of_archived_yes() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "---\nArchived: Yes\n---\n# Note\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let hash = git_head_hash(vault).unwrap();
|
||||
|
||||
// Simulate a stale cache written by old code that parsed Archived: Yes as false
|
||||
let stale_entry = {
|
||||
let mut e = parse_md_file(&vault.join("note.md")).unwrap();
|
||||
e.archived = false; // simulate old parser behavior
|
||||
e
|
||||
};
|
||||
let stale_cache = VaultCache {
|
||||
version: CACHE_VERSION - 1, // old version
|
||||
vault_path: vault.to_string_lossy().to_string(),
|
||||
commit_hash: hash,
|
||||
entries: vec![stale_entry],
|
||||
};
|
||||
write_cache(vault, &stale_cache);
|
||||
|
||||
// Load via cached path — stale version must trigger full rescan
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].archived,
|
||||
"stale cache with old version must be invalidated, re-parsing 'Archived: Yes' as true"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1261,6 +1261,17 @@ References:
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_note_content_deeply_nested_new_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("a/b/c/deep-note.md");
|
||||
let content = "---\ntitle: Deep\n---\n";
|
||||
|
||||
save_note_content(path.to_str().unwrap(), content).unwrap();
|
||||
assert!(path.exists());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
}
|
||||
|
||||
// --- sidebar_label tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -306,10 +306,16 @@ function App() {
|
||||
triggerIncrementalIndex()
|
||||
}, [vault, triggerIncrementalIndex])
|
||||
|
||||
const { notifyThemeSaved } = themeManager
|
||||
const onNotePersisted = useCallback((path: string, content: string) => {
|
||||
vault.clearUnsaved(path)
|
||||
notifyThemeSaved(path, content)
|
||||
}, [vault, notifyThemeSaved])
|
||||
|
||||
const { handleSave: handleSaveRaw, handleContentChange, savePendingForPath, savePending } = useEditorSaveWithLinks({
|
||||
updateEntry: vault.updateEntry,
|
||||
setTabs: notes.setTabs, setToastMessage, onAfterSave,
|
||||
onNotePersisted: vault.clearUnsaved,
|
||||
onNotePersisted,
|
||||
})
|
||||
useEffect(() => { contentChangeRef.current = handleContentChange }, [handleContentChange])
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ interface EditorSaveConfig {
|
||||
setTabs: (fn: SetStateAction<any[]>) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave?: () => void
|
||||
/** Called after content is persisted — used to clear unsaved state. */
|
||||
onNotePersisted?: (path: string) => void
|
||||
/** Called after content is persisted — used to clear unsaved state and live-reload themes. */
|
||||
onNotePersisted?: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,8 +41,9 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
if (!pending) return false
|
||||
if (pathFilter && pending.path !== pathFilter) return false
|
||||
await saveNote(pending.path, pending.content)
|
||||
const savedContent = pending.content
|
||||
pendingContentRef.current = null
|
||||
onNotePersisted?.(pending.path)
|
||||
onNotePersisted?.(pending.path, savedContent)
|
||||
return true
|
||||
}, [saveNote, onNotePersisted])
|
||||
|
||||
@@ -53,7 +54,7 @@ export function useEditorSave({ updateVaultContent, setTabs, setToastMessage, on
|
||||
const saved = await flushPending()
|
||||
if (!saved && unsavedFallback) {
|
||||
await saveNote(unsavedFallback.path, unsavedFallback.content)
|
||||
onNotePersisted?.(unsavedFallback.path)
|
||||
onNotePersisted?.(unsavedFallback.path, unsavedFallback.content)
|
||||
setToastMessage('Saved')
|
||||
onAfterSave()
|
||||
return
|
||||
|
||||
@@ -8,7 +8,7 @@ export function useEditorSaveWithLinks(config: {
|
||||
setTabs: Parameters<typeof useEditorSave>[0]['setTabs']
|
||||
setToastMessage: (msg: string | null) => void
|
||||
onAfterSave: () => void
|
||||
onNotePersisted?: (path: string) => void
|
||||
onNotePersisted?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { updateEntry } = config
|
||||
const saveContent = useCallback((path: string, content: string) => {
|
||||
|
||||
@@ -77,13 +77,21 @@ describe('slugify', () => {
|
||||
expect(slugify('--hello--')).toBe('hello')
|
||||
})
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(slugify('')).toBe('')
|
||||
it('handles empty string with fallback', () => {
|
||||
expect(slugify('')).toBe('untitled')
|
||||
})
|
||||
|
||||
it('collapses multiple separators into one hyphen', () => {
|
||||
expect(slugify('hello world---foo')).toBe('hello-world-foo')
|
||||
})
|
||||
|
||||
it('returns fallback for strings with only special characters', () => {
|
||||
// slugify('+++') should not return empty string — it causes invalid paths
|
||||
expect(slugify('+++')).not.toBe('')
|
||||
expect(slugify('!!!')).not.toBe('')
|
||||
expect(slugify('---')).not.toBe('')
|
||||
expect(slugify('@#$')).not.toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewEntry', () => {
|
||||
@@ -270,6 +278,20 @@ describe('resolveNewNote', () => {
|
||||
expect(entry.path).toBe('/other/vault/note/test.md')
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
|
||||
it('produces a valid path for custom types with special characters', () => {
|
||||
const { entry } = resolveNewNote('My Note', 'Q&A', '/vault')
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.path).toMatch(/\.md$/)
|
||||
expect(entry.filename).not.toBe('.md')
|
||||
})
|
||||
|
||||
it('produces a valid path when type is all special characters', () => {
|
||||
const { entry } = resolveNewNote('My Note', '+++', '/vault')
|
||||
// folder should not be empty, path should not have double slashes
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.path).toMatch(/\.md$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewType', () => {
|
||||
@@ -612,6 +634,34 @@ describe('useNoteActions hook', () => {
|
||||
expect(tabContent).toContain('## Steps')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate does not throw for custom types with special characters', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('Q&A')
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const [entry] = addEntry.mock.calls[0]
|
||||
expect(entry.isA).toBe('Q&A')
|
||||
expect(entry.path).not.toContain('//')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate does not throw for types that slugify to empty string', () => {
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig()))
|
||||
|
||||
expect(() => {
|
||||
act(() => {
|
||||
result.current.handleCreateNoteImmediate('+++')
|
||||
})
|
||||
}).not.toThrow()
|
||||
|
||||
const [entry] = addEntry.mock.calls[0]
|
||||
expect(entry.path).not.toContain('//')
|
||||
expect(entry.filename).not.toBe('.md')
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate uses template for typed notes', () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', template: '## Custom Template\n\n' })
|
||||
const { result } = renderHook(() => useNoteActions(makeConfig([typeEntry])))
|
||||
|
||||
@@ -100,7 +100,8 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const result = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
return result || 'untitled'
|
||||
}
|
||||
|
||||
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
|
||||
@@ -392,17 +393,22 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
try {
|
||||
const noteType = type || 'Note'
|
||||
const title = generateUntitledName(entries, noteType, pendingNamesRef.current)
|
||||
pendingNamesRef.current.add(title)
|
||||
const template = resolveTemplate(entries, noteType)
|
||||
const resolved = resolveNewNote(title, noteType, config.vaultPath, template)
|
||||
openTabWithContent(resolved.entry, resolved.content)
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
} catch (err) {
|
||||
console.error('Failed to create note:', err)
|
||||
setToastMessage('Failed to create note')
|
||||
}
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
/** Close tab and discard entry+unsaved state if the note was never persisted. */
|
||||
|
||||
@@ -441,6 +441,55 @@ describe('useThemeManager', () => {
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('notifyThemeSaved updates CSS vars when active theme is saved', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
|
||||
const updatedContent = `---
|
||||
type: Theme
|
||||
Description: Light theme
|
||||
background: "#1a1a2e"
|
||||
foreground: "#e0e0e0"
|
||||
primary: "#155DFF"
|
||||
sidebar: "#2a2a3e"
|
||||
text-primary: "#e0e0e0"
|
||||
---
|
||||
|
||||
# Default Theme
|
||||
`
|
||||
act(() => {
|
||||
result.current.notifyThemeSaved(THEME_PATH_DEFAULT, updatedContent)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#1a1a2e')
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--sidebar')).toBe('#2a2a3e')
|
||||
})
|
||||
|
||||
it('notifyThemeSaved is a no-op for non-active theme path', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useThemeManager('/vault', entries, allContent)
|
||||
)
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeThemeId).toBe(THEME_PATH_DEFAULT)
|
||||
})
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
|
||||
act(() => {
|
||||
result.current.notifyThemeSaved(THEME_PATH_DARK, DARK_THEME_CONTENT)
|
||||
})
|
||||
|
||||
// Background should still be the default theme's white, not dark
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
expect(document.documentElement.style.getPropertyValue('--background')).toBe('#FFFFFF')
|
||||
})
|
||||
|
||||
it('calls ensure_vault_themes on mount with vaultPath', async () => {
|
||||
renderHook(() => useThemeManager('/vault', entries, allContent))
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -124,6 +124,8 @@ export interface ThemeManager {
|
||||
reloadThemes: () => Promise<void>
|
||||
/** Update a single frontmatter property on the active theme note. */
|
||||
updateThemeProperty: (key: string, value: string) => Promise<void>
|
||||
/** Notify that the active theme note was saved with new content (live-reload on Cmd+S). */
|
||||
notifyThemeSaved: (path: string, content: string) => void
|
||||
}
|
||||
|
||||
/** Manages loading and persisting the active theme path from vault settings. */
|
||||
@@ -275,6 +277,10 @@ export function useThemeManager(
|
||||
|
||||
const reloadThemes = useCallback(async () => { await reload() }, [reload])
|
||||
|
||||
const notifyThemeSaved = useCallback((path: string, content: string) => {
|
||||
if (path === activeThemeId) setCachedThemeContent(content)
|
||||
}, [activeThemeId])
|
||||
|
||||
const updateThemeProperty = useCallback(async (key: string, value: string) => {
|
||||
if (!activeThemeId) return
|
||||
try {
|
||||
@@ -290,6 +296,6 @@ export function useThemeManager(
|
||||
return {
|
||||
themes, activeThemeId, activeTheme,
|
||||
activeThemeContent: cachedThemeContent,
|
||||
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty,
|
||||
isDark, switchTheme, createTheme, reloadThemes, updateThemeProperty, notifyThemeSaved,
|
||||
}
|
||||
}
|
||||
|
||||
46
src/utils/frontmatter.test.ts
Normal file
46
src/utils/frontmatter.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseFrontmatter } from './frontmatter'
|
||||
|
||||
describe('parseFrontmatter', () => {
|
||||
describe('boolean-like Yes/No values', () => {
|
||||
it('parses Archived: Yes as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: Yes\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses Archived: No as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: No\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
|
||||
it('parses Trashed: Yes as true', () => {
|
||||
const fm = parseFrontmatter('---\nTrashed: Yes\n---\nBody')
|
||||
expect(fm['Trashed']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses Trashed: No as false', () => {
|
||||
const fm = parseFrontmatter('---\nTrashed: No\n---\nBody')
|
||||
expect(fm['Trashed']).toBe(false)
|
||||
})
|
||||
|
||||
it('parses yes (lowercase) as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: yes\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('parses no (lowercase) as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: no\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
|
||||
it('still parses true as true', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: true\n---\nBody')
|
||||
expect(fm['Archived']).toBe(true)
|
||||
})
|
||||
|
||||
it('still parses false as false', () => {
|
||||
const fm = parseFrontmatter('---\nArchived: false\n---\nBody')
|
||||
expect(fm['Archived']).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -23,8 +23,9 @@ function parseInlineArray(value: string): FrontmatterValue {
|
||||
|
||||
function parseScalar(value: string): FrontmatterValue {
|
||||
const clean = unquote(value)
|
||||
if (clean.toLowerCase() === 'true') return true
|
||||
if (clean.toLowerCase() === 'false') return false
|
||||
const lower = clean.toLowerCase()
|
||||
if (lower === 'true' || lower === 'yes') return true
|
||||
if (lower === 'false' || lower === 'no') return false
|
||||
return clean
|
||||
}
|
||||
|
||||
|
||||
109
tests/smoke/fix-archived-trashed-detection-v2.spec.ts
Normal file
109
tests/smoke/fix-archived-trashed-detection-v2.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
const QUICK_OPEN_INPUT = 'input[placeholder="Search notes..."]'
|
||||
|
||||
/** Known archived note titles from mock data */
|
||||
const ARCHIVED_TITLES = ['Website Redesign', 'Twitter Thread Growth Experiment']
|
||||
|
||||
async function openQuickOpen(page: import('@playwright/test').Page) {
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'p', ['Control'])
|
||||
await expect(page.locator(QUICK_OPEN_INPUT)).toBeVisible()
|
||||
}
|
||||
|
||||
function quickOpenPanel(page: import('@playwright/test').Page) {
|
||||
return page.locator('.fixed.inset-0').filter({ has: page.locator(QUICK_OPEN_INPUT) })
|
||||
}
|
||||
|
||||
function getResultTitles(container: import('@playwright/test').Locator) {
|
||||
return container.locator('span.truncate').allTextContents()
|
||||
}
|
||||
|
||||
test.describe('Archived/Trashed Yes/No detection', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('archived notes are filtered out of the default sidebar', async ({ page }) => {
|
||||
// In the default sidebar view (non-archived section), archived notes must not appear
|
||||
const noteItems = page.locator('[data-testid="note-item"]')
|
||||
const count = await noteItems.count()
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await noteItems.nth(i).textContent()
|
||||
for (const title of ARCHIVED_TITLES) {
|
||||
expect(text, `archived note "${title}" should not be in default sidebar`).not.toContain(title)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('archived note shows ArchivedNoteBanner in the editor', async ({ page }) => {
|
||||
// Open quick open and navigate to an archived note
|
||||
await openQuickOpen(page)
|
||||
await page.locator(QUICK_OPEN_INPUT).fill('Website Redesign')
|
||||
await page.waitForTimeout(400)
|
||||
|
||||
// The archived note might not appear in quick open (filtered), so try direct URL
|
||||
const panel = quickOpenPanel(page)
|
||||
const titles = await getResultTitles(panel)
|
||||
if (titles.some(t => t.includes('Website Redesign'))) {
|
||||
// If it appears in quick open, click it
|
||||
const result = panel.locator('span.truncate').filter({ hasText: 'Website Redesign' }).first()
|
||||
await result.click()
|
||||
} else {
|
||||
// Close quick open and use sidebar Archived section if available
|
||||
await page.keyboard.press('Escape')
|
||||
// Click the Archived section header to expand it
|
||||
const archivedSection = page.locator('text=Archived').first()
|
||||
if (await archivedSection.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await archivedSection.click()
|
||||
await page.waitForTimeout(300)
|
||||
const archivedNote = page.locator('[data-testid="note-item"]').filter({ hasText: 'Website Redesign' })
|
||||
if (await archivedNote.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await archivedNote.click()
|
||||
} else {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
// Verify the archived banner is visible
|
||||
const banner = page.locator('[data-testid="archived-note-banner"]')
|
||||
await expect(banner).toBeVisible({ timeout: 3000 })
|
||||
await expect(banner).toContainText('Archived')
|
||||
})
|
||||
|
||||
test('archived note shows archived badge in the note list', async ({ page }) => {
|
||||
// Navigate to the Archived section in the sidebar
|
||||
const archivedSection = page.locator('button, [role="button"], span').filter({ hasText: /^Archived/ }).first()
|
||||
if (!await archivedSection.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
test.skip()
|
||||
return
|
||||
}
|
||||
await archivedSection.click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Look for archived badge on note items
|
||||
const badges = page.locator('[data-testid="state-badge"]')
|
||||
const badgeCount = await badges.count()
|
||||
expect(badgeCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('archived notes do not appear in Quick Open search', async ({ page }) => {
|
||||
await openQuickOpen(page)
|
||||
const panel = quickOpenPanel(page)
|
||||
for (const title of ARCHIVED_TITLES) {
|
||||
const query = title.split(' ')[0]
|
||||
await page.locator(QUICK_OPEN_INPUT).fill(query)
|
||||
await page.waitForTimeout(400)
|
||||
const titles = await getResultTitles(panel)
|
||||
expect(titles, `"${title}" should not appear in Quick Open`).not.toContain(title)
|
||||
}
|
||||
})
|
||||
})
|
||||
77
tests/smoke/fix-crash-create-note.spec.ts
Normal file
77
tests/smoke/fix-crash-create-note.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
test.describe('Create note crash fix', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('clicking + next to a type section creates a note without crashing', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
// Hover over the Projects section to reveal the + button
|
||||
const sectionHeader = page.getByText('Projects').first()
|
||||
await sectionHeader.hover()
|
||||
|
||||
// Click the actual create button (exact match avoids the parent sortable div)
|
||||
const createBtn = page.getByRole('button', { name: 'Create new Project', exact: true })
|
||||
await createBtn.click({ force: true })
|
||||
|
||||
// The new note should appear — check for tab + heading
|
||||
await expect(page.getByText('Untitled project').first()).toBeVisible({ timeout: 3000 })
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('Cmd+N creates a note without crashing', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// An "Untitled note" tab should be visible
|
||||
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('creating note for custom type does not crash', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
// Hover over a custom type section (Areas exists in mock vault)
|
||||
const sectionHeader = page.getByText('Areas').first()
|
||||
await sectionHeader.hover()
|
||||
|
||||
const createBtn = page.getByRole('button', { name: 'Create new Area', exact: true })
|
||||
await createBtn.click({ force: true })
|
||||
|
||||
await expect(page.getByText('Untitled area').first()).toBeVisible({ timeout: 3000 })
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('rapid note creation does not crash', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => errors.push(err.message))
|
||||
|
||||
const sectionHeader = page.getByText('Experiments').first()
|
||||
await sectionHeader.hover()
|
||||
|
||||
const createBtn = page.getByRole('button', { name: 'Create new Experiment', exact: true })
|
||||
await createBtn.click({ force: true })
|
||||
await createBtn.click({ force: true })
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// At least one untitled experiment should exist
|
||||
await expect(page.getByText('Untitled experiment').first()).toBeVisible({ timeout: 3000 })
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
121
tests/smoke/theme-live-reload-fix.spec.ts
Normal file
121
tests/smoke/theme-live-reload-fix.spec.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
async function getCssVar(page: Page, name: string): Promise<string> {
|
||||
return page.evaluate(
|
||||
(n) => document.documentElement.style.getPropertyValue(n),
|
||||
name,
|
||||
)
|
||||
}
|
||||
|
||||
async function switchToDefaultTheme(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Switch to Default Theme')
|
||||
await expect(async () => {
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
|
||||
}).toPass({ timeout: 5000 })
|
||||
}
|
||||
|
||||
async function openThemeNoteInRawMode(page: Page) {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Edit Default Theme')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw Editor')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 3000 })
|
||||
}
|
||||
|
||||
/** Replace all text in the CodeMirror editor via its EditorView API. */
|
||||
async function setCmContent(page: Page, newContent: string) {
|
||||
await page.evaluate((text) => {
|
||||
const cmContent = document.querySelector('.cm-content') as HTMLElement | null
|
||||
if (!cmContent) throw new Error('No .cm-content found')
|
||||
// Access EditorView via CodeMirror's internal DOM reference
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const view = (cmContent as any).cmTile?.root?.view
|
||||
if (!view) throw new Error('No EditorView found on .cm-content')
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: text },
|
||||
})
|
||||
}, newContent)
|
||||
}
|
||||
|
||||
test.describe('Theme live reload on save', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('editing theme frontmatter and saving updates CSS vars immediately', async ({ page }) => {
|
||||
// 1. Switch to the default theme
|
||||
await switchToDefaultTheme(page)
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
|
||||
expect(await getCssVar(page, '--sidebar')).toBe('#F7F6F3')
|
||||
|
||||
// 2. Open the theme note in raw editor mode
|
||||
await openThemeNoteInRawMode(page)
|
||||
|
||||
// 3. Replace content via CodeMirror API with changed colors
|
||||
const updatedContent = [
|
||||
'---',
|
||||
'type: Theme',
|
||||
'title: Default',
|
||||
'primary: "#155DFF"',
|
||||
'background: "#1a1a2e"',
|
||||
'foreground: "#37352F"',
|
||||
'sidebar: "#2a2a3e"',
|
||||
'border: "#E9E9E7"',
|
||||
'muted: "#F0F0EF"',
|
||||
'muted-foreground: "#9B9A97"',
|
||||
'accent: "#F0F7FF"',
|
||||
'accent-foreground: "#0A3B8F"',
|
||||
'font-family: "\'Inter\', -apple-system, BlinkMacSystemFont, sans-serif"',
|
||||
'font-size-base: 14',
|
||||
'line-height-base: 1.6',
|
||||
'---',
|
||||
'',
|
||||
'# Default',
|
||||
'',
|
||||
'Light theme with warm, paper-like tones.',
|
||||
].join('\n')
|
||||
await setCmContent(page, updatedContent)
|
||||
|
||||
// Wait for debounce to flush (RawEditorView has 500ms debounce)
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
// 4. Save with Ctrl+S
|
||||
await page.keyboard.press('Control+s')
|
||||
|
||||
// 5. Verify CSS vars updated live
|
||||
await expect(async () => {
|
||||
expect(await getCssVar(page, '--background')).toBe('#1a1a2e')
|
||||
}).toPass({ timeout: 5000 })
|
||||
expect(await getCssVar(page, '--sidebar')).toBe('#2a2a3e')
|
||||
})
|
||||
|
||||
test('saving a non-theme note does not affect active theme CSS', async ({ page }) => {
|
||||
// 1. Switch to the default theme
|
||||
await switchToDefaultTheme(page)
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
|
||||
|
||||
// 2. Open a regular note (first in list), switch to raw mode
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Toggle Raw Editor')
|
||||
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// 3. Type something and save
|
||||
await page.locator('.cm-content').click()
|
||||
await page.keyboard.type('test edit')
|
||||
await page.keyboard.press('Control+s')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 4. Theme CSS vars unchanged
|
||||
expect(await getCssVar(page, '--background')).toBe('#FFFFFF')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user