Compare commits
3 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51010ca578 | ||
|
|
15bc3a4701 | ||
|
|
3ebfa642fa |
@@ -32,7 +32,7 @@
|
||||
"script-src": "'self' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com",
|
||||
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
|
||||
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
|
||||
"style-src": "'self' 'unsafe-inline' 'nonce-tolaria-codemirror-style' https://fonts.googleapis.com",
|
||||
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src": "'self' data: https://fonts.gstatic.com",
|
||||
"media-src": "'self' data: blob: https:",
|
||||
"object-src": "'none'"
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
|
||||
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
@@ -303,7 +304,7 @@ function resolveMockCommandResult(cmd: string, args?: unknown) {
|
||||
}
|
||||
|
||||
vi.mock('./mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: vi.fn(() => false),
|
||||
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
@@ -317,6 +318,24 @@ vi.mock('./utils/ai-chat', () => ({
|
||||
streamClaudeChat: vi.fn(async () => 'mock-session'),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/useUpdater', async () => {
|
||||
const actual = await vi.importActual<typeof import('./hooks/useUpdater')>('./hooks/useUpdater')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useUpdater: vi.fn(() => ({
|
||||
status: { state: 'idle' },
|
||||
actions: {
|
||||
checkForUpdates: vi.fn(async () => ({ kind: 'up-to-date' })),
|
||||
startDownload: vi.fn(),
|
||||
openReleaseNotes: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
})),
|
||||
restartApp: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock BlockNote components (they need DOM APIs not available in jsdom)
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
||||
@@ -396,14 +415,33 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
|
||||
}))
|
||||
|
||||
import App from './App'
|
||||
import { useUpdater } from './hooks/useUpdater'
|
||||
import { isTauri } from './mock-tauri'
|
||||
|
||||
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
|
||||
function createMockUpdaterResult(
|
||||
checkForUpdates: () => Promise<{ kind: 'up-to-date' } | { kind: 'available'; version: string; displayVersion: string } | { kind: 'error'; message: string }> = async () => ({ kind: 'up-to-date' }),
|
||||
) {
|
||||
return {
|
||||
status: { state: 'idle' as const },
|
||||
actions: {
|
||||
checkForUpdates,
|
||||
startDownload: vi.fn(),
|
||||
openReleaseNotes: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetMockCommandResults()
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args))
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult())
|
||||
localStorage.clear()
|
||||
window.history.replaceState({}, '', '/')
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
@@ -480,6 +518,45 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows visible feedback when a manual update check finds an update', async () => {
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({
|
||||
kind: 'available',
|
||||
version: '2026.4.25',
|
||||
displayVersion: '2026.4.25',
|
||||
})))
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-build-number'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tolaria 2026.4.25 is available')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows visible feedback when a menu-driven update check finds no eligible update', async () => {
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({ kind: 'up-to-date' })))
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
|
||||
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
|
||||
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)
|
||||
|
||||
14
src/App.tsx
14
src/App.tsx
@@ -89,6 +89,7 @@ import { openNoteListPropertiesPicker } from './components/note-list/noteListPro
|
||||
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
|
||||
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { normalizeReleaseChannel } from './lib/releaseChannel'
|
||||
import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
@@ -1097,12 +1098,15 @@ function App() {
|
||||
return
|
||||
}
|
||||
const result = await updateActions.checkForUpdates()
|
||||
if (result === 'up-to-date') {
|
||||
setToastMessage("You're on the latest version")
|
||||
} else if (result === 'error') {
|
||||
setToastMessage('Could not check for updates')
|
||||
if (result.kind === 'up-to-date') {
|
||||
const checkedChannel = normalizeReleaseChannel(settings.release_channel)
|
||||
setToastMessage(`No newer ${checkedChannel} update is available right now`)
|
||||
} else if (result.kind === 'available') {
|
||||
setToastMessage(`Tolaria ${result.displayVersion} is available`)
|
||||
} else {
|
||||
setToastMessage(result.message)
|
||||
}
|
||||
}, [updateActions, updateStatus.state, setToastMessage])
|
||||
}, [settings.release_channel, updateActions, updateStatus.state, setToastMessage])
|
||||
|
||||
const handleRepairVault = useCallback(async () => {
|
||||
if (!resolvedPath) return
|
||||
|
||||
@@ -105,6 +105,16 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opts the command input out of spellcheck and text correction', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
const input = screen.getByPlaceholderText('Type a command...')
|
||||
|
||||
expect(input).toHaveAttribute('spellcheck', 'false')
|
||||
expect(input).toHaveAttribute('autocorrect', 'off')
|
||||
expect(input).toHaveAttribute('autocapitalize', 'off')
|
||||
expect(input).toHaveAttribute('autocomplete', 'off')
|
||||
})
|
||||
|
||||
it('shows all enabled commands grouped by category', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
expect(screen.getByText('Search Notes')).toBeInTheDocument()
|
||||
@@ -181,6 +191,35 @@ describe('CommandPalette', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a short query keyboard-selectable after ArrowDown and Enter', () => {
|
||||
const changeNoteType = makeCommand({
|
||||
id: 'change-note-type',
|
||||
label: 'Change Note Type…',
|
||||
group: 'Note',
|
||||
})
|
||||
|
||||
render(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
commands={[
|
||||
changeNoteType,
|
||||
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings' }),
|
||||
]}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'ch' } })
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
|
||||
const selectedRow = screen.getByText('Change Note Type…').closest('[data-selected]')
|
||||
expect(selectedRow).toHaveAttribute('data-selected', 'true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
expect(changeNoteType.execute).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not go below the last item', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
|
||||
import { groupSortKey } from '../hooks/useCommandRegistry'
|
||||
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
|
||||
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
|
||||
import { Input } from './ui/input'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
@@ -124,12 +125,16 @@ function CommandPaletteInput({
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
className="h-auto rounded-none border-x-0 border-t-0 border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground shadow-none transition-none outline-none placeholder:text-muted-foreground focus-visible:border-border focus-visible:ring-0 md:text-[15px]"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { CODEMIRROR_CSP_NONCE, useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
|
||||
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
|
||||
|
||||
const noop = () => {}
|
||||
const noopCallbacks: CodeMirrorCallbacks = {
|
||||
@@ -31,14 +31,6 @@ describe('useCodeMirror', () => {
|
||||
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mounts CodeMirror generated styles with the configured CSP nonce', () => {
|
||||
const ref = { current: container }
|
||||
renderHook(() => useCodeMirror(ref, 'hello', noopCallbacks))
|
||||
|
||||
const style = document.head.querySelector(`style[nonce="${CODEMIRROR_CSP_NONCE}"]`)
|
||||
expect(style?.textContent).toContain('.cm-scroller')
|
||||
})
|
||||
|
||||
it('calls requestMeasure when laputa-zoom-change event fires', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -16,8 +16,6 @@ const RAW_EDITOR_COLORS = {
|
||||
gutterBorder: 'var(--border-subtle)',
|
||||
gutterText: 'var(--text-muted)',
|
||||
}
|
||||
export const CODEMIRROR_CSP_NONCE = 'tolaria-codemirror-style'
|
||||
|
||||
export interface CodeMirrorCallbacks {
|
||||
onDocChange: (doc: string) => void
|
||||
onCursorActivity: (view: EditorView) => void
|
||||
@@ -145,7 +143,6 @@ export function useCodeMirror(
|
||||
buildArrowLigaturesExtension(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
buildSaveKeymap(callbacksRef),
|
||||
EditorView.cspNonce.of(CODEMIRROR_CSP_NONCE),
|
||||
buildBaseTheme(),
|
||||
markdownLanguage(),
|
||||
frontmatterHighlightTheme(),
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('useUpdater', () => {
|
||||
it('returns up-to-date when no update is available', async () => {
|
||||
const { result, outcome } = await performManualCheck('stable', null)
|
||||
|
||||
expect(outcome).toBe('up-to-date')
|
||||
expect(outcome).toEqual({ kind: 'up-to-date' })
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
@@ -181,7 +181,11 @@ describe('useUpdater', () => {
|
||||
makeUpdate({ body: undefined }),
|
||||
)
|
||||
|
||||
expect(outcome).toBe('available')
|
||||
expect(outcome).toEqual({
|
||||
kind: 'available',
|
||||
version: '2026.4.16',
|
||||
displayVersion: '2026.4.16',
|
||||
})
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '2026.4.16',
|
||||
@@ -210,7 +214,10 @@ describe('useUpdater', () => {
|
||||
new Error('network error'),
|
||||
)
|
||||
|
||||
expect(outcome).toBe('error')
|
||||
expect(outcome).toEqual({
|
||||
kind: 'error',
|
||||
message: 'Could not check for updates: network error',
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
@@ -23,7 +23,10 @@ export type UpdateStatus =
|
||||
| ({ state: 'ready' } & UpdateVersionInfo)
|
||||
| { state: 'error' }
|
||||
|
||||
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
|
||||
export type UpdateCheckResult =
|
||||
| { kind: 'up-to-date' }
|
||||
| ({ kind: 'available' } & UpdateVersionInfo)
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export interface UpdateActions {
|
||||
checkForUpdates: () => Promise<UpdateCheckResult>
|
||||
@@ -57,6 +60,16 @@ function createVersionInfo(version: string): UpdateVersionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdateCheckErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `Could not check for updates: ${error.message}`
|
||||
}
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
return `Could not check for updates: ${error}`
|
||||
}
|
||||
return 'Could not check for updates'
|
||||
}
|
||||
|
||||
function toAvailableStatus(update: AppUpdateMetadata): UpdateStatus {
|
||||
return {
|
||||
state: 'available',
|
||||
@@ -96,22 +109,23 @@ export function useUpdater(
|
||||
const updateRef = useRef<AppUpdateMetadata | null>(null)
|
||||
|
||||
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
|
||||
if (!isTauri()) return 'up-to-date'
|
||||
if (!isTauri()) return { kind: 'up-to-date' }
|
||||
|
||||
try {
|
||||
const update = await checkForAppUpdate(releaseChannel)
|
||||
if (!update) {
|
||||
updateRef.current = null
|
||||
setStatus({ state: 'idle' })
|
||||
return 'up-to-date'
|
||||
return { kind: 'up-to-date' }
|
||||
}
|
||||
|
||||
const versionInfo = createVersionInfo(update.version)
|
||||
updateRef.current = update
|
||||
setStatus(toAvailableStatus(update))
|
||||
return 'available'
|
||||
} catch {
|
||||
return { kind: 'available', ...versionInfo }
|
||||
} catch (error) {
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
return 'error'
|
||||
return { kind: 'error', message: buildUpdateCheckErrorMessage(error) }
|
||||
}
|
||||
}, [releaseChannel])
|
||||
|
||||
|
||||
11
src/utils/tauriCsp.test.ts
Normal file
11
src/utils/tauriCsp.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
describe('Tauri Content Security Policy', () => {
|
||||
it('keeps broad inline styles available when runtime libraries inject style tags', () => {
|
||||
const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8'))
|
||||
const styleSrc = config.app.security.csp['style-src'] as string
|
||||
|
||||
expect(styleSrc).toContain("'unsafe-inline'")
|
||||
expect(styleSrc).not.toMatch(/'nonce-|sha(256|384|512)-/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user