fix: recover empty startup vault reloads
This commit is contained in:
106
src/hooks/useVaultLoader.startup.test.ts
Normal file
106
src/hooks/useVaultLoader.startup.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { VaultOption } from '../components/status-bar/types'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useVaultLoader } from './useVaultLoader'
|
||||
|
||||
const backendInvokeFn = vi.fn()
|
||||
let mockIsTauri = true
|
||||
const ACTIVE_VAULT_PATH = '/laputa'
|
||||
const EMPTY_ARRAY_COMMANDS = new Set(['get_modified_files', 'list_vault_folders', 'list_views'])
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => backendInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => mockIsTauri,
|
||||
mockInvoke: (command: string, args?: Record<string, unknown>) => backendInvokeFn(command, args),
|
||||
}))
|
||||
|
||||
function makeEntry(): VaultEntry {
|
||||
return {
|
||||
path: `${ACTIVE_VAULT_PATH}/note/recovered.md`,
|
||||
filename: 'recovered.md',
|
||||
title: 'Recovered',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
archived: false,
|
||||
modifiedAt: 1,
|
||||
createdAt: 1,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: null,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
}
|
||||
}
|
||||
|
||||
function commandPath(args?: Record<string, unknown>): string {
|
||||
return typeof args?.path === 'string' ? args.path : ''
|
||||
}
|
||||
|
||||
function buildUpgradeStartupMock() {
|
||||
let activeReloads = 0
|
||||
|
||||
return {
|
||||
activeReloadCount: () => activeReloads,
|
||||
invoke: (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'reload_vault') {
|
||||
if (commandPath(args) !== ACTIVE_VAULT_PATH) return Promise.resolve([])
|
||||
activeReloads += 1
|
||||
return Promise.resolve(activeReloads === 1 ? [] : [makeEntry()])
|
||||
}
|
||||
if (EMPTY_ARRAY_COMMANDS.has(command)) return Promise.resolve([])
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('useVaultLoader startup recovery', () => {
|
||||
beforeEach(() => {
|
||||
mockIsTauri = true
|
||||
backendInvokeFn.mockReset()
|
||||
})
|
||||
|
||||
it('freshly reloads the active workspace when persisted metadata follows an empty startup scan', async () => {
|
||||
const laputa: VaultOption = { label: 'Laputa', path: ACTIVE_VAULT_PATH, available: true, mounted: true }
|
||||
const startupMock = buildUpgradeStartupMock()
|
||||
backendInvokeFn.mockImplementation(startupMock.invoke)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ vaults }: { vaults?: VaultOption[] }) => useVaultLoader(ACTIVE_VAULT_PATH, vaults, ACTIVE_VAULT_PATH, vaults),
|
||||
{ initialProps: { vaults: undefined } },
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
expect(result.current.entries).toEqual([])
|
||||
|
||||
rerender({ vaults: [laputa] })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries.map((entry) => entry.title)).toEqual(['Recovered'])
|
||||
})
|
||||
expect(startupMock.activeReloadCount()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
@@ -888,12 +888,14 @@ function useInitialLoadedWorkspaceMarker({
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (isLoading || !hasVaultPath({ vaultPath }) || initialLoadedVaultPathRef.current === vaultPath) return
|
||||
const inferFallbackWorkspacePath = !vaults?.length
|
||||
const loadedPaths = inferFallbackWorkspacePath && entries.length === 0
|
||||
? []
|
||||
: loadedWorkspacePathsFromEntries(entries, vaultPath, { inferFallbackWorkspacePath })
|
||||
initialLoadedVaultPathRef.current = vaultPath
|
||||
loadedWorkspacePathsRef.current = new Set([
|
||||
...loadedWorkspacePathsRef.current,
|
||||
...loadedWorkspacePathsFromEntries(entries, vaultPath, {
|
||||
inferFallbackWorkspacePath: !vaults?.length,
|
||||
}),
|
||||
...loadedPaths,
|
||||
])
|
||||
}, [entries, initialLoadedVaultPathRef, isLoading, loadedWorkspacePathsRef, vaultPath, vaults])
|
||||
}
|
||||
@@ -967,7 +969,10 @@ function loadMissingWorkspaceEntries({
|
||||
vaultPath: string
|
||||
vaults?: VaultOption[]
|
||||
}) {
|
||||
void loadWorkspaceEntries(vault, defaultWorkspacePath, { reloadIfEmpty: true })
|
||||
void loadWorkspaceEntries(vault, defaultWorkspacePath, {
|
||||
forceReload: vault.path === vaultPath,
|
||||
reloadIfEmpty: true,
|
||||
})
|
||||
.then((loadedEntries) => {
|
||||
if (!isCurrentVaultPath(vaultPath)) return
|
||||
loadedPaths.add(vault.path)
|
||||
|
||||
152
tests/smoke/startup-empty-vault-recovery.spec.ts
Normal file
152
tests/smoke/startup-empty-vault-recovery.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
type CommandArgs = Record<string, unknown> | undefined
|
||||
type CommandHandler = (args?: CommandArgs) => unknown
|
||||
type HandlerMap = Record<string, CommandHandler>
|
||||
|
||||
interface TauriInternals {
|
||||
invoke: (command: string, args?: CommandArgs) => Promise<unknown>
|
||||
transformCallback: () => string
|
||||
unregisterCallback: () => void
|
||||
}
|
||||
|
||||
type StartupWindow = Window & typeof globalThis & {
|
||||
__mockHandlers?: HandlerMap
|
||||
__startupReloadCount?: () => number
|
||||
__TAURI_INTERNALS__?: TauriInternals
|
||||
}
|
||||
|
||||
const activeVaultPath = path.join(process.cwd(), 'demo-vault-v2')
|
||||
const starterVaultPath = path.join(process.cwd(), 'mock-getting-started')
|
||||
const recoveredNotePath = path.join(activeVaultPath, 'startup-recovered.md')
|
||||
const recoveredEntry = {
|
||||
path: recoveredNotePath,
|
||||
filename: 'startup-recovered.md',
|
||||
title: 'Recovered Startup Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1_700_000_000,
|
||||
createdAt: 1_700_000_000,
|
||||
fileSize: 64,
|
||||
snippet: 'Recovered after the startup reload.',
|
||||
wordCount: 6,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
sidebarLabel: null,
|
||||
template: null,
|
||||
sort: null,
|
||||
view: null,
|
||||
visible: true,
|
||||
organized: false,
|
||||
favorite: false,
|
||||
favoriteIndex: null,
|
||||
listPropertiesDisplay: [],
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
hasH1: true,
|
||||
fileKind: 'markdown',
|
||||
}
|
||||
|
||||
async function installStartupRecoveryMock(page: Page): Promise<void> {
|
||||
await page.addInitScript(({ defaultPath, vaultPath, noteEntry }) => {
|
||||
localStorage.setItem('tolaria_welcome_dismissed', '1')
|
||||
localStorage.setItem('tolaria:ai-agents-onboarding-dismissed', '1')
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
const startupWindow = window as StartupWindow
|
||||
const noteContent = {
|
||||
[noteEntry.path]: '# Recovered Startup Note\n\nRecovered content.',
|
||||
}
|
||||
let reloadCount = 0
|
||||
|
||||
function emptyArray(): unknown[] {
|
||||
return []
|
||||
}
|
||||
|
||||
function commandPath(args: CommandArgs): string {
|
||||
return typeof args?.path === 'string' ? args.path : ''
|
||||
}
|
||||
|
||||
const handlers: HandlerMap = {
|
||||
load_vault_list: () => ({
|
||||
vaults: [
|
||||
{ label: 'Recovered Vault', path: vaultPath },
|
||||
{ label: 'Secondary Vault', path: `${vaultPath}-secondary` },
|
||||
],
|
||||
active_vault: vaultPath,
|
||||
default_workspace_path: vaultPath,
|
||||
hidden_defaults: [],
|
||||
}),
|
||||
check_vault_exists: () => true,
|
||||
get_default_vault_path: () => defaultPath,
|
||||
get_settings: () => ({
|
||||
auto_pull_interval_minutes: null,
|
||||
autogit_enabled: false,
|
||||
autogit_idle_threshold_seconds: 90,
|
||||
autogit_inactive_threshold_seconds: 30,
|
||||
auto_advance_inbox_after_organize: null,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
note_width_mode: null,
|
||||
sidebar_type_pluralization_enabled: null,
|
||||
default_ai_agent: null,
|
||||
default_ai_target: null,
|
||||
ai_model_providers: [],
|
||||
}),
|
||||
get_vault_settings: () => ({ theme: null }),
|
||||
reload_vault: (args) => {
|
||||
if (commandPath(args) !== vaultPath) return emptyArray()
|
||||
reloadCount += 1
|
||||
return reloadCount === 1 ? emptyArray() : [noteEntry]
|
||||
},
|
||||
list_vault: emptyArray,
|
||||
list_vault_folders: emptyArray,
|
||||
list_views: emptyArray,
|
||||
get_modified_files: emptyArray,
|
||||
get_all_content: () => noteContent,
|
||||
get_note_content: (args) => noteContent[commandPath(args) as keyof typeof noteContent] ?? '',
|
||||
is_git_repo: () => true,
|
||||
sync_mcp_bridge_vault: () => null,
|
||||
start_vault_watcher: () => null,
|
||||
stop_vault_watcher: () => null,
|
||||
update_menu_state: () => null,
|
||||
}
|
||||
|
||||
startupWindow.__startupReloadCount = () => reloadCount
|
||||
startupWindow.__TAURI_INTERNALS__ = {
|
||||
transformCallback: () => crypto.randomUUID(),
|
||||
unregisterCallback: () => {},
|
||||
invoke: async (command, args) => {
|
||||
if (command === 'plugin:event|listen') return 1
|
||||
if (command === 'plugin:event|unlisten') return null
|
||||
return handlers[command]?.(args) ?? startupWindow.__mockHandlers?.[command]?.(args) ?? null
|
||||
},
|
||||
}
|
||||
startupWindow.isTauri = true
|
||||
}, { defaultPath: starterVaultPath, vaultPath: activeVaultPath, noteEntry: recoveredEntry })
|
||||
}
|
||||
|
||||
async function startupReloadCount(page: Page): Promise<number> {
|
||||
return page.evaluate(() => (window as StartupWindow).__startupReloadCount?.() ?? 0)
|
||||
}
|
||||
|
||||
test('startup recovers notes after an empty first vault reload @smoke', async ({ page }) => {
|
||||
await installStartupRecoveryMock(page)
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByText('Recovered Startup Note')).toBeVisible({ timeout: 8_000 })
|
||||
await expect.poll(() => startupReloadCount(page)).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
Reference in New Issue
Block a user