fix: guard note suggestions during reload

This commit is contained in:
lucaronin
2026-04-30 12:09:35 +02:00
parent 690ace2d1f
commit fdc7d8fd84
8 changed files with 389 additions and 14 deletions

View File

@@ -191,6 +191,30 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option<AiAge
mod tests {
use super::*;
fn request_with_permission(
permission_mode: Option<AiAgentPermissionMode>,
) -> AiAgentStreamRequest {
AiAgentStreamRequest {
agent: AiAgentId::Codex,
message: "Summarize this vault".into(),
system_prompt: None,
vault_path: "/tmp/vault".into(),
permission_mode,
}
}
#[test]
fn stream_request_uses_default_or_explicit_permission_mode() {
assert_eq!(
request_with_permission(None).permission_mode(),
AiAgentPermissionMode::Safe
);
assert_eq!(
request_with_permission(Some(AiAgentPermissionMode::PowerUser)).permission_mode(),
AiAgentPermissionMode::PowerUser
);
}
#[test]
fn normalize_status_contains_all_agents() {
let status = get_ai_agents_status();
@@ -214,6 +238,66 @@ mod tests {
assert!(matches!(mapped, Some(AiAgentStreamEvent::Done)));
}
#[test]
fn map_claude_text_events_preserve_stream_data() {
assert!(matches!(
map_claude_event(crate::claude_cli::ClaudeStreamEvent::Init {
session_id: "session-1".into(),
}),
Some(AiAgentStreamEvent::Init { session_id }) if session_id == "session-1"
));
assert!(matches!(
map_claude_event(crate::claude_cli::ClaudeStreamEvent::TextDelta {
text: "visible output".into(),
}),
Some(AiAgentStreamEvent::TextDelta { text }) if text == "visible output"
));
assert!(matches!(
map_claude_event(crate::claude_cli::ClaudeStreamEvent::ThinkingDelta {
text: "thinking".into(),
}),
Some(AiAgentStreamEvent::ThinkingDelta { text }) if text == "thinking"
));
}
#[test]
fn map_claude_tool_events_preserve_stream_data() {
let started = map_claude_event(crate::claude_cli::ClaudeStreamEvent::ToolStart {
tool_name: "Read".into(),
tool_id: "tool-1".into(),
input: Some("{\"file\":\"note.md\"}".into()),
});
let finished = map_claude_event(crate::claude_cli::ClaudeStreamEvent::ToolDone {
tool_id: "tool-1".into(),
output: Some("done".into()),
});
assert!(matches!(
started,
Some(AiAgentStreamEvent::ToolStart { tool_name, tool_id, input })
if tool_name == "Read"
&& tool_id == "tool-1"
&& input.as_deref() == Some("{\"file\":\"note.md\"}")
));
assert!(matches!(
finished,
Some(AiAgentStreamEvent::ToolDone { tool_id, output })
if tool_id == "tool-1" && output.as_deref() == Some("done")
));
}
#[test]
fn map_claude_error_event_preserves_message() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Error {
message: "missing auth".into(),
});
assert!(matches!(
mapped,
Some(AiAgentStreamEvent::Error { message }) if message == "missing auth"
));
}
#[test]
fn map_claude_result_event_preserves_final_text() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
@@ -226,4 +310,14 @@ mod tests {
Some(AiAgentStreamEvent::TextDelta { text }) if text == "Final answer from Claude"
));
}
#[test]
fn map_claude_empty_result_event_is_ignored() {
let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Result {
text: String::new(),
session_id: "session-1".into(),
});
assert!(mapped.is_none());
}
}

View File

