fix: make getting started clone destination explicit
This commit is contained in:
@@ -543,9 +543,14 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte
|
||||
|
||||
`useOnboarding` hook detects first launch:
|
||||
- If vault path doesn't exist → show `WelcomeScreen`
|
||||
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
|
||||
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen parent folder; Tolaria derives the final `Getting Started` child path before cloning
|
||||
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
|
||||
|
||||
`useGettingStartedClone` encapsulates the non-onboarding Getting Started action:
|
||||
- Opens the same parent-folder picker used by onboarding
|
||||
- Derives the final `.../Getting Started` destination path
|
||||
- Surfaces the resolved path through the app toast after a successful clone
|
||||
|
||||
`useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step:
|
||||
- Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key)
|
||||
- Only shows after vault onboarding has already resolved to a ready state
|
||||
|
||||
@@ -433,10 +433,12 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
|
||||
- **Create a new vault** → creates an empty git repo in a folder the user chooses
|
||||
- **Open an existing folder** → system file picker
|
||||
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
|
||||
- **Get started with a template** → pick a parent folder, then call `create_getting_started_vault()` with the derived `.../Getting Started` child path so the cloned vault opens into the populated repo root immediately
|
||||
|
||||
Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch.
|
||||
|
||||
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
|
||||
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL, delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions.
|
||||
|
||||
### Remote Clone & Auth Model
|
||||
|
||||
@@ -102,6 +102,7 @@ tolaria/
|
||||
│ │ ├── appCommandCatalog.ts # Shortcut combos + command metadata
|
||||
│ │ ├── appCommandDispatcher.ts # Shared shortcut/menu command IDs + dispatch
|
||||
│ │ ├── useSettings.ts # App settings
|
||||
│ │ ├── useGettingStartedClone.ts # Shared Getting Started clone action
|
||||
│ │ ├── useOnboarding.ts # First-launch flow
|
||||
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
|
||||
│ │ ├── useMcpBridge.ts # MCP WebSocket client
|
||||
@@ -208,7 +209,8 @@ tolaria/
|
||||
|------|---------------|
|
||||
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
|
||||
| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and restoring the cloned Getting Started vault. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and persisting cloned vaults in the switcher list. |
|
||||
| `src/hooks/useGettingStartedClone.ts` | Shared "Clone Getting Started Vault" action for the status bar and command palette. |
|
||||
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
|
||||
|
||||
### Backend
|
||||
|
||||
34
src/App.tsx
34
src/App.tsx
@@ -35,7 +35,7 @@ import { useEntryActions } from './hooks/useEntryActions'
|
||||
import { useAppCommands } from './hooks/useAppCommands'
|
||||
import { generateCommitMessage } from './utils/commitMessage'
|
||||
import { useDialogs } from './hooks/useDialogs'
|
||||
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { GETTING_STARTED_LABEL, useVaultSwitcher } from './hooks/useVaultSwitcher'
|
||||
import { useGitHistory } from './hooks/useGitHistory'
|
||||
import { useUpdater, restartApp } from './hooks/useUpdater'
|
||||
import { useAutoSync } from './hooks/useAutoSync'
|
||||
@@ -44,6 +44,7 @@ import { useZoom } from './hooks/useZoom'
|
||||
import { useVaultConfig } from './hooks/useVaultConfig'
|
||||
import { useBuildNumber } from './hooks/useBuildNumber'
|
||||
import { useOnboarding } from './hooks/useOnboarding'
|
||||
import { useGettingStartedClone } from './hooks/useGettingStartedClone'
|
||||
import { useNetworkStatus } from './hooks/useNetworkStatus'
|
||||
import { useAppNavigation } from './hooks/useAppNavigation'
|
||||
import { useAiActivity } from './hooks/useAiActivity'
|
||||
@@ -152,12 +153,24 @@ function App() {
|
||||
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
||||
onToast: (msg) => setToastMessage(msg),
|
||||
})
|
||||
const { handleVaultCloned } = vaultSwitcher
|
||||
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath)
|
||||
const handleGettingStartedVaultReady = useCallback((vaultPath: string, label: string) => {
|
||||
handleVaultCloned(vaultPath, label)
|
||||
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
|
||||
}, [handleVaultCloned])
|
||||
const cloneGettingStartedVault = useGettingStartedClone({
|
||||
onError: (message) => setToastMessage(message),
|
||||
onSuccess: handleGettingStartedVaultReady,
|
||||
})
|
||||
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
|
||||
handleGettingStartedVaultReady(vaultPath, GETTING_STARTED_LABEL)
|
||||
})
|
||||
const aiAgentsStatus = useAiAgentsStatus()
|
||||
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
||||
|
||||
// When onboarding resolves to a different vault path, update the switcher
|
||||
// The active vault path can temporarily come from onboarding before the
|
||||
// persisted vault switcher catches up to the newly cloned starter vault.
|
||||
const resolvedPath = noteWindowParams?.vaultPath ?? (onboarding.state.status === 'ready' ? onboarding.state.vaultPath : vaultSwitcher.vaultPath)
|
||||
// Git repo check: 'checking' | 'required' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
|
||||
@@ -673,7 +686,7 @@ function App() {
|
||||
onToggleAIChat: dialogs.toggleAIChat,
|
||||
onCheckForUpdates: handleCheckForUpdates,
|
||||
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
|
||||
onRestoreGettingStarted: vaultSwitcher.restoreGettingStarted,
|
||||
onRestoreGettingStarted: cloneGettingStartedVault,
|
||||
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
mcpStatus,
|
||||
@@ -735,10 +748,13 @@ function App() {
|
||||
|
||||
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
|
||||
return (
|
||||
<AiAgentsOnboardingView
|
||||
statuses={aiAgentsStatus}
|
||||
onContinue={aiAgentsOnboarding.dismissPrompt}
|
||||
/>
|
||||
<>
|
||||
<AiAgentsOnboardingView
|
||||
statuses={aiAgentsStatus}
|
||||
onContinue={aiAgentsOnboarding.dismissPrompt}
|
||||
/>
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -852,7 +868,7 @@ function App() {
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={vaultSwitcher.restoreGettingStarted} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isOffline={networkStatus.isOffline} isGitVault={!vault.modifiedFilesError} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
||||
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
|
||||
73
src/hooks/useGettingStartedClone.test.ts
Normal file
73
src/hooks/useGettingStartedClone.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockInvokeFn = vi.fn()
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
import { useGettingStartedClone } from './useGettingStartedClone'
|
||||
|
||||
describe('useGettingStartedClone', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('does nothing when the folder picker is cancelled', async () => {
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const { result } = renderHook(() => useGettingStartedClone({ onError, onSuccess }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current()
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).not.toHaveBeenCalled()
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clones into a child Getting Started folder and reports the canonical path', async () => {
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Documents')
|
||||
mockInvokeFn.mockResolvedValue('/Users/luca/Documents/Getting Started')
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const { result } = renderHook(() => useGettingStartedClone({ onError, onSuccess }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current()
|
||||
})
|
||||
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', {
|
||||
targetPath: '/Users/luca/Documents/Getting Started',
|
||||
})
|
||||
expect(onSuccess).toHaveBeenCalledWith('/Users/luca/Documents/Getting Started', 'Getting Started')
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a friendly message for download failures', async () => {
|
||||
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Documents')
|
||||
mockInvokeFn.mockRejectedValue('git clone failed: fatal: unable to access')
|
||||
|
||||
const onSuccess = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const { result } = renderHook(() => useGettingStartedClone({ onError, onSuccess }))
|
||||
|
||||
await act(async () => {
|
||||
await result.current()
|
||||
})
|
||||
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
expect(onError).toHaveBeenCalledWith('Could not download Getting Started vault. Check your connection and try again.')
|
||||
})
|
||||
})
|
||||
37
src/hooks/useGettingStartedClone.ts
Normal file
37
src/hooks/useGettingStartedClone.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
import {
|
||||
buildGettingStartedVaultPath,
|
||||
formatGettingStartedCloneError,
|
||||
labelFromPath,
|
||||
} from '../utils/gettingStartedVault'
|
||||
|
||||
interface UseGettingStartedCloneOptions {
|
||||
onError: (message: string) => void
|
||||
onSuccess: (path: string, label: string) => void
|
||||
}
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
export function useGettingStartedClone({
|
||||
onError,
|
||||
onSuccess,
|
||||
}: UseGettingStartedCloneOptions) {
|
||||
return useCallback(async () => {
|
||||
const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
|
||||
if (!parentPath) return
|
||||
|
||||
const targetPath = buildGettingStartedVaultPath(parentPath)
|
||||
|
||||
try {
|
||||
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
|
||||
onSuccess(vaultPath, labelFromPath(vaultPath))
|
||||
} catch (err) {
|
||||
onError(formatGettingStartedCloneError(err))
|
||||
}
|
||||
}, [onError, onSuccess])
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
|
||||
|
||||
const DEFAULT_GETTING_STARTED_PATH = '/mock/Documents/Getting Started'
|
||||
const DEFAULT_PARENT_PATH = '/mock/Documents'
|
||||
const MISSING_VAULT_PATH = '/vault/missing'
|
||||
|
||||
type MockArgs = Record<string, unknown> | undefined
|
||||
type MockOverride = unknown | ((args?: MockArgs) => unknown)
|
||||
|
||||
// localStorage mock
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
@@ -21,15 +28,49 @@ vi.mock('../mock-tauri', () => ({
|
||||
mockInvoke: (...args: unknown[]) => mockInvokeFn(...args),
|
||||
}))
|
||||
|
||||
vi.mock('./useVaultSwitcher', () => ({
|
||||
}))
|
||||
vi.mock('./useVaultSwitcher', () => ({}))
|
||||
|
||||
vi.mock('../utils/vault-dialog', () => ({
|
||||
pickFolder: vi.fn(),
|
||||
}))
|
||||
|
||||
import { useOnboarding } from './useOnboarding'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
import { useOnboarding } from './useOnboarding'
|
||||
|
||||
function mockCommands(overrides: Record<string, MockOverride> = {}) {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string, args?: MockArgs) => {
|
||||
const override = overrides[cmd]
|
||||
if (typeof override === 'function') {
|
||||
return override(args)
|
||||
}
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
if (cmd === 'get_default_vault_path') return DEFAULT_GETTING_STARTED_PATH
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
async function renderOnboarding(
|
||||
initialVaultPath = MISSING_VAULT_PATH,
|
||||
onTemplateVaultReady?: (vaultPath: string) => void,
|
||||
) {
|
||||
const rendered = renderHook(() => useOnboarding(initialVaultPath, onTemplateVaultReady))
|
||||
await waitFor(() => {
|
||||
expect(rendered.result.current.state.status).not.toBe('loading')
|
||||
})
|
||||
return rendered
|
||||
}
|
||||
|
||||
async function expectStatus(
|
||||
result: { current: ReturnType<typeof useOnboarding> },
|
||||
status: 'ready' | 'welcome' | 'vault-missing',
|
||||
) {
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe(status)
|
||||
})
|
||||
}
|
||||
|
||||
describe('useOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
@@ -37,80 +78,49 @@ describe('useOnboarding', () => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('transitions to ready when vault exists', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return true
|
||||
return null
|
||||
})
|
||||
it('transitions to ready when the vault already exists', async () => {
|
||||
mockCommands({ check_vault_exists: true })
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/path'))
|
||||
const { result } = await renderOnboarding('/vault/path')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('ready')
|
||||
})
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/path' })
|
||||
})
|
||||
|
||||
it('shows welcome screen when vault does not exist', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
it('shows the welcome screen when the vault does not exist', async () => {
|
||||
mockCommands()
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: '/mock/Documents/Getting Started' })
|
||||
expect(result.current.state).toEqual({ status: 'welcome', defaultPath: DEFAULT_GETTING_STARTED_PATH })
|
||||
})
|
||||
|
||||
it('shows vault-missing when previously dismissed and vault gone', async () => {
|
||||
it('shows vault-missing when the welcome screen was previously dismissed', async () => {
|
||||
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
|
||||
mockCommands()
|
||||
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
const { result } = await renderOnboarding('/vault/deleted')
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/deleted'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('vault-missing')
|
||||
})
|
||||
expect(result.current.state).toEqual({
|
||||
status: 'vault-missing',
|
||||
vaultPath: '/vault/deleted',
|
||||
defaultPath: '/mock/Documents/Getting Started',
|
||||
defaultPath: DEFAULT_GETTING_STARTED_PATH,
|
||||
})
|
||||
})
|
||||
|
||||
it('clears the persisted active vault when the saved path no longer exists', async () => {
|
||||
localStorage.setItem(LEGACY_APP_STORAGE_KEYS.welcomeDismissed, '1')
|
||||
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'load_vault_list') {
|
||||
return {
|
||||
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
|
||||
active_vault: '/vault/deleted',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
}
|
||||
if (cmd === 'save_vault_list') return null
|
||||
return null
|
||||
mockCommands({
|
||||
load_vault_list: {
|
||||
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
|
||||
active_vault: '/vault/deleted',
|
||||
hidden_defaults: [],
|
||||
},
|
||||
save_vault_list: null,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/deleted'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('vault-missing')
|
||||
})
|
||||
const { result } = await renderOnboarding('/vault/deleted')
|
||||
|
||||
await expectStatus(result, 'vault-missing')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('save_vault_list', {
|
||||
list: {
|
||||
vaults: [{ label: 'Old Vault', path: '/vault/deleted' }],
|
||||
@@ -120,46 +130,35 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('handleCreateVault creates vault and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') return (args as { targetPath: string }).targetPath
|
||||
return null
|
||||
it('creates the template vault inside the selected parent folder', async () => {
|
||||
const onTemplateVaultReady = vi.fn()
|
||||
mockCommands({
|
||||
create_getting_started_vault: (args?: MockArgs) => (args as { targetPath: string }).targetPath,
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
|
||||
vi.mocked(pickFolder).mockResolvedValue(DEFAULT_PARENT_PATH)
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding(MISSING_VAULT_PATH, onTemplateVaultReady)
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: DEFAULT_GETTING_STARTED_PATH })
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', {
|
||||
targetPath: '/mock/Documents/Getting Started',
|
||||
targetPath: DEFAULT_GETTING_STARTED_PATH,
|
||||
})
|
||||
expect(onTemplateVaultReady).toHaveBeenCalledWith(DEFAULT_GETTING_STARTED_PATH)
|
||||
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
|
||||
})
|
||||
|
||||
it('handleCreateVault does nothing when picker is cancelled', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
it('does nothing when the template folder picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
@@ -168,21 +167,15 @@ describe('useOnboarding', () => {
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('create_getting_started_vault', expect.anything())
|
||||
})
|
||||
|
||||
it('handleCreateVault sets a friendly download error on clone failure', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') throw 'git clone failed: fatal: unable to access'
|
||||
return null
|
||||
it('sets a friendly template error on clone failure', async () => {
|
||||
mockCommands({
|
||||
create_getting_started_vault: () => { throw 'git clone failed: fatal: unable to access' },
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
|
||||
vi.mocked(pickFolder).mockResolvedValue(DEFAULT_PARENT_PATH)
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
@@ -191,57 +184,46 @@ describe('useOnboarding', () => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('retryCreateVault reuses the last selected template path', async () => {
|
||||
it('retries the last template clone without reopening the picker', async () => {
|
||||
let attempts = 0
|
||||
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') {
|
||||
mockCommands({
|
||||
create_getting_started_vault: (args?: MockArgs) => {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw 'git clone failed: fatal: unable to access'
|
||||
if (attempts === 1) {
|
||||
throw 'git clone failed: fatal: unable to access'
|
||||
}
|
||||
return (args as { targetPath: string }).targetPath
|
||||
}
|
||||
return null
|
||||
},
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
|
||||
vi.mocked(pickFolder).mockResolvedValue(DEFAULT_PARENT_PATH)
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.canRetryTemplate).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.retryCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: DEFAULT_GETTING_STARTED_PATH })
|
||||
expect(mockInvokeFn).toHaveBeenLastCalledWith('create_getting_started_vault', {
|
||||
targetPath: '/mock/Documents/Getting Started',
|
||||
targetPath: DEFAULT_GETTING_STARTED_PATH,
|
||||
})
|
||||
})
|
||||
|
||||
it('handleCreateNewVault picks folder, creates empty vault, and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_empty_vault') return (args as { targetPath: string }).targetPath
|
||||
return null
|
||||
it('creates a new empty vault and transitions to ready', async () => {
|
||||
mockCommands({
|
||||
create_empty_vault: (args?: MockArgs) => (args as { targetPath: string }).targetPath,
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue('/new/vault')
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateNewVault()
|
||||
})
|
||||
@@ -250,20 +232,13 @@ describe('useOnboarding', () => {
|
||||
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
|
||||
})
|
||||
|
||||
it('handleCreateNewVault does nothing when picker is cancelled', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
it('does nothing when the empty-vault picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleCreateNewVault()
|
||||
})
|
||||
@@ -271,20 +246,13 @@ describe('useOnboarding', () => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
it('opens an existing folder and transitions to ready', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue('/selected/folder')
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleOpenFolder()
|
||||
})
|
||||
@@ -293,20 +261,13 @@ describe('useOnboarding', () => {
|
||||
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
|
||||
})
|
||||
|
||||
it('handleOpenFolder does nothing when picker is cancelled', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
it('does nothing when the open-folder picker is cancelled', async () => {
|
||||
mockCommands()
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
await act(async () => {
|
||||
await result.current.handleOpenFolder()
|
||||
})
|
||||
@@ -314,34 +275,25 @@ describe('useOnboarding', () => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('handleDismiss marks dismissed and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
return null
|
||||
})
|
||||
it('marks the welcome screen dismissed and keeps the initial vault path', async () => {
|
||||
mockCommands()
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
const { result } = await renderOnboarding()
|
||||
|
||||
await expectStatus(result, 'welcome')
|
||||
act(() => {
|
||||
result.current.handleDismiss()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/missing' })
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: MISSING_VAULT_PATH })
|
||||
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
|
||||
})
|
||||
|
||||
it('falls back to ready if commands fail', async () => {
|
||||
it('falls back to ready if onboarding commands fail', async () => {
|
||||
mockInvokeFn.mockRejectedValue(new Error('command not found'))
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/path'))
|
||||
const { result } = await renderOnboarding('/vault/path')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('ready')
|
||||
})
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/path' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
|
||||
import { buildGettingStartedVaultPath, formatGettingStartedCloneError } from '../utils/gettingStartedVault'
|
||||
import { pickFolder } from '../utils/vault-dialog'
|
||||
|
||||
type OnboardingState =
|
||||
@@ -39,26 +40,6 @@ function markDismissed(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTemplateError(err: unknown): string {
|
||||
const message =
|
||||
typeof err === 'string'
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: `${err}`
|
||||
|
||||
if (
|
||||
message.includes('already exists and is not empty')
|
||||
|| message.includes('already exists and is not a directory')
|
||||
|| message.includes('Failed to create parent directory')
|
||||
|| message.includes('Target path is required')
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
return 'Could not download Getting Started vault. Check your connection and try again.'
|
||||
}
|
||||
|
||||
async function clearMissingActiveVault(missingPath: string): Promise<void> {
|
||||
try {
|
||||
const list = await tauriCall<PersistedVaultList>('load_vault_list', {})
|
||||
@@ -75,7 +56,10 @@ async function clearMissingActiveVault(missingPath: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export function useOnboarding(initialVaultPath: string) {
|
||||
export function useOnboarding(
|
||||
initialVaultPath: string,
|
||||
onTemplateVaultReady?: (vaultPath: string) => void,
|
||||
) {
|
||||
const [state, setState] = useState<OnboardingState>({ status: 'loading' })
|
||||
const [creatingAction, setCreatingAction] = useState<CreatingAction>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -126,17 +110,18 @@ export function useOnboarding(initialVaultPath: string) {
|
||||
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath })
|
||||
onTemplateVaultReady?.(vaultPath)
|
||||
} catch (err) {
|
||||
setError(formatTemplateError(err))
|
||||
setError(formatGettingStartedCloneError(err))
|
||||
} finally {
|
||||
setCreatingAction(null)
|
||||
}
|
||||
}, [])
|
||||
}, [onTemplateVaultReady])
|
||||
|
||||
const handleCreateVault = useCallback(async () => {
|
||||
const path = await pickFolder('Choose where to clone the Getting Started vault')
|
||||
if (!path) return
|
||||
await createTemplateVault(path)
|
||||
const parentPath = await pickFolder('Choose a parent folder for the Getting Started vault')
|
||||
if (!parentPath) return
|
||||
await createTemplateVault(buildGettingStartedVaultPath(parentPath))
|
||||
}, [createTemplateVault])
|
||||
|
||||
const retryCreateVault = useCallback(async () => {
|
||||
|
||||
35
src/utils/gettingStartedVault.test.ts
Normal file
35
src/utils/gettingStartedVault.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
GETTING_STARTED_VAULT_NAME,
|
||||
buildGettingStartedVaultPath,
|
||||
formatGettingStartedCloneError,
|
||||
labelFromPath,
|
||||
} from './gettingStartedVault'
|
||||
|
||||
describe('gettingStartedVault', () => {
|
||||
it('builds a child vault path from a parent folder', () => {
|
||||
expect(buildGettingStartedVaultPath('/Users/luca/Documents')).toBe('/Users/luca/Documents/Getting Started')
|
||||
})
|
||||
|
||||
it('trims trailing separators when building the child vault path', () => {
|
||||
expect(buildGettingStartedVaultPath('/Users/luca/Documents/')).toBe('/Users/luca/Documents/Getting Started')
|
||||
})
|
||||
|
||||
it('preserves windows separators when building the child vault path', () => {
|
||||
expect(buildGettingStartedVaultPath('C:\\Users\\luca\\Documents\\')).toBe('C:\\Users\\luca\\Documents\\Getting Started')
|
||||
})
|
||||
|
||||
it('derives a label from the final path segment', () => {
|
||||
expect(labelFromPath('/Users/luca/Documents/Getting Started')).toBe(GETTING_STARTED_VAULT_NAME)
|
||||
})
|
||||
|
||||
it('passes through destination errors verbatim', () => {
|
||||
expect(formatGettingStartedCloneError("Destination '/tmp/Getting Started' already exists and is not empty"))
|
||||
.toBe("Destination '/tmp/Getting Started' already exists and is not empty")
|
||||
})
|
||||
|
||||
it('converts other clone failures into a friendly download message', () => {
|
||||
expect(formatGettingStartedCloneError('git clone failed: fatal: unable to access'))
|
||||
.toBe('Could not download Getting Started vault. Check your connection and try again.')
|
||||
})
|
||||
})
|
||||
38
src/utils/gettingStartedVault.ts
Normal file
38
src/utils/gettingStartedVault.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const GETTING_STARTED_VAULT_NAME = 'Getting Started'
|
||||
|
||||
const CLONE_PATH_ERRORS = [
|
||||
'already exists and is not empty',
|
||||
'already exists and is not a directory',
|
||||
'Failed to create parent directory',
|
||||
'Target path is required',
|
||||
]
|
||||
|
||||
export function buildGettingStartedVaultPath(parentPath: string): string {
|
||||
const trimmed = parentPath.trim().replace(/[\\/]+$/g, '')
|
||||
if (!trimmed) {
|
||||
return GETTING_STARTED_VAULT_NAME
|
||||
}
|
||||
|
||||
const separator = trimmed.includes('\\') && !trimmed.includes('/') ? '\\' : '/'
|
||||
return `${trimmed}${separator}${GETTING_STARTED_VAULT_NAME}`
|
||||
}
|
||||
|
||||
export function labelFromPath(path: string): string {
|
||||
const trimmed = path.trim().replace(/[\\/]+$/g, '')
|
||||
return trimmed.split(/[\\/]/).pop() || 'Vault'
|
||||
}
|
||||
|
||||
export function formatGettingStartedCloneError(err: unknown): string {
|
||||
const message =
|
||||
typeof err === 'string'
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: `${err}`
|
||||
|
||||
if (CLONE_PATH_ERRORS.some(fragment => message.includes(fragment))) {
|
||||
return message
|
||||
}
|
||||
|
||||
return 'Could not download Getting Started vault. Check your connection and try again.'
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Getting Started template shows inline retry on clone failure and opens after retry @smoke', async ({ page }) => {
|
||||
const clonedPath = '/Users/mock/Documents/Getting Started'
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -23,7 +25,10 @@ test('Getting Started template shows inline retry on clone failure and opens aft
|
||||
if (cloneAttempts === 1) {
|
||||
throw 'git clone failed: fatal: unable to access'
|
||||
}
|
||||
return args.targetPath || '/Users/mock/Documents/Getting Started'
|
||||
if (args.targetPath !== '/Users/mock/Documents/Getting Started') {
|
||||
throw new Error(`Unexpected Getting Started target: ${args.targetPath}`)
|
||||
}
|
||||
return args.targetPath
|
||||
}
|
||||
},
|
||||
get() {
|
||||
@@ -33,7 +38,7 @@ test('Getting Started template shows inline retry on clone failure and opens aft
|
||||
|
||||
Object.defineProperty(window, 'prompt', {
|
||||
configurable: true,
|
||||
value: () => '/Users/mock/Documents/Getting Started',
|
||||
value: () => '/Users/mock/Documents',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +56,7 @@ test('Getting Started template shows inline retry on clone failure and opens aft
|
||||
await page.getByTestId('welcome-retry-template').click()
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).not.toBeVisible()
|
||||
await expect(page.getByText(`Getting Started vault cloned and opened at ${clonedPath}`)).toBeVisible()
|
||||
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
|
||||
await expect(page.getByText('Claude Code not detected')).toBeVisible()
|
||||
await page.getByTestId('claude-onboarding-continue').click()
|
||||
|
||||
@@ -18,7 +18,10 @@ async function installOnboardingMocks(page: Page, offline: boolean) {
|
||||
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
|
||||
ref.check_vault_exists = () => false
|
||||
ref.create_getting_started_vault = (args: { targetPath?: string | null }) => {
|
||||
return args.targetPath || '/Users/mock/Documents/Getting Started'
|
||||
if (args.targetPath !== '/Users/mock/Documents/Getting Started') {
|
||||
throw new Error(`Unexpected Getting Started target: ${args.targetPath}`)
|
||||
}
|
||||
return args.targetPath
|
||||
}
|
||||
},
|
||||
get() {
|
||||
@@ -28,7 +31,7 @@ async function installOnboardingMocks(page: Page, offline: boolean) {
|
||||
|
||||
Object.defineProperty(window, 'prompt', {
|
||||
configurable: true,
|
||||
value: () => '/Users/mock/Documents/Getting Started',
|
||||
value: () => '/Users/mock/Documents',
|
||||
})
|
||||
|
||||
Object.defineProperty(window.navigator, 'onLine', {
|
||||
@@ -58,6 +61,9 @@ test('status bar keeps a Getting Started clone entry available after onboarding'
|
||||
|
||||
await page.getByTestId('welcome-create-vault').click()
|
||||
|
||||
await expect(page.getByText('Getting Started vault cloned and opened at /Users/mock/Documents/Getting Started')).toBeVisible()
|
||||
await expect(page.getByTestId('claude-onboarding-screen')).toBeVisible()
|
||||
await page.getByTestId('claude-onboarding-continue').click()
|
||||
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
|
||||
await page.getByTitle('Switch vault').click()
|
||||
await expect(page.getByTestId('vault-menu-clone-getting-started')).toBeVisible()
|
||||
|
||||
Reference in New Issue
Block a user