From dbf54657f021b0fbaae4d5a3aa555f55cfa70d84 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 11 Apr 2026 15:22:34 +0200 Subject: [PATCH] fix: harden untitled h1 auto-rename flow --- package.json | 2 +- src/App.tsx | 2 +- src/hooks/useAppSave.test.ts | 40 ++++++ src/hooks/useAppSave.ts | 37 ++++- src/hooks/useEditorFocus.test.ts | 35 +++++ src/hooks/useEditorFocus.ts | 53 ++++++- src/hooks/useEditorTabSwap.test.ts | 70 +++++++++ src/hooks/useEditorTabSwap.ts | 43 ++++++ src/hooks/useNoteCreation.test.ts | 29 ++++ src/hooks/useNoteCreation.ts | 9 +- tests/helpers/fixtureVault.ts | 149 ++++++++++++++++++++ tests/smoke/h1-untitled-auto-rename.spec.ts | 110 +++++++++++++++ 12 files changed, 564 insertions(+), 15 deletions(-) create mode 100644 tests/smoke/h1-untitled-auto-rename.spec.ts diff --git a/package.json b/package.json index 4eb6aa40..4f987390 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 829a92b0..3c7b31b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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, diff --git a/src/hooks/useAppSave.test.ts b/src/hooks/useAppSave.test.ts index d4640a97..98a2ab77 100644 --- a/src/hooks/useAppSave.test.ts +++ b/src/hooks/useAppSave.test.ts @@ -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) => { + tabsState = typeof updater === 'function' ? updater(tabsState) : updater + }) + + vi.mocked(invoke).mockImplementation(async (command: string, args?: Record) => { + 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) diff --git a/src/hooks/useAppSave.ts b/src/hooks/useAppSave.ts index 520c7589..5cbd142d 100644 --- a/src/hooks/useAppSave.ts +++ b/src/hooks/useAppSave.ts @@ -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 { @@ -99,13 +103,31 @@ async function reloadAutoRenamedNote( invoke('reload_vault_entry', { path: newPath }), invoke('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('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) => void setTabs: Parameters[0]['setTabs'] + handleSwitchTab: (path: string) => void setToastMessage: (msg: string | null) => void loadModifiedFiles: () => void reloadViews?: () => Promise @@ -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) diff --git a/src/hooks/useEditorFocus.test.ts b/src/hooks/useEditorFocus.test.ts index e594394b..f789e5d8 100644 --- a/src/hooks/useEditorFocus.test.ts +++ b/src/hooks/useEditorFocus.test.ts @@ -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 diff --git a/src/hooks/useEditorFocus.ts b/src/hooks/useEditorFocus.ts index a2afd564..aed546da 100644 --- a/src/hooks/useEditorFocus.ts +++ b/src/hooks/useEditorFocus.ts @@ -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, ) { 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]) } diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index 0ca766c1..64aa111d 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -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('

') + 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 }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index b107604a..986cadef 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -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, preprocessed: string, @@ -153,6 +167,26 @@ function applyBlocksToEditor( }) } +function applyBlankStateToEditor( + editor: ReturnType, + suppressChangeRef: MutableRefObject, +) { + suppressChangeRef.current = true + try { + editor._tiptapEditor.commands.setContent('

') + } 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) diff --git a/src/hooks/useNoteCreation.test.ts b/src/hooks/useNoteCreation.test.ts index 682d44e3..0c439b8b 100644 --- a/src/hooks/useNoteCreation.test.ts +++ b/src/hooks/useNoteCreation.test.ts @@ -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') }) diff --git a/src/hooks/useNoteCreation.ts b/src/hooks/useNoteCreation.ts index 657df24d..d4d0ec7b 100644 --- a/src/hooks/useNoteCreation.ts +++ b/src/hooks/useNoteCreation.ts @@ -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 { diff --git a/tests/helpers/fixtureVault.ts b/tests/helpers/fixtureVault.ts index 9a2e46e6..377e3234 100644 --- a/tests/helpers/fixtureVault.ts +++ b/tests/helpers/fixtureVault.ts @@ -112,3 +112,152 @@ export async function openFixtureVault( timeout: FIXTURE_VAULT_READY_TIMEOUT, }) } + +export async function openFixtureVaultTauri( + page: Page, + vaultPath: string, +): Promise { + 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) => { + 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__)) +} diff --git a/tests/smoke/h1-untitled-auto-rename.spec.ts b/tests/smoke/h1-untitled-auto-rename.spec.ts new file mode 100644 index 00000000..b2fb0375 --- /dev/null +++ b/tests/smoke/h1-untitled-auto-rename.spec.ts @@ -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 { + 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 { + await page.keyboard.type(`# ${title}`) + await page.keyboard.press('Enter') +} + +async function expectRenamedFile(vaultPath: string, filename: string): Promise { + await expect(async () => { + expect(markdownFiles(vaultPath)).toContain(filename) + }).toPass({ timeout: 10_000 }) +} + +async function expectFileContentContains(vaultPath: string, filename: string, text: string): Promise { + 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 { + await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(filenameStem, { timeout: 10_000 }) +} + +async function expectEditorFocused(page: Page): Promise { + 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([]) +})