@@ -131,7 +131,8 @@ pub async fn download_and_install_app_update<R: Runtime>(
#[cfg(test)]
mod tests {
use super::ReleaseChannel;
use super::{AppUpdateDownloadEvent, AppUpdateMetadata, ReleaseChannel};
use serde_json::json;
#[test]
fn release_channel_defaults_to_stable() {
@@ -176,4 +177,54 @@ mod tests {
"https://refactoringhq.github.io/tolaria/stable/latest.json"
);
}
#[test]
fn update_metadata_serializes_for_frontend_consumers() {
let metadata = AppUpdateMetadata {
current_version: "2026.4.1".into(),
version: "2026.4.2".into(),
date: Some("2026-04-30T12:00:00Z".into()),
body: Some("Bug fixes".into()),
};
assert_eq!(
serde_json::to_value(metadata).unwrap(),
json!({
"currentVersion": "2026.4.1",
"version": "2026.4.2",
"date": "2026-04-30T12:00:00Z",
"body": "Bug fixes"
})
);
}
#[test]
fn download_events_serialize_as_tagged_frontend_events() {
let events = [
(
AppUpdateDownloadEvent::Started {
content_length: Some(4096),
},
json!({
"event": "Started",
"data": { "contentLength": 4096 }
}),
),
(
AppUpdateDownloadEvent::Progress { chunk_length: 512 },
json!({
"event": "Progress",
"data": { "chunkLength": 512 }
}),
),
(
AppUpdateDownloadEvent::Finished,
json!({ "event": "Finished" }),
),
];
for (event, expected) in events {
assert_eq!(serde_json::to_value(event).unwrap(), expected);
}
}
}

View File

@@ -90,3 +90,67 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
git::ensure_gitignore(&vault_path)?;
Ok("Vault repaired".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn empty_vault_target_validation_allows_missing_or_empty_directories() {
let dir = tempfile::TempDir::new().unwrap();
let missing = dir.path().join("new-vault");
let empty = dir.path().join("empty-vault");
fs::create_dir(&empty).unwrap();
assert_eq!(ensure_directory_is_missing_or_empty(&missing), Ok(()));
assert_eq!(ensure_directory_is_missing_or_empty(&empty), Ok(()));
}
#[test]
fn empty_vault_target_validation_rejects_files_and_nonempty_directories() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("vault.md");
let nonempty = dir.path().join("vault");
fs::write(&file, "# Not a folder").unwrap();
fs::create_dir(&nonempty).unwrap();
fs::write(nonempty.join("note.md"), "# Existing note").unwrap();
assert_eq!(
ensure_directory_is_missing_or_empty(&file),
Err("Choose a folder path for the new vault".to_string())
);
assert_eq!(
ensure_directory_is_missing_or_empty(&nonempty),
Err("Choose an empty folder to create a new vault".to_string())
);
}
#[test]
fn canonical_vault_path_uses_existing_canonical_path_or_original_path() {
let dir = tempfile::TempDir::new().unwrap();
let existing = dir.path().join("existing");
let missing = dir.path().join("missing");
fs::create_dir(&existing).unwrap();
assert_eq!(
canonical_vault_path_string(&existing),
existing.canonicalize().unwrap().to_string_lossy()
);
assert_eq!(
canonical_vault_path_string(&missing),
missing.to_string_lossy()
);
}
#[test]
fn getting_started_target_uses_explicit_path_when_provided() {
let dir = tempfile::TempDir::new().unwrap();
let explicit = dir.path().join("starter");
assert_eq!(
resolve_getting_started_target(explicit.to_str()),
Ok(explicit.to_string_lossy().to_string())
);
}
}

View File

@@ -35,3 +35,50 @@ pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), Strin
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::{FilterCondition, FilterGroup, FilterNode, FilterOp};
fn definition(name: &str) -> ViewDefinition {
ViewDefinition {
name: name.to_string(),
icon: Some("star".to_string()),
color: None,
order: None,
sort: Some("modified:desc".to_string()),
list_properties_display: vec!["Priority".to_string()],
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
}
}
#[test]
fn view_commands_roundtrip_through_validated_vault_paths() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().to_string_lossy().to_string();
assert!(list_views(vault_path.clone()).unwrap().is_empty());
save_view_cmd(
vault_path.clone(),
"active-projects.yml".to_string(),
definition("Active Projects"),
)
.unwrap();
let views = list_views(vault_path.clone()).unwrap();
assert_eq!(views.len(), 1);
assert_eq!(views[0].filename, "active-projects.yml");
assert_eq!(views[0].definition.name, "Active Projects");
delete_view_cmd(vault_path.clone(), "active-projects.yml".to_string()).unwrap();
assert!(list_views(vault_path).unwrap().is_empty());
}
}

View File

