Compare commits

...

6 Commits

Author SHA1 Message Date
Luca Rossi
1d7c027700 Updated Tolaria Icon 2026-04-20 10:25:46 +02:00
lucaronin
74e50c64bb fix: hide stale getting started vault entries 2026-04-20 01:02:08 +02:00
lucaronin
8b97ef711f feat: refresh welcome screen branding 2026-04-20 00:45:14 +02:00
lucaronin
08855cee92 fix: defer git history during note switching 2026-04-20 00:09:52 +02:00
lucaronin
03042bb49c fix: normalize shipped telemetry secrets 2026-04-19 23:27:57 +02:00
lucaronin
cee4bef179 fix: harden shipped telemetry config 2026-04-19 23:16:34 +02:00
15 changed files with 800 additions and 35 deletions

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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)

View 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

View File

@@ -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', () => {

View File

@@ -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)' }}>&#10022;</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',

View 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([])
})
})

View File

@@ -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
: []
}

View File

@@ -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) => {

View File

@@ -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({

View File

@@ -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',

View 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()
})
})

View 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 }

View 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()
})