Compare commits
13 Commits
v0.2026030
...
v0.2026031
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8104a8380c | ||
|
|
593a0d3d54 | ||
|
|
a50aae70e8 | ||
|
|
2387b9a637 | ||
|
|
27452515d7 | ||
|
|
14a4d371e6 | ||
|
|
9199ceaa35 | ||
|
|
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]
|
||||
|
||||
@@ -368,21 +368,31 @@ pub fn rename_note(
|
||||
.unwrap_or_default();
|
||||
let old_title = super::extract_title(&content, &old_filename);
|
||||
|
||||
if old_title == new_title {
|
||||
// Check both title and filename: even if the title in content matches,
|
||||
// the filename may still be stale (e.g. "untitled-note.md" after user changed H1).
|
||||
let expected_filename = format!("{}.md", title_to_slug(new_title));
|
||||
let title_unchanged = old_title == new_title;
|
||||
let filename_matches = old_filename == expected_filename;
|
||||
|
||||
if title_unchanged && filename_matches {
|
||||
return Ok(RenameResult {
|
||||
new_path: old_path.to_string(),
|
||||
updated_files: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Update content (H1 + frontmatter title)
|
||||
let updated_content = update_note_title_in_content(&content, new_title);
|
||||
// Update content only if the title actually changed
|
||||
let updated_content = if title_unchanged {
|
||||
content.clone()
|
||||
} else {
|
||||
update_note_title_in_content(&content, new_title)
|
||||
};
|
||||
|
||||
// Compute new path and write file
|
||||
// Compute new path, handling collisions with numeric suffix
|
||||
let parent_dir = old_file
|
||||
.parent()
|
||||
.ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title)));
|
||||
let new_file = unique_dest_path(parent_dir, &expected_filename);
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
fs::write(&new_file, &updated_content)
|
||||
@@ -649,6 +659,80 @@ mod tests {
|
||||
assert!(content.contains("# Renamed Note"));
|
||||
}
|
||||
|
||||
// --- rename-on-save: filename doesn't match title slug ---
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_filename_mismatch_same_title() {
|
||||
// Simulates: user created "Untitled note", changed H1 to "My New Note",
|
||||
// saved content (H1 now correct), but filename is still "untitled-note.md".
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/untitled-note.md",
|
||||
"---\ntitle: My New Note\ntype: Note\n---\n\n# My New Note\n\nContent.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My New Note",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// File should be renamed to match the title slug
|
||||
assert!(
|
||||
result.new_path.ends_with("my-new-note.md"),
|
||||
"expected my-new-note.md, got {}",
|
||||
result.new_path
|
||||
);
|
||||
assert!(!old_path.exists(), "old file should be removed");
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
|
||||
// Content should be preserved (title was already correct)
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(content.contains("# My New Note"));
|
||||
assert!(content.contains("title: My New Note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_collision_appends_suffix() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
// Existing file with the slug we want
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/my-note.md",
|
||||
"---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nExisting.\n",
|
||||
);
|
||||
// File with wrong name that should be renamed to my-note.md
|
||||
create_test_file(
|
||||
vault,
|
||||
"note/untitled-note.md",
|
||||
"---\ntitle: My Note\ntype: Note\n---\n\n# My Note\n\nNew content.\n",
|
||||
);
|
||||
|
||||
let old_path = vault.join("note/untitled-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should get a suffixed name to avoid collision
|
||||
assert!(
|
||||
result.new_path.ends_with("my-note-2.md"),
|
||||
"expected my-note-2.md, got {}",
|
||||
result.new_path
|
||||
);
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
// Original file should be untouched
|
||||
assert!(vault.join("note/my-note.md").exists());
|
||||
}
|
||||
|
||||
// --- move_note_to_type_folder tests ---
|
||||
|
||||
#[test]
|
||||
@@ -826,6 +910,36 @@ mod tests {
|
||||
assert!(other_content.contains("[[Weekly Review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_collision_preserves_both_contents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
let moving_content =
|
||||
"---\ntype: Quarter\n---\n# Migrate newsletter to Beehiiv\n\nImportant content.\n";
|
||||
let existing_content =
|
||||
"---\ntype: Quarter\n---\n# Feedback for Laputa\n\nCompletely different note.\n";
|
||||
create_test_file(vault, "note/my-note.md", moving_content);
|
||||
create_test_file(vault, "quarter/my-note.md", existing_content);
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = move_note_to_type_folder(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Quarter",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(result.moved);
|
||||
// Must get a unique path, not the existing file's path
|
||||
assert!(result.new_path.contains("/quarter/my-note-2.md"));
|
||||
// Moved note must retain its own content
|
||||
let moved_content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert_eq!(moved_content, moving_content);
|
||||
// Existing note must be untouched
|
||||
let untouched = fs::read_to_string(vault.join("quarter/my-note.md")).unwrap();
|
||||
assert_eq!(untouched, existing_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_move_note_empty_type_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
28
src/App.tsx
28
src/App.tsx
@@ -17,7 +17,7 @@ import { WelcomeScreen } from './components/WelcomeScreen'
|
||||
import { useMcpStatus } from './hooks/useMcpStatus'
|
||||
import { useVaultLoader } from './hooks/useVaultLoader'
|
||||
import { useSettings } from './hooks/useSettings'
|
||||
import { useNoteActions } from './hooks/useNoteActions'
|
||||
import { useNoteActions, needsRenameOnSave } from './hooks/useNoteActions'
|
||||
import { useCommitFlow } from './hooks/useCommitFlow'
|
||||
import { useViewMode } from './hooks/useViewMode'
|
||||
import { useEntryActions } from './hooks/useEntryActions'
|
||||
@@ -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])
|
||||
|
||||
@@ -352,14 +358,25 @@ function App() {
|
||||
}
|
||||
}, [resolvedPath, vault.entries, notes, dialogs])
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, resolvedPath, vault, savePendingForPath])
|
||||
|
||||
// Wrap handleSave to also persist unsaved notes that have no pending edits (user pressed Cmd+S without typing)
|
||||
// and trigger file rename when the title slug doesn't match the filename.
|
||||
const handleSave = useCallback(async () => {
|
||||
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
||||
const fallback = activeTab && vault.unsavedPaths.has(activeTab.entry.path)
|
||||
? { path: activeTab.entry.path, content: activeTab.content }
|
||||
: undefined
|
||||
await handleSaveRaw(fallback)
|
||||
}, [handleSaveRaw, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
// After saving, check if filename needs to match the current title
|
||||
if (activeTab && needsRenameOnSave(activeTab.entry.title, activeTab.entry.filename)) {
|
||||
await handleRenameTab(activeTab.entry.path, activeTab.entry.title)
|
||||
}
|
||||
}, [handleSaveRaw, handleRenameTab, notes.tabs, notes.activeTabPath, vault.unsavedPaths])
|
||||
|
||||
const commitFlow = useCommitFlow({ savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage })
|
||||
|
||||
@@ -391,11 +408,6 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleRenameTab = useCallback(async (path: string, newTitle: string) => {
|
||||
await savePendingForPath(path)
|
||||
await notes.handleRenameNote(path, newTitle, resolvedPath, vault.replaceEntry).then(vault.loadModifiedFiles)
|
||||
}, [notes, resolvedPath, vault, savePendingForPath])
|
||||
|
||||
/** H1→title sync: update VaultEntry.title and tab entry in memory. */
|
||||
const handleTitleSync = useCallback((path: string, newTitle: string) => {
|
||||
vault.updateEntry(path, { title: newTitle })
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { uploadImageFile, useImageDrop } from './useImageDrop'
|
||||
import { createRef } from 'react'
|
||||
|
||||
let tauriMode = false
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: () => tauriMode,
|
||||
}))
|
||||
|
||||
type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } }
|
||||
type DragDropCallback = (event: DragDropEvent) => void
|
||||
let capturedDragDropHandler: DragDropCallback | null = null
|
||||
|
||||
vi.mock('@tauri-apps/api/webview', () => ({
|
||||
getCurrentWebview: () => ({
|
||||
onDragDropEvent: vi.fn((cb: DragDropCallback) => {
|
||||
capturedDragDropHandler = cb
|
||||
return Promise.resolve(() => { capturedDragDropHandler = null })
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
// JSDOM lacks DragEvent and File.arrayBuffer — polyfill for tests
|
||||
@@ -68,10 +83,7 @@ describe('uploadImageFile', () => {
|
||||
})
|
||||
|
||||
it('passes file to Tauri save_image in Tauri mode', async () => {
|
||||
const mockTauri = await import('../mock-tauri')
|
||||
const originalIsTauri = mockTauri.isTauri
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(mockTauri as any).isTauri = () => true
|
||||
tauriMode = true
|
||||
|
||||
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-test.png')
|
||||
@@ -88,8 +100,7 @@ describe('uploadImageFile', () => {
|
||||
})
|
||||
expect(url).toBe('asset://localhost/vault/attachments/123-test.png')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
;(mockTauri as any).isTauri = originalIsTauri
|
||||
tauriMode = false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -168,3 +179,75 @@ describe('useImageDrop', () => {
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useImageDrop — Tauri native drag-drop', () => {
|
||||
let container: HTMLDivElement
|
||||
|
||||
beforeEach(() => {
|
||||
tauriMode = true
|
||||
capturedDragDropHandler = null
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
tauriMode = false
|
||||
capturedDragDropHandler = null
|
||||
container.remove()
|
||||
})
|
||||
|
||||
function renderImageDropTauri(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) {
|
||||
const ref = createRef<HTMLDivElement>()
|
||||
Object.defineProperty(ref, 'current', { value: container, writable: true })
|
||||
return renderHook(() => useImageDrop({ containerRef: ref, ...opts }))
|
||||
}
|
||||
|
||||
it('does not set isDragOver on Tauri over event (internal drags are indistinguishable)', async () => {
|
||||
const { result } = renderImageDropTauri()
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({ payload: { type: 'over', paths: [], position: { x: 100, y: 100 } } })
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('resets isDragOver on Tauri drop event', async () => {
|
||||
const onImageUrl = vi.fn()
|
||||
const { result } = renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
// Set isDragOver via HTML5 dragover (simulates real OS file drag)
|
||||
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({
|
||||
payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } },
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('resets isDragOver on Tauri cancel event', async () => {
|
||||
const { result } = renderImageDropTauri()
|
||||
|
||||
await waitFor(() => expect(capturedDragDropHandler).not.toBeNull())
|
||||
|
||||
// Set isDragOver via HTML5 dragover first
|
||||
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
|
||||
act(() => {
|
||||
capturedDragDropHandler!({ payload: { type: 'cancel', paths: [], position: { x: 0, y: 0 } } })
|
||||
})
|
||||
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,8 +109,9 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr
|
||||
unlisten = await getCurrentWebview().onDragDropEvent((event) => {
|
||||
const payload = event.payload
|
||||
if (payload.type === 'over') {
|
||||
// Tauri 'over' events don't include paths — show overlay for any drag
|
||||
setIsDragOver(true)
|
||||
// Tauri 'over' events don't include paths and can't distinguish
|
||||
// OS file drags from internal drags (tabs, blocks). Let the HTML5
|
||||
// dragover handler drive isDragOver — it checks hasImageFiles().
|
||||
} else if (payload.type === 'drop') {
|
||||
setIsDragOver(false)
|
||||
const imagePaths = payload.paths.filter(isImagePath)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
slugify,
|
||||
needsRenameOnSave,
|
||||
buildNewEntry,
|
||||
generateUntitledName,
|
||||
entryMatchesTarget,
|
||||
@@ -77,13 +78,37 @@ 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('needsRenameOnSave', () => {
|
||||
it('returns true when filename does not match title slug', () => {
|
||||
expect(needsRenameOnSave('My New Note', 'untitled-note.md')).toBe(true)
|
||||
expect(needsRenameOnSave('Run good ads for newsletter', 'untitled-note-9.md')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when filename matches title slug', () => {
|
||||
expect(needsRenameOnSave('My Note', 'my-note.md')).toBe(false)
|
||||
expect(needsRenameOnSave('Hello World', 'hello-world.md')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for untitled note with matching slug', () => {
|
||||
expect(needsRenameOnSave('Untitled note', 'untitled-note.md')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewEntry', () => {
|
||||
@@ -270,6 +295,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 +651,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])))
|
||||
@@ -938,6 +1005,42 @@ describe('useNoteActions hook', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('preserves note content after type change — never loads another note (regression)', async () => {
|
||||
// The mock updateMockFrontmatter returns '---\nupdated: true\n---\n' —
|
||||
// this represents the note's own content after the frontmatter update.
|
||||
const frontmatterUpdatedContent = '---\nupdated: true\n---\n'
|
||||
const wrongContent = '---\ntype: Project\n---\n# Feedback for Laputa\n\nCompletely different note.\n'
|
||||
|
||||
const entry = makeEntry({ path: '/test/vault/note/migrate-newsletter.md', filename: 'migrate-newsletter.md', title: 'Migrate newsletter to Beehiiv', isA: 'Note' })
|
||||
const replaceEntry = vi.fn()
|
||||
const config = makeConfig([entry])
|
||||
config.replaceEntry = replaceEntry
|
||||
|
||||
vi.mocked(mockInvoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'move_note_to_type_folder') return { new_path: '/test/vault/project/migrate-newsletter.md', updated_links: 0, moved: true }
|
||||
// Simulate the bug: get_note_content returns a DIFFERENT note's content
|
||||
// (e.g. path collision, stale cache, or filesystem race)
|
||||
if (cmd === 'get_note_content') return wrongContent
|
||||
return ''
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useNoteActions(config))
|
||||
|
||||
// Open the tab first so we have a tab to check
|
||||
act(() => { result.current.openTabWithContent(entry, '---\ntype: Note\n---\n# Migrate\n') })
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateFrontmatter('/test/vault/note/migrate-newsletter.md', 'type', 'Project')
|
||||
})
|
||||
|
||||
// The tab content must be the note's OWN content (from the frontmatter update),
|
||||
// NEVER the content of a different note loaded via get_note_content.
|
||||
const tab = result.current.tabs.find(t => t.entry.path === '/test/vault/project/migrate-newsletter.md')
|
||||
expect(tab).toBeDefined()
|
||||
expect(tab!.content).toBe(frontmatterUpdatedContent)
|
||||
expect(tab!.content).not.toBe(wrongContent)
|
||||
})
|
||||
|
||||
it('does not move when value is empty or null-like', async () => {
|
||||
const config = makeConfig()
|
||||
vi.mocked(mockInvoke).mockResolvedValue('')
|
||||
|
||||
@@ -100,7 +100,13 @@ 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'
|
||||
}
|
||||
|
||||
/** Check if a note's filename doesn't match the slug of its current title. */
|
||||
export function needsRenameOnSave(title: string, filename: string): boolean {
|
||||
return `${slugify(title)}.md` !== filename
|
||||
}
|
||||
|
||||
/** Generate a unique "Untitled <type>" name by checking existing entries and pending names. */
|
||||
@@ -392,17 +398,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. */
|
||||
@@ -470,16 +481,19 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
try {
|
||||
const result = await performMoveToTypeFolder(config.vaultPath, path, value)
|
||||
if (result.moved) {
|
||||
const entry = entries.find(e => e.path === path)
|
||||
if (entry) {
|
||||
const newFilename = result.new_path.split('/').pop() ?? entry.filename
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
config.replaceEntry?.(path, { ...entry, path: result.new_path, filename: newFilename })
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: newContent }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
}
|
||||
const newFilename = result.new_path.split('/').pop() ?? ''
|
||||
// Update the vault entry with the new path. Only pass the changed
|
||||
// fields — avoid spreading a stale closure entry which would revert
|
||||
// the isA update that runFrontmatterOp already applied.
|
||||
config.replaceEntry?.(path, { path: result.new_path, filename: newFilename } as Partial<VaultEntry> & { path: string })
|
||||
// Preserve the tab content already set by runFrontmatterOp.
|
||||
// Re-reading from disk via loadNoteContent is unnecessary (the move
|
||||
// does not change content) and dangerous: if the path collides or a
|
||||
// stale cache intervenes it could return a different note's content.
|
||||
setTabs(prev => prev.map(t => t.entry.path === path
|
||||
? { entry: { ...t.entry, path: result.new_path, filename: newFilename }, content: t.content }
|
||||
: t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
const folder = result.new_path.split('/').slice(-2, -1)[0] ?? ''
|
||||
setToastMessage(`Note moved to ${folder}/`)
|
||||
}
|
||||
@@ -487,7 +501,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
console.error('Failed to move note to type folder:', err)
|
||||
}
|
||||
}
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]),
|
||||
}, [runFrontmatterOp, config.vaultPath, config.replaceEntry, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleRenameNote,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +227,16 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
if (currentFolder === slug) return { new_path: args.note_path, updated_links: 0, moved: false }
|
||||
const filename = args.note_path.split('/').pop() ?? ''
|
||||
const vaultPath = args.vault_path.replace(/\/$/, '')
|
||||
const newPath = `${vaultPath}/${slug}/${filename}`
|
||||
// Handle collisions: append -2, -3, etc. if the target path already exists
|
||||
// (mirrors the Rust unique_dest_path logic).
|
||||
let newPath = `${vaultPath}/${slug}/${filename}`
|
||||
if (newPath in MOCK_CONTENT && newPath !== args.note_path) {
|
||||
const stem = filename.replace(/\.md$/, '')
|
||||
const ext = filename.endsWith('.md') ? '.md' : ''
|
||||
let counter = 2
|
||||
while (`${vaultPath}/${slug}/${stem}-${counter}${ext}` in MOCK_CONTENT) counter++
|
||||
newPath = `${vaultPath}/${slug}/${stem}-${counter}${ext}`
|
||||
}
|
||||
const content = MOCK_CONTENT[args.note_path] ?? ''
|
||||
delete MOCK_CONTENT[args.note_path]
|
||||
MOCK_CONTENT[newPath] = content
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
112
tests/smoke/changing-type-data-corruption.spec.ts
Normal file
112
tests/smoke/changing-type-data-corruption.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Changing note type preserves content (data corruption fix)', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('type change does not load a different note into the editor', async ({ page }) => {
|
||||
// 1. Click the first note in the note list to open it
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
const firstNote = noteListContainer.locator('.cursor-pointer').first()
|
||||
await firstNote.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 2. Get the editor's H1 heading text before the type change.
|
||||
// The note's title is shown as an H1 in the BlockNote editor.
|
||||
const editorContainer = page.locator('.bn-editor')
|
||||
await expect(editorContainer).toBeVisible({ timeout: 5000 })
|
||||
const headingBefore = await editorContainer.locator('h1').first().textContent()
|
||||
expect(headingBefore).toBeTruthy()
|
||||
|
||||
// 3. The type selector should be visible in the inspector
|
||||
const typeSelector = page.locator('[data-testid="type-selector"]')
|
||||
await expect(typeSelector).toBeVisible({ timeout: 5000 })
|
||||
const selectTrigger = typeSelector.locator('button[role="combobox"]')
|
||||
const currentType = (await selectTrigger.textContent())?.trim() ?? ''
|
||||
|
||||
// 4. Change the type to something different
|
||||
const targetType = currentType === 'Project' ? 'Experiment' : 'Project'
|
||||
await selectTrigger.click()
|
||||
await page.waitForTimeout(300)
|
||||
const option = page.getByRole('option', { name: targetType, exact: true })
|
||||
await expect(option).toBeVisible({ timeout: 3000 })
|
||||
await option.click()
|
||||
|
||||
// 5. Wait for the move to complete (toast confirms it)
|
||||
const toastSlug = targetType.toLowerCase()
|
||||
const toast = page.getByText(`Note moved to ${toastSlug}/`)
|
||||
await expect(toast).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// 6. CRITICAL: verify the editor still shows the SAME note's heading.
|
||||
// The data-corruption bug would replace this with another note's content.
|
||||
await page.waitForTimeout(300)
|
||||
const headingAfter = await editorContainer.locator('h1').first().textContent()
|
||||
expect(headingAfter).toBe(headingBefore)
|
||||
|
||||
// 7. Restore original type to leave vault clean
|
||||
await page.waitForTimeout(2500)
|
||||
const restoredTrigger = typeSelector.locator('button[role="combobox"]')
|
||||
await restoredTrigger.click()
|
||||
await page.waitForTimeout(300)
|
||||
const restoreOption = page.getByRole('option', { name: currentType, exact: true })
|
||||
if (await restoreOption.isVisible()) {
|
||||
await restoreOption.click()
|
||||
await page.waitForTimeout(1000)
|
||||
} else {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
|
||||
test('changing type of existing note preserves its content', async ({ page }) => {
|
||||
// 1. Click the second note in the note list (different note than test 1)
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
const notes = noteListContainer.locator('.cursor-pointer')
|
||||
const noteCount = await notes.count()
|
||||
const noteIndex = noteCount > 1 ? 1 : 0
|
||||
await notes.nth(noteIndex).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// 2. Capture the H1 heading
|
||||
const editorContainer = page.locator('.bn-editor')
|
||||
await expect(editorContainer).toBeVisible({ timeout: 5000 })
|
||||
const headingBefore = await editorContainer.locator('h1').first().textContent()
|
||||
expect(headingBefore).toBeTruthy()
|
||||
|
||||
// 3. Change the type
|
||||
const typeSelector = page.locator('[data-testid="type-selector"]')
|
||||
await expect(typeSelector).toBeVisible({ timeout: 5000 })
|
||||
const selectTrigger = typeSelector.locator('button[role="combobox"]')
|
||||
const currentType = (await selectTrigger.textContent())?.trim() ?? ''
|
||||
const targetType = currentType === 'Experiment' ? 'Person' : 'Experiment'
|
||||
|
||||
await selectTrigger.click()
|
||||
await page.waitForTimeout(300)
|
||||
const option = page.getByRole('option', { name: targetType, exact: true })
|
||||
await expect(option).toBeVisible({ timeout: 3000 })
|
||||
await option.click()
|
||||
|
||||
// 4. Wait for move
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// 5. CRITICAL: the H1 heading must still be the original note's title
|
||||
const headingAfter = await editorContainer.locator('h1').first().textContent()
|
||||
expect(headingAfter).toBe(headingBefore)
|
||||
|
||||
// 6. Restore the original type
|
||||
const restoredTrigger = typeSelector.locator('button[role="combobox"]')
|
||||
await restoredTrigger.click()
|
||||
await page.waitForTimeout(300)
|
||||
const restoreOption = page.getByRole('option', { name: currentType, exact: true })
|
||||
if (await restoreOption.isVisible()) {
|
||||
await restoreOption.click()
|
||||
await page.waitForTimeout(1000)
|
||||
} else {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
})
|
||||
})
|
||||
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([])
|
||||
})
|
||||
})
|
||||
100
tests/smoke/fix-note-filename-on-rename.spec.ts
Normal file
100
tests/smoke/fix-note-filename-on-rename.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
/** Known BlockNote/ProseMirror error that fires during editor re-mount after rename. */
|
||||
const KNOWN_EDITOR_ERRORS = ['isConnected']
|
||||
|
||||
function isKnownEditorError(msg: string): boolean {
|
||||
return KNOWN_EDITOR_ERRORS.some(k => msg.includes(k))
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: create a new note, select the existing H1 heading text,
|
||||
* replace it with a new title, then wait for the 500 ms title-sync debounce.
|
||||
*/
|
||||
async function createNoteWithTitle(page: import('@playwright/test').Page, title: string) {
|
||||
// 1. Cmd+N → new "Untitled note" (creates heading with "Untitled note")
|
||||
await page.locator('body').click()
|
||||
await sendShortcut(page, 'n', ['Control'])
|
||||
await expect(page.getByText(/Untitled note/).first()).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// 2. Wait for the heading to render in BlockNote
|
||||
const heading = page.locator('[data-content-type="heading"] h1')
|
||||
await heading.waitFor({ timeout: 3000 })
|
||||
|
||||
// 3. Triple-click the heading to select all its text, then type replacement
|
||||
await heading.click({ clickCount: 3 })
|
||||
await page.keyboard.type(title, { delay: 20 })
|
||||
|
||||
// 4. Wait for the 500 ms useHeadingTitleSync debounce to fire + React re-render
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
|
||||
test.describe('Note filename updates on title change + save', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Wait for startup toast ("Laputa registered as MCP tool") to dismiss
|
||||
await page.waitForTimeout(2500)
|
||||
})
|
||||
|
||||
test('Cmd+N creates untitled note, typing new title + Cmd+S renames file', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
|
||||
|
||||
await createNoteWithTitle(page, 'Test Note ABC')
|
||||
|
||||
// Breadcrumb should already show the new title (title sync fired)
|
||||
const breadcrumb = page.locator('span.truncate.font-medium')
|
||||
await expect(breadcrumb.first()).toContainText('Test Note ABC', { timeout: 2000 })
|
||||
|
||||
// 5. Cmd+S → save + rename
|
||||
await sendShortcut(page, 's', ['Control'])
|
||||
|
||||
// 6. Toast should show "Renamed" (appears within 5s, auto-dismisses after 2s)
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('Renamed', { timeout: 5000 })
|
||||
|
||||
// 7. No unexpected JS errors
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('saving a note whose filename already matches does not trigger rename', async ({ page }) => {
|
||||
// Click on an existing note in the note list that already has a matching filename.
|
||||
// The default sidebar shows "Notes" section with several notes visible.
|
||||
const noteItem = page.locator('.truncate.text-foreground.font-medium').filter({ hasText: /Refactoring/ }).first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Cmd+S — should show "Saved" or "Nothing to save", NOT "Renamed"
|
||||
await sendShortcut(page, 's', ['Control'])
|
||||
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toBeVisible({ timeout: 3000 })
|
||||
const toastText = await toast.textContent()
|
||||
expect(toastText).not.toContain('Renamed')
|
||||
})
|
||||
|
||||
test('rapid title changes only rename to final title on Cmd+S', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => { if (!isKnownEditorError(err.message)) errors.push(err.message) })
|
||||
|
||||
await createNoteWithTitle(page, 'First Title')
|
||||
|
||||
// Select heading text again and replace with final title
|
||||
const heading = page.locator('[data-content-type="heading"] h1')
|
||||
await heading.click({ clickCount: 3 })
|
||||
await page.keyboard.type('Final Title', { delay: 20 })
|
||||
|
||||
// Wait for debounce again
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// Cmd+S → should rename to final-title.md
|
||||
await sendShortcut(page, 's', ['Control'])
|
||||
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('Renamed', { timeout: 5000 })
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
})
|
||||
114
tests/smoke/image-drop-overlay-fix.spec.ts
Normal file
114
tests/smoke/image-drop-overlay-fix.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
const DROP_OVERLAY = '.editor__drop-overlay'
|
||||
const EDITOR_CONTAINER = '.editor__blocknote-container'
|
||||
|
||||
test.describe('Image drop overlay — internal drag does not trigger overlay', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Open the first note to mount the editor
|
||||
const noteList = page.locator('[data-testid="note-list-container"]')
|
||||
await noteList.waitFor({ timeout: 5_000 })
|
||||
await noteList.locator('.cursor-pointer').first().click()
|
||||
await page.waitForTimeout(300)
|
||||
await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 })
|
||||
})
|
||||
|
||||
test('internal drag (no image files) does not show the overlay', async ({ page }) => {
|
||||
// Simulate an internal drag (e.g. block or tab) — dataTransfer has no files
|
||||
await page.locator(EDITOR_CONTAINER).first().dispatchEvent('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
|
||||
// The overlay should NOT appear for non-file drags
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('dragover with image file shows the overlay', async ({ page }) => {
|
||||
// Simulate an OS file drag with an image file in dataTransfer
|
||||
// Playwright dispatchEvent can't set dataTransfer items directly,
|
||||
// so we use page.evaluate to dispatch a proper DragEvent with file items
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
|
||||
const event = new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
el.dispatchEvent(event)
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
await expect(page.locator(DROP_OVERLAY)).toContainText('Drop image here')
|
||||
})
|
||||
|
||||
test('dragleave after image dragover hides the overlay', async ({ page }) => {
|
||||
// First show the overlay via image dragover
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
|
||||
// Now simulate dragleave (cursor left the container)
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
el.dispatchEvent(new DragEvent('dragleave', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
relatedTarget: document.body,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('drop resets the overlay', async ({ page }) => {
|
||||
// Show overlay via image dragover
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
const dt = new DataTransfer()
|
||||
dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' }))
|
||||
el.dispatchEvent(new DragEvent('dragover', {
|
||||
dataTransfer: dt,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).toBeVisible()
|
||||
|
||||
// Simulate drop
|
||||
await page.evaluate((selector) => {
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) throw new Error('Editor container not found')
|
||||
|
||||
el.dispatchEvent(new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}))
|
||||
}, EDITOR_CONTAINER)
|
||||
|
||||
await expect(page.locator(DROP_OVERLAY)).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
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