@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tauri::{
App, AppHandle, LogicalPosition, LogicalSize, Manager, Position, RunEvent, Size, WebviewWindow,
@@ -263,7 +263,11 @@ fn window_state_path() -> Result<PathBuf, String> {
}
fn read_main_window_frame(scale_factor: f64) -> Option<WindowFrame> {
let content = fs::read_to_string(window_state_path().ok()?).ok()?;
read_main_window_frame_at(&window_state_path().ok()?, scale_factor)
}
fn read_main_window_frame_at(path: &Path, scale_factor: f64) -> Option<WindowFrame> {
let content = fs::read_to_string(path).ok()?;
let persisted: PersistedWindowState = serde_json::from_str(&content).ok()?;
persisted
.main
@@ -276,7 +280,10 @@ fn read_main_window_frame(scale_factor: f64) -> Option<WindowFrame> {
}
fn write_main_window_frame(frame: WindowFrame) -> Result<(), String> {
let path = window_state_path()?;
write_main_window_frame_at(&window_state_path()?, frame)
}
fn write_main_window_frame_at(path: &Path, frame: WindowFrame) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create window state directory: {e}"))?;
@@ -511,4 +518,50 @@ mod tests {
&RunEvent::Resumed
));
}
#[test]
fn persists_and_reads_logical_frame_from_disk() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nested/window-state.json");
let saved = frame(80, 120, 1100, 700);
write_main_window_frame_at(&path, saved).unwrap();
let json = std::fs::read_to_string(&path).unwrap();
assert!(json.contains("\"coordinate_space\": \"logical\""));
assert_eq!(read_main_window_frame_at(&path, 2.0), Some(saved));
}
#[test]
fn reads_legacy_physical_frame_from_disk_as_logical_points() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("window-state.json");
std::fs::write(
&path,
r#"{
"main": { "x": 160, "y": 240, "width": 2200, "height": 1400 },
"coordinate_space": "physical"
}"#,
)
.unwrap();
assert_eq!(
read_main_window_frame_at(&path, 2.0),
Some(frame(80, 120, 1100, 700))
);
}
#[test]
fn ignores_missing_corrupt_or_tiny_window_state_files() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("window-state.json");
assert_eq!(read_main_window_frame_at(&path, 1.0), None);
std::fs::write(&path, "not json").unwrap();
assert_eq!(read_main_window_frame_at(&path, 1.0), None);
std::fs::write(&path, r#"{"main":{"x":0,"y":0,"width":100,"height":100}}"#).unwrap();
assert_eq!(read_main_window_frame_at(&path, 1.0), None);
}
}

View File

@@ -440,6 +440,25 @@ describe('SingleEditorView', () => {
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
it('renders when a reload returns an entry with missing suggestion metadata', () => {
const reloadedEntry = {
...makeEntry({ path: '/vault/project/reloaded.md', title: 'Reloaded' }),
filename: undefined,
aliases: undefined,
isA: undefined,
} as unknown as VaultEntry
expect(() => {
render(
<SingleEditorView
editor={createEditor() as never}
entries={[reloadedEntry]}
onNavigateWikilink={vi.fn()}
/>,
)
}).not.toThrow()
})
it('ignores stale suggestion item clicks after the editor DOM disconnects', () => {
const editor = createEditor()
editor.domElement = document.createElement('div')

View File

@@ -451,15 +451,42 @@ function handleCodeBlockCopy(event: React.ClipboardEvent<HTMLDivElement>) {
event.preventDefault()
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value : null
}
function markdownStem(value: string): string {
return value.replace(/\.md$/i, '')
}
function pathStem(path: string): string {
return markdownStem(path.split('/').pop() ?? path)
}
function safeStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => nonEmptyString(item) !== null)
: []
}
function buildBaseSuggestionItems(entries: VaultEntry[]) {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryType: entry.isA,
entryTitle: entry.title,
path: entry.path,
})))
return deduplicateByPath(entries.flatMap(entry => {
const path = nonEmptyString(entry.path)
if (!path) return []
const filename = nonEmptyString(entry.filename)
const filenameStem = filename ? markdownStem(filename) : pathStem(path)
const title = nonEmptyString(entry.title) ?? filenameStem
const entryType = nonEmptyString(entry.isA)
return [{
title,
aliases: [...new Set([filenameStem, ...safeStringArray(entry.aliases)])],
group: entryType ?? 'Note',
entryType,
entryTitle: title,
path,
}]
}))
}
function useInsertWikilink(

View File

@@ -1,9 +1,10 @@
import { test, expect, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVault,
openFixtureVaultDesktopHarness,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette } from './helpers'
let tempVaultDir: string
@@ -46,6 +47,12 @@ function removeAlphaProjectStringMetadata(entries: Array<Record<string, unknown>
})
}
async function reloadVaultFromCommandPalette(page: Page): Promise<void> {
await openCommandPalette(page)
await executeCommand(page, 'Reload Vault')
await expect(page.locator('input[placeholder="Type a command..."]')).not.toBeVisible()
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
@@ -62,7 +69,7 @@ test.beforeEach(async ({ page }, testInfo) => {
json: removeAlphaProjectStringMetadata(entries),
})
})
await openFixtureVault(page, tempVaultDir, {
await openFixtureVaultDesktopHarness(page, tempVaultDir, {
expectedReadyTitle: 'alpha-project',
})
await page.setViewportSize({ width: 1180, height: 760 })
@@ -85,3 +92,16 @@ test('@smoke note open tolerates missing string metadata from the vault scan', a
expect(errors).toHaveLength(0)
})
test('note open after vault reload tolerates missing suggestion metadata', async ({ page }) => {
const errors = collectMissingMetadataCrashes(page)
const noteList = page.getByTestId('note-list-container')
await reloadVaultFromCommandPalette(page)
await noteList.getByText('Note B', { exact: true }).click()
await noteList.getByText('alpha-project', { exact: true }).click()
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
expect(errors).toHaveLength(0)
})