fix: harden untitled h1 auto-rename flow
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -280,7 +280,7 @@ function App() {
|
||||
})
|
||||
|
||||
const appSave = useAppSave({
|
||||
updateEntry: vault.updateEntry, setTabs: notes.setTabs, setToastMessage,
|
||||
updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage,
|
||||
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: vault.reloadViews,
|
||||
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
|
||||
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import type { SetStateAction } from 'react'
|
||||
import { useAppSave } from './useAppSave'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
@@ -22,6 +23,7 @@ describe('useAppSave', () => {
|
||||
const deps = {
|
||||
updateEntry: vi.fn(),
|
||||
setTabs: vi.fn(),
|
||||
handleSwitchTab: vi.fn(),
|
||||
setToastMessage: vi.fn(),
|
||||
loadModifiedFiles: vi.fn(),
|
||||
clearUnsaved: vi.fn(),
|
||||
@@ -147,6 +149,44 @@ describe('useAppSave', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('switches the active tab to the renamed path after untitled H1 auto-rename', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
const oldPath = '/vault/untitled-note-123.md'
|
||||
const newPath = '/vault/fresh-title.md'
|
||||
const entry = makeEntry(oldPath, 'Untitled Note 123', 'untitled-note-123.md')
|
||||
let tabsState = [{ entry, content: '# Fresh Title\n\nBody' }]
|
||||
const setTabs = vi.fn((updater: SetStateAction<typeof tabsState>) => {
|
||||
tabsState = typeof updater === 'function' ? updater(tabsState) : updater
|
||||
})
|
||||
|
||||
vi.mocked(invoke).mockImplementation(async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'save_note_content') return undefined
|
||||
if (command === 'auto_rename_untitled') return { new_path: newPath, updated_files: 0 }
|
||||
if (command === 'reload_vault_entry') return makeEntry(newPath, 'Fresh Title', 'fresh-title.md')
|
||||
if (command === 'get_note_content' && args?.path === newPath) return '# Fresh Title\n\nBody'
|
||||
return undefined
|
||||
})
|
||||
|
||||
const { result } = renderSave({
|
||||
setTabs,
|
||||
tabs: tabsState,
|
||||
activeTabPath: oldPath,
|
||||
unsavedPaths: new Set([oldPath]),
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleContentChange(oldPath, '# Fresh Title\n\nBody')
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
})
|
||||
|
||||
expect(deps.handleSwitchTab).toHaveBeenCalledWith(newPath)
|
||||
expect(tabsState[0].entry.path).toBe(newPath)
|
||||
expect(tabsState[0].entry.filename).toBe('fresh-title.md')
|
||||
expect(tabsState[0].content).toBe('# Fresh Title\n\nBody')
|
||||
})
|
||||
|
||||
it('cancels a pending untitled auto-rename when the user navigates away', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
@@ -92,6 +92,10 @@ function pendingRenameOutsideActiveTab(
|
||||
async function reloadAutoRenamedNote(
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
tabs: TabState[],
|
||||
activeTabPath: string | null,
|
||||
setTabs: AppSaveDeps['setTabs'],
|
||||
handleSwitchTab: AppSaveDeps['handleSwitchTab'],
|
||||
replaceEntry: AppSaveDeps['replaceEntry'],
|
||||
loadModifiedFiles: AppSaveDeps['loadModifiedFiles'],
|
||||
): Promise<void> {
|
||||
@@ -99,13 +103,31 @@ async function reloadAutoRenamedNote(
|
||||
invoke<VaultEntry>('reload_vault_entry', { path: newPath }),
|
||||
invoke<string>('get_note_content', { path: newPath }),
|
||||
])
|
||||
|
||||
const otherTabPaths = tabs
|
||||
.filter((tab) => tab.entry.path !== oldPath && tab.entry.path !== newPath)
|
||||
.map((tab) => tab.entry.path)
|
||||
|
||||
setTabs((prev: TabState[]) => prev.map((tab) => (
|
||||
tab.entry.path === oldPath
|
||||
? { entry: { ...tab.entry, ...newEntry, path: newPath }, content: newContent }
|
||||
: tab
|
||||
)))
|
||||
if (activeTabPath === oldPath) handleSwitchTab(newPath)
|
||||
replaceEntry(oldPath, { ...newEntry, path: newPath }, newContent)
|
||||
await Promise.all(otherTabPaths.map(async (path) => {
|
||||
const content = await invoke<string>('get_note_content', { path })
|
||||
setTabs((prev: TabState[]) => prev.map((tab) => (
|
||||
tab.entry.path === path ? { ...tab, content } : tab
|
||||
)))
|
||||
}))
|
||||
loadModifiedFiles()
|
||||
}
|
||||
|
||||
interface AppSaveDeps {
|
||||
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
|
||||
setTabs: Parameters<typeof useEditorSaveWithLinks>[0]['setTabs']
|
||||
handleSwitchTab: (path: string) => void
|
||||
setToastMessage: (msg: string | null) => void
|
||||
loadModifiedFiles: () => void
|
||||
reloadViews?: () => Promise<void>
|
||||
@@ -119,7 +141,7 @@ interface AppSaveDeps {
|
||||
}
|
||||
|
||||
export function useAppSave({
|
||||
updateEntry, setTabs, setToastMessage,
|
||||
updateEntry, setTabs, handleSwitchTab, setToastMessage,
|
||||
loadModifiedFiles, reloadViews, clearUnsaved, unsavedPaths,
|
||||
tabs, activeTabPath,
|
||||
handleRenameNote, replaceEntry, resolvedPath,
|
||||
@@ -142,12 +164,21 @@ export function useAppSave({
|
||||
notePath: path,
|
||||
})
|
||||
if (!result) return false
|
||||
await reloadAutoRenamedNote(path, result.new_path, replaceEntry, loadModifiedFiles)
|
||||
await reloadAutoRenamedNote(
|
||||
path,
|
||||
result.new_path,
|
||||
tabs,
|
||||
activeTabPath,
|
||||
setTabs,
|
||||
handleSwitchTab,
|
||||
replaceEntry,
|
||||
loadModifiedFiles,
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [resolvedPath, replaceEntry, loadModifiedFiles])
|
||||
}, [resolvedPath, tabs, activeTabPath, setTabs, handleSwitchTab, replaceEntry, loadModifiedFiles])
|
||||
|
||||
const flushPendingUntitledRename = useCallback(async (path?: string) => {
|
||||
const pending = takePendingRename(pendingUntitledRenameRef, path)
|
||||
|
||||
@@ -48,6 +48,41 @@ describe('useEditorFocus', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waits for the matching tab swap event when a target path is provided', () => {
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
vi.spyOn(window, 'setTimeout')
|
||||
const { editor } = setup(true)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path: '/vault/new-note.md' } }))
|
||||
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
expect(rAF).not.toHaveBeenCalled()
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path: '/vault/other.md' } }))
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', { detail: { path: '/vault/new-note.md' } }))
|
||||
expect(rAF).toHaveBeenCalled()
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to focusing when the swap event never arrives', () => {
|
||||
vi.useFakeTimers()
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const { editor } = setup(true)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { path: '/vault/new-note.md' } }))
|
||||
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(249)
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(rAF).toHaveBeenCalled()
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cleans up event listener on unmount', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn() } as any
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const TAB_SWAP_EVENT_NAME = 'laputa:editor-tab-swapped'
|
||||
const FOCUS_EVENT_NAME = 'laputa:focus-editor'
|
||||
const SWAP_WAIT_FALLBACK_MS = 250
|
||||
|
||||
interface TiptapChain {
|
||||
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
||||
run: () => void
|
||||
@@ -42,10 +46,13 @@ export function useEditorFocus(
|
||||
editorMountedRef: React.RefObject<boolean>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const pendingCleanups = new Set<() => void>()
|
||||
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean } | undefined
|
||||
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean; path?: string | null } | undefined
|
||||
const t0 = detail?.t0
|
||||
const selectTitle = detail?.selectTitle ?? false
|
||||
const targetPath = detail?.path ?? null
|
||||
const doFocus = () => {
|
||||
editor.focus()
|
||||
if (!selectTitle) {
|
||||
@@ -63,13 +70,47 @@ export function useEditorFocus(
|
||||
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
})
|
||||
}
|
||||
if (editorMountedRef.current) {
|
||||
requestAnimationFrame(doFocus)
|
||||
} else {
|
||||
|
||||
const scheduleFocus = () => {
|
||||
if (editorMountedRef.current) {
|
||||
requestAnimationFrame(doFocus)
|
||||
return
|
||||
}
|
||||
setTimeout(doFocus, 80)
|
||||
}
|
||||
|
||||
if (!targetPath) {
|
||||
scheduleFocus()
|
||||
return
|
||||
}
|
||||
|
||||
const handleTabSwap = (event: Event) => {
|
||||
const swapPath = (event as CustomEvent).detail?.path
|
||||
if (swapPath !== targetPath) return
|
||||
cleanupPending()
|
||||
scheduleFocus()
|
||||
}
|
||||
|
||||
const fallbackTimer = window.setTimeout(() => {
|
||||
cleanupPending()
|
||||
scheduleFocus()
|
||||
}, SWAP_WAIT_FALLBACK_MS)
|
||||
|
||||
const cleanupPending = () => {
|
||||
window.clearTimeout(fallbackTimer)
|
||||
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
|
||||
pendingCleanups.delete(cleanupPending)
|
||||
}
|
||||
|
||||
pendingCleanups.add(cleanupPending)
|
||||
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
|
||||
}
|
||||
|
||||
window.addEventListener(FOCUS_EVENT_NAME, handler)
|
||||
return () => {
|
||||
window.removeEventListener(FOCUS_EVENT_NAME, handler)
|
||||
pendingCleanups.forEach((cleanup) => cleanup())
|
||||
pendingCleanups.clear()
|
||||
}
|
||||
window.addEventListener('laputa:focus-editor', handler)
|
||||
return () => window.removeEventListener('laputa:focus-editor', handler)
|
||||
}, [editor, editorMountedRef])
|
||||
}
|
||||
|
||||
@@ -167,6 +167,13 @@ function makeTab(path: string, title: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeUntitledTab(path: string, title = 'Untitled Note 1') {
|
||||
return {
|
||||
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
|
||||
content: '---\ntype: Note\nstatus: Active\n---\n',
|
||||
}
|
||||
}
|
||||
|
||||
function makeMockEditor(docRef: { current: unknown[] }) {
|
||||
return {
|
||||
document: docRef.current,
|
||||
@@ -220,6 +227,69 @@ describe('useEditorTabSwap raw mode sync', () => {
|
||||
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('signals when the target tab content has been applied', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const swapListener = vi.fn()
|
||||
window.addEventListener('laputa:editor-tab-swapped', swapListener)
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const tabA = makeTab('a.md', 'Note A')
|
||||
const tabB = makeTab('b.md', 'March 2024')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
swapListener.mockClear()
|
||||
|
||||
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(swapListener).toHaveBeenCalledTimes(1)
|
||||
const event = swapListener.mock.calls[0][0] as CustomEvent
|
||||
expect(event.detail.path).toBe('b.md')
|
||||
|
||||
window.removeEventListener('laputa:editor-tab-swapped', swapListener)
|
||||
})
|
||||
|
||||
it('hard-resets the editor when the target note body is blank', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
const docRef = { current: blocksA as unknown[] }
|
||||
const mockEditor = makeMockEditor(docRef)
|
||||
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
|
||||
|
||||
const populatedTab = makeTab('a.md', 'Note A')
|
||||
const untitledTab = makeUntitledTab('untitled.md')
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
|
||||
tabs, activeTabPath, editor: mockEditor as never, rawMode,
|
||||
}),
|
||||
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
|
||||
)
|
||||
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
mockEditor._tiptapEditor.commands.setContent.mockClear()
|
||||
mockEditor.replaceBlocks.mockClear()
|
||||
|
||||
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
|
||||
await act(() => new Promise(r => setTimeout(r, 0)))
|
||||
|
||||
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<p></p>')
|
||||
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
|
||||
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
|
||||
@@ -22,6 +22,12 @@ interface UseEditorTabSwapOptions {
|
||||
rawMode?: boolean
|
||||
}
|
||||
|
||||
function signalEditorTabSwapped(path: string): void {
|
||||
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
|
||||
detail: { path },
|
||||
}))
|
||||
}
|
||||
|
||||
/** Strip the YAML frontmatter from raw file content, returning the body
|
||||
* (including any H1 heading) that should appear in the editor. */
|
||||
export function extractEditorBody(rawFileContent: string): string {
|
||||
@@ -83,6 +89,14 @@ function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
|
||||
]
|
||||
}
|
||||
|
||||
function isBlankBodyContent(content: string): boolean {
|
||||
return extractEditorBody(content).trim() === ''
|
||||
}
|
||||
|
||||
function blankParagraphBlocks(): EditorBlocks {
|
||||
return [{ type: 'paragraph', content: [], children: [] }]
|
||||
}
|
||||
|
||||
async function parseMarkdownBlocks(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
preprocessed: string,
|
||||
@@ -153,6 +167,26 @@ function applyBlocksToEditor(
|
||||
})
|
||||
}
|
||||
|
||||
function applyBlankStateToEditor(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
suppressChangeRef: MutableRefObject<boolean>,
|
||||
) {
|
||||
suppressChangeRef.current = true
|
||||
try {
|
||||
editor._tiptapEditor.commands.setContent('<p></p>')
|
||||
} catch (err) {
|
||||
console.error('applyBlankStateToEditor failed, falling back to replaceBlocks:', err)
|
||||
applyBlocksToEditor(editor, blankParagraphBlocks(), 0, suppressChangeRef)
|
||||
return
|
||||
}
|
||||
|
||||
queueMicrotask(() => { suppressChangeRef.current = false })
|
||||
requestAnimationFrame(() => {
|
||||
const scrollEl = document.querySelector('.editor__blocknote-container')
|
||||
if (scrollEl) scrollEl.scrollTop = 0
|
||||
})
|
||||
}
|
||||
|
||||
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
|
||||
return activeTabPath
|
||||
? tabs.find(tab => tab.entry.path === activeTabPath)
|
||||
@@ -319,10 +353,19 @@ function scheduleTabSwap(options: {
|
||||
const doSwap = () => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
rawSwapPendingRef.current = false
|
||||
|
||||
if (isBlankBodyContent(activeTab.content)) {
|
||||
cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 })
|
||||
applyBlankStateToEditor(editor, suppressChangeRef)
|
||||
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
|
||||
return
|
||||
}
|
||||
|
||||
void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content)
|
||||
.then(({ blocks, scrollTop }) => {
|
||||
if (prevActivePathRef.current !== targetPath) return
|
||||
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
|
||||
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('Failed to parse/swap editor content:', err)
|
||||
|
||||
@@ -342,6 +342,20 @@ describe('useNoteCreation hook', () => {
|
||||
expect(markContentPending).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleCreateNoteImmediate requests editor focus for the new path', () => {
|
||||
const focusListener = vi.fn()
|
||||
window.addEventListener('laputa:focus-editor', focusListener)
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
|
||||
act(() => { result.current.handleCreateNoteImmediate() })
|
||||
|
||||
expect(focusListener).toHaveBeenCalledTimes(1)
|
||||
const event = focusListener.mock.calls[0][0] as CustomEvent
|
||||
expect(event.detail.path).toMatch(/\/test\/vault\/untitled-note-\d+\.md$/)
|
||||
|
||||
window.removeEventListener('laputa:focus-editor', focusListener)
|
||||
})
|
||||
|
||||
it('handleOpenDailyNote creates new daily note when none exists', () => {
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
act(() => { result.current.handleOpenDailyNote() })
|
||||
@@ -358,6 +372,21 @@ describe('useNoteCreation hook', () => {
|
||||
expect(handleSelectNote).toHaveBeenCalledWith(existing)
|
||||
})
|
||||
|
||||
it('handleOpenDailyNote requests focus for the daily note path', () => {
|
||||
const focusListener = vi.fn()
|
||||
window.addEventListener('laputa:focus-editor', focusListener)
|
||||
const today = todayDateString()
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
|
||||
act(() => { result.current.handleOpenDailyNote() })
|
||||
|
||||
expect(focusListener).toHaveBeenCalledTimes(1)
|
||||
const event = focusListener.mock.calls[0][0] as CustomEvent
|
||||
expect(event.detail.path).toBe(`/test/vault/${today}.md`)
|
||||
|
||||
window.removeEventListener('laputa:focus-editor', focusListener)
|
||||
})
|
||||
|
||||
it('handleCreateType creates type entry', () => {
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig(), tabDeps))
|
||||
act(() => { result.current.handleCreateType('Recipe') })
|
||||
|
||||
@@ -161,9 +161,9 @@ function addEntryWithMock(entry: VaultEntry, content: string, addEntry: (e: Vaul
|
||||
}
|
||||
|
||||
/** Dispatch focus-editor event with perf timing marker. */
|
||||
function signalFocusEditor(opts?: { selectTitle?: boolean }): void {
|
||||
function signalFocusEditor(opts?: { selectTitle?: boolean; path?: string }): void {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', {
|
||||
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false },
|
||||
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false, path: opts?.path ?? null },
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -200,9 +200,10 @@ function createAndPersist(
|
||||
function openDailyNote(entries: VaultEntry[], selectNote: (e: VaultEntry) => void, persist: PersistFn, vaultPath: string): void {
|
||||
const date = todayDateString()
|
||||
const existing = findDailyNote(entries, date)
|
||||
const targetPath = existing?.path ?? `${vaultPath}/${date}.md`
|
||||
if (existing) selectNote(existing)
|
||||
else persist(resolveDailyNote(date, vaultPath))
|
||||
signalFocusEditor()
|
||||
signalFocusEditor({ path: targetPath })
|
||||
}
|
||||
|
||||
interface ImmediateCreateDeps {
|
||||
@@ -250,7 +251,7 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
|
||||
addEntryWithMock(entry, content, deps.addEntry)
|
||||
deps.trackUnsaved?.(entry.path)
|
||||
deps.markContentPending?.(entry.path, content)
|
||||
signalFocusEditor()
|
||||
signalFocusEditor({ path: entry.path })
|
||||
}
|
||||
|
||||
interface RelationshipCreateDeps {
|
||||
|
||||
@@ -112,3 +112,152 @@ export async function openFixtureVault(
|
||||
timeout: FIXTURE_VAULT_READY_TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
export async function openFixtureVaultTauri(
|
||||
page: Page,
|
||||
vaultPath: string,
|
||||
): Promise<void> {
|
||||
await openFixtureVault(page, vaultPath)
|
||||
await page.evaluate((resolvedVaultPath: string) => {
|
||||
const jsonHeaders = { 'Content-Type': 'application/json' }
|
||||
const nativeFetch = window.fetch.bind(window)
|
||||
|
||||
const readJson = async (url: string, init?: RequestInit) => {
|
||||
const response = await nativeFetch(url, init)
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`
|
||||
try {
|
||||
const body = await response.json() as { error?: string }
|
||||
message = body.error ?? message
|
||||
} catch {
|
||||
// Keep the HTTP status fallback when the body is not JSON.
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
const invoke = async (command: string, args?: Record<string, unknown>) => {
|
||||
switch (command) {
|
||||
case 'trigger_menu_command': {
|
||||
const commandId = String(args?.id ?? '')
|
||||
const bridge = window.__laputaTest?.dispatchBrowserMenuCommand
|
||||
if (!bridge) throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
|
||||
bridge(commandId)
|
||||
return null
|
||||
}
|
||||
case 'load_vault_list':
|
||||
return {
|
||||
vaults: [{ label: 'Test Vault', path: resolvedVaultPath }],
|
||||
active_vault: resolvedVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
case 'check_vault_exists':
|
||||
case 'is_git_repo':
|
||||
return true
|
||||
case 'get_last_vault_path':
|
||||
case 'get_default_vault_path':
|
||||
return resolvedVaultPath
|
||||
case 'save_vault_list':
|
||||
case 'save_settings':
|
||||
case 'register_mcp_tools':
|
||||
case 'reinit_telemetry':
|
||||
case 'update_menu_state':
|
||||
return null
|
||||
case 'get_settings':
|
||||
return {
|
||||
github_token: null,
|
||||
github_username: null,
|
||||
auto_pull_interval_minutes: 5,
|
||||
telemetry_consent: false,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
}
|
||||
case 'list_vault':
|
||||
case 'reload_vault': {
|
||||
const path = String(args?.path ?? resolvedVaultPath)
|
||||
return readJson(`/api/vault/list?path=${encodeURIComponent(path)}&reload=${command === 'reload_vault' ? '1' : '0'}`)
|
||||
}
|
||||
case 'list_vault_folders':
|
||||
case 'list_views':
|
||||
case 'get_modified_files':
|
||||
case 'detect_renames':
|
||||
return []
|
||||
case 'reload_vault_entry':
|
||||
return readJson(`/api/vault/entry?path=${encodeURIComponent(String(args?.path ?? ''))}`)
|
||||
case 'get_note_content': {
|
||||
const data = await readJson(`/api/vault/content?path=${encodeURIComponent(String(args?.path ?? ''))}`) as { content: string }
|
||||
return data.content
|
||||
}
|
||||
case 'get_all_content':
|
||||
return readJson(`/api/vault/all-content?path=${encodeURIComponent(String(args?.path ?? resolvedVaultPath))}`)
|
||||
case 'save_note_content':
|
||||
return readJson('/api/vault/save', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({ path: args?.path, content: args?.content }),
|
||||
})
|
||||
case 'rename_note':
|
||||
return readJson('/api/vault/rename', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
vault_path: args?.vaultPath ?? resolvedVaultPath,
|
||||
old_path: args?.oldPath,
|
||||
new_title: args?.newTitle,
|
||||
old_title: args?.oldTitle ?? null,
|
||||
}),
|
||||
})
|
||||
case 'rename_note_filename':
|
||||
return readJson('/api/vault/rename-filename', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
vault_path: args?.vaultPath ?? resolvedVaultPath,
|
||||
old_path: args?.oldPath,
|
||||
new_filename_stem: args?.newFilenameStem,
|
||||
}),
|
||||
})
|
||||
case 'search_vault': {
|
||||
const path = String(args?.path ?? args?.vaultPath ?? resolvedVaultPath)
|
||||
const query = encodeURIComponent(String(args?.query ?? ''))
|
||||
const mode = encodeURIComponent(String(args?.mode ?? 'all'))
|
||||
return readJson(`/api/vault/search?vault_path=${encodeURIComponent(path)}&query=${query}&mode=${mode}`)
|
||||
}
|
||||
case 'auto_rename_untitled': {
|
||||
const notePath = String(args?.notePath ?? '')
|
||||
const contentData = await readJson(`/api/vault/content?path=${encodeURIComponent(notePath)}`) as { content: string }
|
||||
const match = contentData.content.match(/^#\s+(.+)$/m)
|
||||
if (!match) return null
|
||||
return readJson('/api/vault/rename', {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders,
|
||||
body: JSON.stringify({
|
||||
vault_path: args?.vaultPath ?? resolvedVaultPath,
|
||||
old_path: notePath,
|
||||
new_title: match[1].trim(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
default: {
|
||||
const handler = window.__mockHandlers?.[command]
|
||||
if (!handler) throw new Error(`Unhandled invoke: ${command}`)
|
||||
return handler(args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, '__TAURI__', {
|
||||
configurable: true,
|
||||
value: {},
|
||||
})
|
||||
Object.defineProperty(window, '__TAURI_INTERNALS__', {
|
||||
configurable: true,
|
||||
value: { invoke },
|
||||
})
|
||||
}, vaultPath)
|
||||
|
||||
await page.waitForFunction(() => Boolean(window.__TAURI_INTERNALS__))
|
||||
}
|
||||
|
||||
110
tests/smoke/h1-untitled-auto-rename.spec.ts
Normal file
110
tests/smoke/h1-untitled-auto-rename.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from 'fs'
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { triggerMenuCommand } from './testBridge'
|
||||
|
||||
const KNOWN_EDITOR_ERRORS = ['isConnected']
|
||||
|
||||
function isKnownEditorError(message: string): boolean {
|
||||
return KNOWN_EDITOR_ERRORS.some((known) => message.includes(known))
|
||||
}
|
||||
|
||||
function markdownFiles(vaultPath: string): string[] {
|
||||
return fs.readdirSync(vaultPath).filter((name) => name.endsWith('.md')).sort()
|
||||
}
|
||||
|
||||
function slugifyTitle(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
}
|
||||
|
||||
async function createUntitledNote(page: Page): Promise<void> {
|
||||
await triggerMenuCommand(page, 'file-new-note')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
async function writeNewHeading(page: Page, title: string): Promise<void> {
|
||||
await page.keyboard.type(`# ${title}`)
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function expectRenamedFile(vaultPath: string, filename: string): Promise<void> {
|
||||
await expect(async () => {
|
||||
expect(markdownFiles(vaultPath)).toContain(filename)
|
||||
}).toPass({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function expectFileContentContains(vaultPath: string, filename: string, text: string): Promise<void> {
|
||||
await expect(async () => {
|
||||
const content = fs.readFileSync(`${vaultPath}/${filename}`, 'utf-8')
|
||||
expect(content).toContain(text)
|
||||
}).toPass({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function expectActiveFilename(page: Page, filenameStem: string): Promise<void> {
|
||||
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(filenameStem, { timeout: 10_000 })
|
||||
}
|
||||
|
||||
async function expectEditorFocused(page: Page): Promise<void> {
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(90_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultTauri(page, tempVaultDir)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('@smoke new-note H1 auto-rename keeps the editor usable and leaves no untitled duplicates', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (err) => {
|
||||
if (!isKnownEditorError(err.message)) errors.push(err.message)
|
||||
})
|
||||
|
||||
const titles = [
|
||||
'Fresh Focus Title',
|
||||
'Rapid Rename 2',
|
||||
'Rapid Rename 3',
|
||||
'Rapid Rename 4',
|
||||
'Rapid Rename 5',
|
||||
]
|
||||
|
||||
for (const [index, title] of titles.entries()) {
|
||||
await createUntitledNote(page)
|
||||
await expectEditorFocused(page)
|
||||
await writeNewHeading(page, title)
|
||||
await expectActiveFilename(page, slugifyTitle(title))
|
||||
await expectRenamedFile(tempVaultDir, `${slugifyTitle(title)}.md`)
|
||||
await expectEditorFocused(page)
|
||||
|
||||
if (index === 0) {
|
||||
await page.keyboard.type(' focus-probe')
|
||||
await expectFileContentContains(tempVaultDir, 'fresh-focus-title.md', 'focus-probe')
|
||||
}
|
||||
}
|
||||
|
||||
const files = markdownFiles(tempVaultDir)
|
||||
expect(files).toContain('fresh-focus-title.md')
|
||||
expect(files.filter((name) => name.startsWith('untitled-note-'))).toEqual([])
|
||||
expect(files.filter((name) => /^rapid-rename-\d+\.md$/.test(name))).toHaveLength(4)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
Reference in New Issue
Block a user