Compare commits
6 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d7c027700 | ||
|
|
74e50c64bb | ||
|
|
8b97ef711f | ||
|
|
08855cee92 | ||
|
|
03042bb49c | ||
|
|
cee4bef179 |
57
.github/workflows/release-stable.yml
vendored
57
.github/workflows/release-stable.yml
vendored
@@ -127,6 +127,63 @@ jobs:
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate telemetry env
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1].strip()
|
||||
return value
|
||||
|
||||
def normalize_http_like(value: str) -> str:
|
||||
if "://" in value:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
for name in (
|
||||
"VITE_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"VITE_POSTHOG_KEY",
|
||||
"VITE_POSTHOG_HOST",
|
||||
)
|
||||
}
|
||||
errors = []
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if not value:
|
||||
errors.append(f"{name} must be set for release builds")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL")
|
||||
|
||||
if not values["VITE_POSTHOG_KEY"]:
|
||||
errors.append("VITE_POSTHOG_KEY must be set for release builds")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("Telemetry env validation passed.")
|
||||
PY
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
|
||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -170,6 +170,63 @@ jobs:
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate telemetry env
|
||||
env:
|
||||
VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
VITE_POSTHOG_KEY: ${{ secrets.VITE_POSTHOG_KEY }}
|
||||
VITE_POSTHOG_HOST: ${{ secrets.VITE_POSTHOG_HOST }}
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def normalize(name: str) -> str:
|
||||
value = os.getenv(name, "").strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
||||
value = value[1:-1].strip()
|
||||
return value
|
||||
|
||||
def normalize_http_like(value: str) -> str:
|
||||
if "://" in value:
|
||||
return value
|
||||
return f"https://{value}"
|
||||
|
||||
def is_http_url(value: str) -> bool:
|
||||
parsed = urlparse(normalize_http_like(value))
|
||||
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
||||
|
||||
values = {
|
||||
name: normalize(name)
|
||||
for name in (
|
||||
"VITE_SENTRY_DSN",
|
||||
"SENTRY_DSN",
|
||||
"VITE_POSTHOG_KEY",
|
||||
"VITE_POSTHOG_HOST",
|
||||
)
|
||||
}
|
||||
errors = []
|
||||
|
||||
for name in ("VITE_SENTRY_DSN", "SENTRY_DSN", "VITE_POSTHOG_HOST"):
|
||||
value = values[name]
|
||||
if not value:
|
||||
errors.append(f"{name} must be set for release builds")
|
||||
elif not is_http_url(value):
|
||||
errors.append(f"{name} must be a valid http(s) URL")
|
||||
|
||||
if not values["VITE_POSTHOG_KEY"]:
|
||||
errors.append("VITE_POSTHOG_KEY must be set for release builds")
|
||||
|
||||
if errors:
|
||||
print("Telemetry env validation failed:", file=sys.stderr)
|
||||
for error in errors:
|
||||
print(f"- {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
print("Telemetry env validation passed.")
|
||||
PY
|
||||
|
||||
- name: Build Tauri app (with signing + notarization)
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
|
||||
@@ -11,6 +11,39 @@ fn scrub_paths(input: &str) -> String {
|
||||
re.replace_all(input, "<redacted-path>").to_string()
|
||||
}
|
||||
|
||||
fn normalize_embedded_env(raw: Option<&str>) -> Option<String> {
|
||||
let value = raw?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let unwrapped = match (value.chars().next(), value.chars().last()) {
|
||||
(Some('"'), Some('"')) | (Some('\''), Some('\'')) if value.len() >= 2 => {
|
||||
value[1..value.len() - 1].trim()
|
||||
}
|
||||
_ => value,
|
||||
};
|
||||
|
||||
if unwrapped.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(unwrapped.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_http_like_value(value: &str) -> String {
|
||||
if value.contains("://") {
|
||||
value.to_string()
|
||||
} else {
|
||||
format!("https://{value}")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_embedded_sentry_dsn(raw: Option<&str>) -> Option<sentry::types::Dsn> {
|
||||
let normalized = normalize_embedded_env(raw)?;
|
||||
normalize_http_like_value(&normalized).parse().ok()
|
||||
}
|
||||
|
||||
/// Initialize Sentry if the user has opted in to crash reporting.
|
||||
/// Returns `true` if Sentry was initialized, `false` if skipped.
|
||||
pub fn init_sentry_from_settings() -> bool {
|
||||
@@ -23,14 +56,13 @@ pub fn init_sentry_from_settings() -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
let dsn = option_env!("SENTRY_DSN").unwrap_or_default();
|
||||
if dsn.is_empty() {
|
||||
let Some(dsn) = parse_embedded_sentry_dsn(option_env!("SENTRY_DSN")) else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let anonymous_id = settings.anonymous_id.unwrap_or_default();
|
||||
let guard = sentry::init(sentry::ClientOptions {
|
||||
dsn: dsn.parse().ok(),
|
||||
dsn: Some(dsn),
|
||||
release: Some(env!("CARGO_PKG_VERSION").into()),
|
||||
send_default_pii: false,
|
||||
before_send: Some(std::sync::Arc::new(|mut event| {
|
||||
@@ -86,6 +118,43 @@ mod tests {
|
||||
assert_eq!(scrub_paths("Normal error message"), "Normal error message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_embedded_env_trims_wrapping_quotes() {
|
||||
assert_eq!(
|
||||
normalize_embedded_env(Some(" \"value\" ")).as_deref(),
|
||||
Some("value")
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_embedded_env(Some(" 'value' ")).as_deref(),
|
||||
Some("value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_embedded_env_drops_blank_values() {
|
||||
assert_eq!(normalize_embedded_env(Some(" ")), None);
|
||||
assert_eq!(normalize_embedded_env(None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embedded_sentry_dsn_accepts_valid_trimmed_value() {
|
||||
let parsed =
|
||||
parse_embedded_sentry_dsn(Some(" \"https://public@example.ingest.sentry.io/1\" "));
|
||||
assert!(parsed.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embedded_sentry_dsn_accepts_scheme_less_value() {
|
||||
let parsed = parse_embedded_sentry_dsn(Some("public@example.ingest.sentry.io/1"));
|
||||
assert!(parsed.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_embedded_sentry_dsn_rejects_invalid_value() {
|
||||
let parsed = parse_embedded_sentry_dsn(Some("not a dsn"));
|
||||
assert!(parsed.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_init_sentry_returns_false_without_dsn() {
|
||||
// Without SENTRY_DSN env var set at compile time, init should return false
|
||||
|
||||
@@ -858,7 +858,8 @@ function App() {
|
||||
setToastMessage,
|
||||
})
|
||||
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory)
|
||||
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
|
||||
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
|
||||
|
||||
const handleCreateType = useCallback((name: string) => {
|
||||
notes.handleCreateType(name)
|
||||
|
||||
10
src/assets/tolaria-icon.svg
Normal file
10
src/assets/tolaria-icon.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="266" height="363" viewBox="0 0 266 363" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_111_151)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M119.524 5.31219C126.607 -1.77073 138.117 -1.77073 145.2 5.31219C178.844 40.7268 265.61 147.856 265.61 230.195C265.61 303.68 206.29 363 132.805 363C59.3195 363 0 303.68 0 230.195C0 147.856 86.7659 40.7268 119.524 5.31219ZM59.5947 242.88C58.1619 234.744 50.3952 229.074 42.2195 230.195C34.0438 231.316 28.5932 238.799 30.0259 246.935C37.6847 290.425 72.2734 325.622 116.094 334.485C117.821 334.836 119.552 334.864 121.178 334.641C127.269 333.806 132.289 329.26 133.371 322.934C134.756 314.893 129.25 307.054 121.086 305.401C89.7767 299.057 65.0615 273.923 59.5947 242.88Z" fill="#155DFF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_111_151">
|
||||
<rect width="266" height="363" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 889 B |
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { WelcomeScreen } from './WelcomeScreen'
|
||||
import tolariaIcon from '@/assets/tolaria-icon.svg'
|
||||
|
||||
const dragRegionMouseDown = vi.fn()
|
||||
|
||||
@@ -30,7 +31,14 @@ describe('WelcomeScreen', () => {
|
||||
it('renders welcome title and subtitle', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
|
||||
expect(screen.getByText('Markdown knowledge management for the age of AI')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the local Tolaria branding icon', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
|
||||
const brandIcon = screen.getByAltText('Tolaria icon')
|
||||
expect(brandIcon).toHaveAttribute('src', tolariaIcon)
|
||||
})
|
||||
|
||||
it('shows all three option buttons', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
|
||||
import { OnboardingShell } from './OnboardingShell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import tolariaIcon from '@/assets/tolaria-icon.svg'
|
||||
|
||||
interface WelcomeScreenProps {
|
||||
mode: 'welcome' | 'vault-missing'
|
||||
@@ -105,6 +106,12 @@ const ICON_WRAP_STYLE: React.CSSProperties = {
|
||||
justifyContent: 'center',
|
||||
}
|
||||
|
||||
const BRAND_ICON_STYLE: React.CSSProperties = {
|
||||
width: 64,
|
||||
height: 64,
|
||||
display: 'block',
|
||||
}
|
||||
|
||||
const TITLE_STYLE: React.CSSProperties = {
|
||||
fontSize: 28,
|
||||
fontWeight: 700,
|
||||
@@ -267,10 +274,10 @@ function getWelcomeScreenPresentation(
|
||||
): WelcomeScreenPresentation {
|
||||
if (mode === 'welcome') {
|
||||
return {
|
||||
heroBackground: 'var(--accent-blue-light, #EBF4FF)',
|
||||
heroIcon: <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>✦</span>,
|
||||
heroBackground: 'transparent',
|
||||
heroIcon: <img src={tolariaIcon} alt="Tolaria icon" style={BRAND_ICON_STYLE} />,
|
||||
openFolderLabel: 'Open existing vault',
|
||||
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
|
||||
subtitle: 'Markdown knowledge management for the age of AI',
|
||||
templateDescription: isOffline
|
||||
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
|
||||
: 'Download the getting started vault',
|
||||
|
||||
105
src/hooks/useGitHistory.test.ts
Normal file
105
src/hooks/useGitHistory.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GitCommit } from '../types'
|
||||
import { useGitHistory } from './useGitHistory'
|
||||
|
||||
const mockHistory: GitCommit[] = [
|
||||
{ hash: 'abc', shortHash: 'abc', author: 'luca', date: 1_700_000_000, message: 'Initial commit' },
|
||||
]
|
||||
|
||||
describe('useGitHistory', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waits briefly before loading note history', async () => {
|
||||
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
|
||||
|
||||
const { result } = renderHook(() => useGitHistory('/vault/a.md', loadGitHistory, true))
|
||||
|
||||
expect(result.current).toEqual([])
|
||||
expect(loadGitHistory).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).toHaveBeenCalledWith('/vault/a.md')
|
||||
expect(result.current).toEqual(mockHistory)
|
||||
})
|
||||
|
||||
it('skips loading when history is disabled', () => {
|
||||
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
|
||||
|
||||
const { result } = renderHook(() => useGitHistory('/vault/a.md', loadGitHistory, false))
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1_000)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels stale pending loads when the active note changes quickly', async () => {
|
||||
const loadGitHistory = vi.fn((path: string) => Promise.resolve([
|
||||
{ ...mockHistory[0], hash: path, shortHash: path, message: path },
|
||||
]))
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ path }) => useGitHistory(path, loadGitHistory, true),
|
||||
{ initialProps: { path: '/vault/a.md' as string | null } },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
})
|
||||
|
||||
rerender({ path: '/vault/b.md' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(199)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
})
|
||||
|
||||
expect(loadGitHistory).toHaveBeenCalledTimes(1)
|
||||
expect(loadGitHistory).toHaveBeenCalledWith('/vault/b.md')
|
||||
expect(result.current).toEqual([
|
||||
expect.objectContaining({ hash: '/vault/b.md', message: '/vault/b.md' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('clears previously loaded history when the inspector is hidden', async () => {
|
||||
const loadGitHistory = vi.fn().mockResolvedValue(mockHistory)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ enabled }) => useGitHistory('/vault/a.md', loadGitHistory, enabled),
|
||||
{ initialProps: { enabled: true } },
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
})
|
||||
|
||||
expect(result.current).toEqual(mockHistory)
|
||||
|
||||
rerender({ enabled: false })
|
||||
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,13 +1,43 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { GitCommit } from '../types'
|
||||
|
||||
export function useGitHistory(activeTabPath: string | null, loadGitHistory: (path: string) => Promise<GitCommit[]>) {
|
||||
const [gitHistory, setGitHistory] = useState<GitCommit[]>([])
|
||||
const GIT_HISTORY_LOAD_DELAY_MS = 200
|
||||
|
||||
export function useGitHistory(
|
||||
activeTabPath: string | null,
|
||||
loadGitHistory: (path: string) => Promise<GitCommit[]>,
|
||||
enabled = true,
|
||||
) {
|
||||
const [loadedHistory, setLoadedHistory] = useState<{
|
||||
path: string | null
|
||||
commits: GitCommit[]
|
||||
}>({
|
||||
path: null,
|
||||
commits: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabPath) return
|
||||
loadGitHistory(activeTabPath).then(setGitHistory)
|
||||
}, [activeTabPath, loadGitHistory])
|
||||
if (!enabled || !activeTabPath) return
|
||||
|
||||
return activeTabPath ? gitHistory : []
|
||||
let cancelled = false
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadGitHistory(activeTabPath).then((history) => {
|
||||
if (cancelled) return
|
||||
setLoadedHistory({
|
||||
path: activeTabPath,
|
||||
commits: history,
|
||||
})
|
||||
})
|
||||
}, GIT_HISTORY_LOAD_DELAY_MS)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [activeTabPath, enabled, loadGitHistory])
|
||||
|
||||
return enabled && activeTabPath && loadedHistory.path === activeTabPath
|
||||
? loadedHistory.commits
|
||||
: []
|
||||
}
|
||||
|
||||
@@ -195,6 +195,55 @@ describe('useVaultSwitcher', () => {
|
||||
expect(result.current.isGettingStartedHidden).toBe(false)
|
||||
})
|
||||
|
||||
it('drops stale canonical Getting Started entries when the starter path is missing', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [
|
||||
{ label: 'Getting Started', path: expectedDefaultVaultPath },
|
||||
{ label: 'Work', path: '/work/vault' },
|
||||
],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [],
|
||||
}
|
||||
setMockInvokeBehavior({
|
||||
checkVaultExists: ({ path }) => path === '/work/vault',
|
||||
})
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
expect(result.current.allVaults).toEqual([{ label: 'Work', path: '/work/vault', available: true }])
|
||||
expect(result.current.vaultPath).toBe('/work/vault')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVaultListStore).toEqual({
|
||||
vaults: [{ label: 'Work', path: '/work/vault' }],
|
||||
active_vault: '/work/vault',
|
||||
hidden_defaults: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a stale canonical Getting Started selection when the starter path is missing', async () => {
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'Getting Started', path: expectedDefaultVaultPath }],
|
||||
active_vault: expectedDefaultVaultPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
setMockInvokeBehavior({ checkVaultExists: false })
|
||||
|
||||
const { result } = await renderLoadedVaultSwitcher()
|
||||
|
||||
expect(result.current.allVaults).toEqual([])
|
||||
expect(result.current.selectedVaultPath).toBeNull()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockVaultListStore).toEqual({
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('handles load error gracefully', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
|
||||
@@ -137,6 +137,18 @@ function syncDefaultVaultExport(path: string) {
|
||||
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path }
|
||||
}
|
||||
|
||||
function isCanonicalGettingStartedPath(path: string, resolvedDefaultPath: string): boolean {
|
||||
return path === resolvedDefaultPath
|
||||
}
|
||||
|
||||
function isUnavailableGettingStartedVault(vault: VaultOption): boolean {
|
||||
return vault.label === GETTING_STARTED_LABEL && vault.available === false
|
||||
}
|
||||
|
||||
function shouldDropPersistedGettingStartedVault(vault: VaultOption, resolvedDefaultPath: string): boolean {
|
||||
return isCanonicalGettingStartedPath(vault.path, resolvedDefaultPath) || isUnavailableGettingStartedVault(vault)
|
||||
}
|
||||
|
||||
async function checkVaultAvailability(path: string): Promise<boolean> {
|
||||
if (!path) {
|
||||
return false
|
||||
@@ -166,7 +178,79 @@ async function loadInitialVaultState() {
|
||||
console.warn('Failed to load vault list:', vaultListResult.reason)
|
||||
}
|
||||
|
||||
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
|
||||
return sanitizeCanonicalGettingStartedState({
|
||||
activeVault,
|
||||
defaultAvailable,
|
||||
hiddenDefaults,
|
||||
resolvedDefaultPath,
|
||||
vaults,
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeCanonicalGettingStartedState({
|
||||
activeVault,
|
||||
defaultAvailable,
|
||||
hiddenDefaults,
|
||||
resolvedDefaultPath,
|
||||
vaults,
|
||||
}: {
|
||||
activeVault: string | null
|
||||
defaultAvailable: boolean
|
||||
hiddenDefaults: string[]
|
||||
resolvedDefaultPath: string
|
||||
vaults: VaultOption[]
|
||||
}) {
|
||||
if (!resolvedDefaultPath) {
|
||||
return { activeVault, defaultAvailable, hiddenDefaults, resolvedDefaultPath, vaults }
|
||||
}
|
||||
|
||||
const filteredVaults = vaults.filter(
|
||||
(vault) => !shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath),
|
||||
)
|
||||
const removedStarterPaths = new Set(
|
||||
vaults
|
||||
.filter((vault) => shouldDropPersistedGettingStartedVault(vault, resolvedDefaultPath))
|
||||
.map((vault) => vault.path),
|
||||
)
|
||||
const sanitizedActiveVault = resolveSanitizedGettingStartedSelection({
|
||||
activeVault,
|
||||
defaultAvailable,
|
||||
filteredVaults,
|
||||
removedStarterPaths,
|
||||
resolvedDefaultPath,
|
||||
})
|
||||
|
||||
return {
|
||||
activeVault: sanitizedActiveVault,
|
||||
defaultAvailable,
|
||||
hiddenDefaults,
|
||||
resolvedDefaultPath,
|
||||
vaults: filteredVaults,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSanitizedGettingStartedSelection({
|
||||
activeVault,
|
||||
defaultAvailable,
|
||||
filteredVaults,
|
||||
removedStarterPaths,
|
||||
resolvedDefaultPath,
|
||||
}: {
|
||||
activeVault: string | null
|
||||
defaultAvailable: boolean
|
||||
filteredVaults: VaultOption[]
|
||||
removedStarterPaths: Set<string>
|
||||
resolvedDefaultPath: string
|
||||
}): string | null {
|
||||
if (!activeVault || !removedStarterPaths.has(activeVault)) {
|
||||
return activeVault
|
||||
}
|
||||
|
||||
if (isCanonicalGettingStartedPath(activeVault, resolvedDefaultPath) && defaultAvailable) {
|
||||
return activeVault
|
||||
}
|
||||
|
||||
return filteredVaults[0]?.path ?? null
|
||||
}
|
||||
|
||||
function buildDefaultVaults({
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import * as Sentry from '@sentry/react'
|
||||
|
||||
const SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN ?? ''
|
||||
const POSTHOG_KEY = import.meta.env.VITE_POSTHOG_KEY ?? ''
|
||||
const POSTHOG_HOST = import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com'
|
||||
import { resolveFrontendTelemetryConfig } from './telemetryConfig'
|
||||
|
||||
/** Pattern that matches absolute file paths (macOS / Linux / Windows). */
|
||||
const PATH_PATTERN = /(?:\/[\w.-]+){2,}|[A-Z]:\\[\w\\.-]+/g
|
||||
@@ -11,24 +8,30 @@ function scrubPaths(input: string): string {
|
||||
return input.replace(PATH_PATTERN, '<redacted-path>')
|
||||
}
|
||||
|
||||
function scrubSentryEvent(event: Sentry.ErrorEvent): Sentry.ErrorEvent {
|
||||
if (event.message) event.message = scrubPaths(event.message)
|
||||
for (const ex of event.exception?.values ?? []) {
|
||||
if (ex.value) ex.value = scrubPaths(ex.value)
|
||||
}
|
||||
for (const breadcrumb of event.breadcrumbs ?? []) {
|
||||
if (breadcrumb.message) breadcrumb.message = scrubPaths(breadcrumb.message)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
let sentryInitialized = false
|
||||
let posthogInstance: typeof import('posthog-js').default | null = null
|
||||
|
||||
export function initSentry(anonymousId: string): void {
|
||||
if (sentryInitialized || !SENTRY_DSN) return
|
||||
if (sentryInitialized) return
|
||||
|
||||
const { sentryDsn } = resolveFrontendTelemetryConfig()
|
||||
if (!sentryDsn) return
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
dsn: sentryDsn,
|
||||
sendDefaultPii: false,
|
||||
beforeSend(event) {
|
||||
if (event.message) event.message = scrubPaths(event.message)
|
||||
for (const ex of event.exception?.values ?? []) {
|
||||
if (ex.value) ex.value = scrubPaths(ex.value)
|
||||
}
|
||||
for (const bc of event.breadcrumbs ?? []) {
|
||||
if (bc.message) bc.message = scrubPaths(bc.message)
|
||||
}
|
||||
return event
|
||||
},
|
||||
beforeSend: scrubSentryEvent,
|
||||
})
|
||||
Sentry.setUser({ id: anonymousId })
|
||||
sentryInitialized = true
|
||||
@@ -41,10 +44,14 @@ export function teardownSentry(): void {
|
||||
}
|
||||
|
||||
export async function initPostHog(anonymousId: string, releaseChannel?: string): Promise<void> {
|
||||
if (posthogInstance || !POSTHOG_KEY) return
|
||||
if (posthogInstance) return
|
||||
|
||||
const { posthogKey, posthogHost } = resolveFrontendTelemetryConfig()
|
||||
if (!posthogKey || !posthogHost) return
|
||||
|
||||
const posthog = (await import('posthog-js')).default
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
posthog.init(posthogKey, {
|
||||
api_host: posthogHost,
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
persistence: 'memory',
|
||||
|
||||
71
src/lib/telemetryConfig.test.ts
Normal file
71
src/lib/telemetryConfig.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
_defaultPostHogHostForTest as defaultPostHogHost,
|
||||
resolveFrontendTelemetryConfig,
|
||||
sanitizeTelemetryEnvValue,
|
||||
} from './telemetryConfig'
|
||||
|
||||
describe('sanitizeTelemetryEnvValue', () => {
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(sanitizeTelemetryEnvValue(' value ')).toBe('value')
|
||||
})
|
||||
|
||||
it('unwraps matching quotes after trimming', () => {
|
||||
expect(sanitizeTelemetryEnvValue(' "value" ')).toBe('value')
|
||||
expect(sanitizeTelemetryEnvValue(" 'value' ")).toBe('value')
|
||||
})
|
||||
|
||||
it('returns an empty string for blank input', () => {
|
||||
expect(sanitizeTelemetryEnvValue(' ')).toBe('')
|
||||
expect(sanitizeTelemetryEnvValue(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFrontendTelemetryConfig', () => {
|
||||
it('keeps valid telemetry values after sanitizing them', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ',
|
||||
VITE_POSTHOG_KEY: " 'phc_test_key' ",
|
||||
VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ',
|
||||
})).toEqual({
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the default PostHog host when one is not configured', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
}).posthogHost).toBe(defaultPostHogHost)
|
||||
})
|
||||
|
||||
it('adds https to scheme-less DSNs and PostHog hosts', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'eu.i.posthog.com',
|
||||
})).toEqual({
|
||||
sentryDsn: 'https://public@example.ingest.sentry.io/123456',
|
||||
posthogKey: 'phc_test_key',
|
||||
posthogHost: 'https://eu.i.posthog.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops invalid Sentry DSNs instead of passing them to the SDK', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'not a dsn',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
|
||||
}).sentryDsn).toBe('')
|
||||
})
|
||||
|
||||
it('drops invalid PostHog hosts instead of loading scripts from them', () => {
|
||||
expect(resolveFrontendTelemetryConfig({
|
||||
VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456',
|
||||
VITE_POSTHOG_KEY: 'phc_test_key',
|
||||
VITE_POSTHOG_HOST: 'not a url',
|
||||
}).posthogHost).toBeNull()
|
||||
})
|
||||
})
|
||||
75
src/lib/telemetryConfig.ts
Normal file
75
src/lib/telemetryConfig.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'
|
||||
|
||||
type TelemetryEnv = {
|
||||
VITE_SENTRY_DSN?: string
|
||||
VITE_POSTHOG_KEY?: string
|
||||
VITE_POSTHOG_HOST?: string
|
||||
}
|
||||
|
||||
export type FrontendTelemetryConfig = {
|
||||
sentryDsn: string
|
||||
posthogKey: string
|
||||
posthogHost: string | null
|
||||
}
|
||||
|
||||
function unwrapMatchingQuotes(value: string): string {
|
||||
if (value.length < 2) return value
|
||||
|
||||
const first = value[0]
|
||||
const last = value[value.length - 1]
|
||||
if (first !== last) return value
|
||||
if (first !== '"' && first !== "'") return value
|
||||
|
||||
return value.slice(1, -1).trim()
|
||||
}
|
||||
|
||||
export function sanitizeTelemetryEnvValue(value: string | undefined): string {
|
||||
if (!value) return ''
|
||||
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return ''
|
||||
|
||||
return unwrapMatchingQuotes(trimmed)
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return url.protocol === 'http:' || url.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHttpLikeValue(value: string): string {
|
||||
if (!value) return ''
|
||||
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return value
|
||||
return `https://${value}`
|
||||
}
|
||||
|
||||
function normalizeSentryDsn(value: string): string {
|
||||
const normalized = normalizeHttpLikeValue(value)
|
||||
return isHttpUrl(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function normalizePostHogHost(value: string): string | null {
|
||||
if (!value) return DEFAULT_POSTHOG_HOST
|
||||
const normalized = normalizeHttpLikeValue(value)
|
||||
return isHttpUrl(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
export function resolveFrontendTelemetryConfig(
|
||||
env: TelemetryEnv = import.meta.env as TelemetryEnv,
|
||||
): FrontendTelemetryConfig {
|
||||
const sentryDsn = normalizeSentryDsn(
|
||||
sanitizeTelemetryEnvValue(env.VITE_SENTRY_DSN),
|
||||
)
|
||||
const posthogKey = sanitizeTelemetryEnvValue(env.VITE_POSTHOG_KEY)
|
||||
const posthogHost = normalizePostHogHost(
|
||||
sanitizeTelemetryEnvValue(env.VITE_POSTHOG_HOST),
|
||||
)
|
||||
|
||||
return { sentryDsn, posthogKey, posthogHost }
|
||||
}
|
||||
|
||||
export { DEFAULT_POSTHOG_HOST as _defaultPostHogHostForTest }
|
||||
135
tests/smoke/stale-getting-started-switcher.spec.ts
Normal file
135
tests/smoke/stale-getting-started-switcher.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
interface StaleStarterPaths {
|
||||
gettingStartedPath: string
|
||||
personalVaultPath: string
|
||||
workVaultPath: string
|
||||
}
|
||||
|
||||
function buildPaths(): StaleStarterPaths {
|
||||
return {
|
||||
gettingStartedPath: '/Users/mock/Documents/Getting Started',
|
||||
personalVaultPath: '/Users/mock/Personal',
|
||||
workVaultPath: '/Users/mock/Work',
|
||||
}
|
||||
}
|
||||
|
||||
async function installStaleStarterMocks(page: Page) {
|
||||
const paths = buildPaths()
|
||||
|
||||
await page.addInitScript((data: StaleStarterPaths) => {
|
||||
localStorage.clear()
|
||||
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = value as Record<string, unknown>
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [
|
||||
{ label: 'Getting Started', path: data.gettingStartedPath },
|
||||
{ label: 'Work Vault', path: data.workVaultPath },
|
||||
{ label: 'Personal Vault', path: data.personalVaultPath },
|
||||
],
|
||||
active_vault: data.workVaultPath,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
ref.get_default_vault_path = () => data.gettingStartedPath
|
||||
ref.check_vault_exists = (args: { path?: string }) =>
|
||||
args?.path === data.workVaultPath || args?.path === data.personalVaultPath
|
||||
ref.list_vault = (args: { path?: string }) => {
|
||||
if (args?.path === data.workVaultPath) {
|
||||
return [{
|
||||
path: `${data.workVaultPath}/work-home.md`,
|
||||
filename: 'work-home.md',
|
||||
title: 'Work Home',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Work Home snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
}]
|
||||
}
|
||||
|
||||
if (args?.path === data.personalVaultPath) {
|
||||
return [{
|
||||
path: `${data.personalVaultPath}/personal-home.md`,
|
||||
filename: 'personal-home.md',
|
||||
title: 'Personal Home',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
archived: false,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
snippet: 'Personal Home snippet',
|
||||
wordCount: 12,
|
||||
relationships: {},
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
template: null,
|
||||
sort: null,
|
||||
}]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
ref.list_vault_folders = () => []
|
||||
ref.list_views = () => []
|
||||
ref.get_all_content = () => ({
|
||||
[`${data.workVaultPath}/work-home.md`]: '# Work Home\n\nWork Home snippet',
|
||||
[`${data.personalVaultPath}/personal-home.md`]: '# Personal Home\n\nPersonal Home snippet',
|
||||
})
|
||||
ref.get_note_content = (args: { path?: string }) => {
|
||||
if (args?.path === `${data.workVaultPath}/work-home.md`) {
|
||||
return '# Work Home\n\nWork Home snippet'
|
||||
}
|
||||
|
||||
if (args?.path === `${data.personalVaultPath}/personal-home.md`) {
|
||||
return '# Personal Home\n\nPersonal Home snippet'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
ref.get_modified_files = () => []
|
||||
ref.get_file_history = () => []
|
||||
},
|
||||
get() {
|
||||
return ref
|
||||
},
|
||||
})
|
||||
}, paths)
|
||||
}
|
||||
|
||||
test('stale persisted Getting Started entries stay hidden when the starter path is missing @smoke', async ({ page }) => {
|
||||
await installStaleStarterMocks(page)
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' })
|
||||
|
||||
const trigger = page.getByTestId('status-vault-trigger')
|
||||
await expect(trigger).toContainText('Work Vault')
|
||||
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect(page.getByTestId('vault-menu-item-Getting Started')).toHaveCount(0)
|
||||
await expect(page.getByTestId('vault-menu-item-Work Vault')).toBeVisible()
|
||||
await expect(page.getByTestId('vault-menu-item-Personal Vault')).toBeVisible()
|
||||
})
|
||||
Reference in New Issue
Block a user