Compare commits
7 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd3ea02ae | ||
|
|
8da1484ebf | ||
|
|
90ebc2e939 | ||
|
|
c14927df8f | ||
|
|
a6d60695a2 | ||
|
|
7ddc0c14bf | ||
|
|
2a00b8aac7 |
@@ -5,7 +5,9 @@ use crate::claude_cli::{
|
||||
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
|
||||
};
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
|
||||
use crate::git::{
|
||||
GitCommit, GitPullResult, GitPushResult, LastCommitInfo, ModifiedFile, PulseCommit,
|
||||
};
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
use crate::search::SearchResponse;
|
||||
@@ -276,7 +278,7 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<String, String> {
|
||||
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use conflict::{
|
||||
};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult};
|
||||
pub use status::{get_modified_files, ModifiedFile};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -110,8 +110,74 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Classify a git push stderr message into a user-friendly status and message.
|
||||
pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
let lower = stderr.to_lowercase();
|
||||
|
||||
if lower.contains("non-fast-forward")
|
||||
|| lower.contains("[rejected]")
|
||||
|| lower.contains("fetch first")
|
||||
|| lower.contains("failed to push some refs")
|
||||
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if lower.contains("authentication failed")
|
||||
|| lower.contains("could not read username")
|
||||
|| lower.contains("permission denied")
|
||||
|| lower.contains("403")
|
||||
|| lower.contains("invalid credentials")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "auth_error".to_string(),
|
||||
message: "Push failed: authentication error. Check your credentials.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if lower.contains("could not resolve host")
|
||||
|| lower.contains("unable to access")
|
||||
|| lower.contains("connection refused")
|
||||
|| lower.contains("network is unreachable")
|
||||
|| lower.contains("timed out")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "network_error".to_string(),
|
||||
message: "Push failed: network error. Check your connection and try again.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: extract the hint line if present, otherwise use the full stderr
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("hint:"))
|
||||
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let detail = if hint_line.is_empty() {
|
||||
stderr.trim().to_string()
|
||||
} else {
|
||||
hint_line
|
||||
};
|
||||
|
||||
GitPushResult {
|
||||
status: "error".to_string(),
|
||||
message: format!("Push failed: {detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
@@ -122,13 +188,13 @@ pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git push failed: {}", stderr));
|
||||
return Ok(classify_push_error(&stderr));
|
||||
}
|
||||
|
||||
// git push often writes to stderr even on success
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(format!("{}{}", stdout, stderr))
|
||||
Ok(GitPushResult {
|
||||
status: "ok".to_string(),
|
||||
message: "Pushed to remote".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -227,6 +293,104 @@ mod tests {
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_non_fast_forward() {
|
||||
let stderr = r#"To github.com:user/repo.git
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'github.com:user/repo.git'
|
||||
hint: Updates were rejected because the remote contains work that you do not
|
||||
hint: have locally."#;
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "rejected");
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_fetch_first() {
|
||||
let stderr = "error: failed to push some refs\nhint: Updates were rejected because the tip of your current branch is behind\nhint: its remote counterpart. Integrate the remote changes (e.g.\nhint: 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\n ! [rejected] main -> main (fetch first)\n";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_auth_failure() {
|
||||
let stderr = "remote: Permission denied to user/repo.git\nfatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "auth_error");
|
||||
assert!(result.message.contains("authentication"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_network() {
|
||||
let stderr = "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "network_error");
|
||||
assert!(result.message.contains("network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown() {
|
||||
let stderr = "error: something unexpected happened\nhint: Try again later";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "error");
|
||||
assert!(result.message.contains("Try again later"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown_no_hint() {
|
||||
let stderr = "error: something totally weird";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "error");
|
||||
assert!(result.message.contains("something totally weird"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_result_serialization() {
|
||||
let result = GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"rejected\""));
|
||||
let parsed: GitPushResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_success_returns_ok() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_rejected_returns_rejected() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
// Both clones commit and push — second push should be rejected
|
||||
fs::write(clone_a.path().join("note.md"), "# A\n").unwrap();
|
||||
git_commit(vp_a, "from A").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
fs::write(clone_b.path().join("note.md"), "# B\n").unwrap();
|
||||
git_commit(vp_b, "from B").unwrap();
|
||||
git_push(vp_b).unwrap();
|
||||
|
||||
// Now A has a new commit but hasn't pulled B's changes
|
||||
fs::write(clone_a.path().join("other.md"), "# Other\n").unwrap();
|
||||
git_commit(vp_a, "from A again").unwrap();
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "rejected");
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_result_serialization() {
|
||||
let result = GitPullResult {
|
||||
|
||||
@@ -646,4 +646,63 @@ mod tests {
|
||||
assert!(titles.contains(&"Default Theme"));
|
||||
assert!(titles.contains(&"Dark Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_visible_removed_from_type_note() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Commit a type note with visible: false
|
||||
create_test_file(
|
||||
vault,
|
||||
"type/topic.md",
|
||||
"---\ntype: Type\nvisible: false\n---\n# Topic\n",
|
||||
);
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime the cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(
|
||||
entries[0].visible,
|
||||
Some(false),
|
||||
"visible must be false initially"
|
||||
);
|
||||
|
||||
// User removes visible field (uncommitted edit)
|
||||
create_test_file(vault, "type/topic.md", "---\ntype: Type\n---\n# Topic\n");
|
||||
|
||||
// Reload — must reflect the removal (visible defaults to None)
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert_eq!(
|
||||
entries2[0].visible, None,
|
||||
"visible must be None after removing the field"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,10 +48,12 @@ import { filterEntries } from './utils/noteListHelpers'
|
||||
import { openLocalFile } from './utils/url'
|
||||
import './App.css'
|
||||
|
||||
// Type declaration for mock content storage
|
||||
// Type declarations for mock content storage and test overrides
|
||||
declare global {
|
||||
interface Window {
|
||||
__mockContent?: Record<string, string>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides
|
||||
__mockHandlers?: Record<string, (args: any) => any>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
114
src/hooks/useAIChat.test.ts
Normal file
114
src/hooks/useAIChat.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
|
||||
// Capture what streamClaudeChat receives
|
||||
const streamClaudeChatMock = vi.fn<
|
||||
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
|
||||
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
|
||||
>()
|
||||
|
||||
vi.mock('../utils/ai-chat', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
|
||||
return {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: call onDone after a tick
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onInit?.('test-session')
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('test-session')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useAIChat } from './useAIChat'
|
||||
|
||||
beforeEach(() => {
|
||||
streamClaudeChatMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
const emptyContent: Record<string, string> = {}
|
||||
|
||||
it('sends first message without history', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
||||
const message = streamClaudeChatMock.mock.calls[0][0]
|
||||
// First message: no history, so just the plain text
|
||||
expect(message).toBe('hello')
|
||||
})
|
||||
|
||||
it('includes conversation history in second message', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('What is Rust?') })
|
||||
// Wait for mock response
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Now messages state has: [user: What is Rust?, assistant: mock response]
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Send second message
|
||||
act(() => { result.current.sendMessage('Tell me more') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
const secondMessage = streamClaudeChatMock.mock.calls[1][0]
|
||||
// Should contain conversation history
|
||||
expect(secondMessage).toContain('What is Rust?')
|
||||
expect(secondMessage).toContain('mock response')
|
||||
expect(secondMessage).toContain('Tell me more')
|
||||
})
|
||||
|
||||
it('resets history on clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send a message and get response
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear conversation
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Send new message — should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start')
|
||||
})
|
||||
|
||||
it('accumulates multiple exchanges in history', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Exchange 1
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 2
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 3
|
||||
act(() => { result.current.sendMessage('Q3') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
||||
const thirdMessage = streamClaudeChatMock.mock.calls[2][0]
|
||||
// Should contain all prior exchanges
|
||||
expect(thirdMessage).toContain('Q1')
|
||||
expect(thirdMessage).toContain('Q2')
|
||||
expect(thirdMessage).toContain('Q3')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
export function useAIChat(
|
||||
@@ -29,9 +30,11 @@ export function useAIChat(
|
||||
abortRef.current = false
|
||||
|
||||
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
|
||||
const history = trimHistory(messages, MAX_HISTORY_TOKENS)
|
||||
const messageWithHistory = formatMessageWithHistory(history, text.trim())
|
||||
let accumulated = ''
|
||||
|
||||
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
|
||||
streamClaudeChat(messageWithHistory, systemPrompt || undefined, sessionIdRef.current, {
|
||||
onInit: (sid) => { sessionIdRef.current = sid },
|
||||
|
||||
onText: (chunk) => {
|
||||
@@ -58,7 +61,7 @@ export function useAIChat(
|
||||
}).then(sid => {
|
||||
if (sid) sessionIdRef.current = sid
|
||||
})
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
}, [isStreaming, allContent, contextNotes, messages])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
|
||||
@@ -25,8 +25,8 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null, sort: null,
|
||||
order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
|
||||
@@ -290,6 +290,8 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['trashed', true, { trashed: true }],
|
||||
['order', 5, { order: 5 }],
|
||||
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
|
||||
['visible', false, { visible: false }],
|
||||
['visible', true, { visible: null }],
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
'maps %s update to correct entry field',
|
||||
(key, value, expected) => {
|
||||
@@ -321,6 +323,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', { archived: false }],
|
||||
['order', { order: null }],
|
||||
['template', { template: null }],
|
||||
['visible', { visible: null }],
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
(key, expected) => {
|
||||
|
||||
@@ -150,7 +150,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
template: { template: null }, sort: { sort: null },
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
@@ -170,6 +170,7 @@ export function frontmatterToEntryPatch(
|
||||
template: { template: str },
|
||||
sort: { sort: str },
|
||||
view: { view: str },
|
||||
visible: { visible: value === false ? false : null },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
|
||||
if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md')
|
||||
if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record<string, string>)?.commitHash}`)
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve('pushed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
@@ -382,6 +382,49 @@ describe('useVaultLoader', () => {
|
||||
|
||||
expect(response).toBe('Committed and pushed')
|
||||
})
|
||||
|
||||
it('returns actionable message when push is rejected', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' })
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
let response = ''
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response).toContain('Pull first')
|
||||
expect(response).not.toBe('Committed and pushed')
|
||||
})
|
||||
|
||||
it('returns network error message on network failure', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' })
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
let response = ''
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response).toContain('network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModifiedFiles', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState, startTransition } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types'
|
||||
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
|
||||
|
||||
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
||||
@@ -18,16 +18,12 @@ async function loadVaultData(vaultPath: string) {
|
||||
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
await mockInvoke<string>('git_commit', { message })
|
||||
await mockInvoke<string>('git_push', {})
|
||||
return 'Committed and pushed'
|
||||
const pushResult = await mockInvoke<GitPushResult>('git_push', {})
|
||||
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
|
||||
}
|
||||
await invoke<string>('git_commit', { vaultPath, message })
|
||||
try {
|
||||
await invoke<string>('git_push', { vaultPath })
|
||||
return 'Committed and pushed'
|
||||
} catch {
|
||||
return 'Committed (push failed)'
|
||||
}
|
||||
const pushResult = await invoke<GitPushResult>('git_push', { vaultPath })
|
||||
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
|
||||
}
|
||||
|
||||
function useNewNoteTracker() {
|
||||
|
||||
@@ -14,9 +14,10 @@ export function isTauri(): boolean {
|
||||
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
|
||||
}
|
||||
|
||||
// Initialize window.__mockContent for browser testing
|
||||
// Initialize window globals for browser testing and Playwright overrides
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mockContent = MOCK_CONTENT
|
||||
window.__mockHandlers = mockHandlers
|
||||
}
|
||||
|
||||
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Each handler simulates a Tauri backend command.
|
||||
*/
|
||||
|
||||
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
|
||||
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { MOCK_ENTRIES } from './mock-entries'
|
||||
|
||||
@@ -172,7 +172,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
get_build_number: () => 'bDEV',
|
||||
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
|
||||
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
|
||||
git_push: () => 'Everything up-to-date',
|
||||
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
|
||||
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
|
||||
const limit = args.limit ?? 30
|
||||
const ts = Math.floor(Date.now() / 1000)
|
||||
|
||||
@@ -78,6 +78,11 @@ export interface GitPullResult {
|
||||
conflictFiles: string[]
|
||||
}
|
||||
|
||||
export interface GitPushResult {
|
||||
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict'
|
||||
|
||||
export interface DeviceFlowStart {
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock('../mock-tauri', () => ({
|
||||
import {
|
||||
estimateTokens, buildSystemPrompt,
|
||||
nextMessageId, checkClaudeCli, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory,
|
||||
type ChatMessage, MAX_HISTORY_TOKENS,
|
||||
} from './ai-chat'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -73,6 +75,107 @@ describe('checkClaudeCli', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- trimHistory ---
|
||||
|
||||
describe('trimHistory', () => {
|
||||
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
|
||||
role, content, id: `msg-${content}`,
|
||||
})
|
||||
|
||||
it('returns empty array for empty history', () => {
|
||||
expect(trimHistory([], 1000)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all messages when under token limit', () => {
|
||||
const history = [msg('user', 'hi'), msg('assistant', 'hello')]
|
||||
expect(trimHistory(history, 1000)).toEqual(history)
|
||||
})
|
||||
|
||||
it('drops oldest messages when over token limit', () => {
|
||||
const history = [
|
||||
msg('user', 'a'.repeat(400)), // 100 tokens
|
||||
msg('assistant', 'b'.repeat(400)), // 100 tokens
|
||||
msg('user', 'c'.repeat(400)), // 100 tokens
|
||||
]
|
||||
const result = trimHistory(history, 200)
|
||||
// Should keep the two most recent messages (200 tokens)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].content).toBe('b'.repeat(400))
|
||||
expect(result[1].content).toBe('c'.repeat(400))
|
||||
})
|
||||
|
||||
it('keeps at least one message if it fits', () => {
|
||||
const history = [
|
||||
msg('user', 'a'.repeat(2000)), // 500 tokens
|
||||
msg('assistant', 'b'.repeat(80)), // 20 tokens
|
||||
]
|
||||
const result = trimHistory(history, 30)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toBe('b'.repeat(80))
|
||||
})
|
||||
|
||||
it('returns empty when single message exceeds limit', () => {
|
||||
const history = [msg('user', 'a'.repeat(4000))] // 1000 tokens
|
||||
expect(trimHistory(history, 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// --- formatMessageWithHistory ---
|
||||
|
||||
describe('formatMessageWithHistory', () => {
|
||||
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
|
||||
role, content, id: `msg-${content}`,
|
||||
})
|
||||
|
||||
it('returns bare message when no history', () => {
|
||||
expect(formatMessageWithHistory([], 'hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('includes conversation history before the new message', () => {
|
||||
const history = [msg('user', 'What is Rust?'), msg('assistant', 'A systems language.')]
|
||||
const result = formatMessageWithHistory(history, 'How does it compare to Go?')
|
||||
expect(result).toContain('What is Rust?')
|
||||
expect(result).toContain('A systems language.')
|
||||
expect(result).toContain('How does it compare to Go?')
|
||||
})
|
||||
|
||||
it('labels user and assistant messages correctly', () => {
|
||||
const history = [msg('user', 'Q1'), msg('assistant', 'A1')]
|
||||
const result = formatMessageWithHistory(history, 'Q2')
|
||||
expect(result).toContain('[user]: Q1')
|
||||
expect(result).toContain('[assistant]: A1')
|
||||
expect(result).toContain('[user]: Q2')
|
||||
})
|
||||
|
||||
it('preserves message order', () => {
|
||||
const history = [
|
||||
msg('user', 'first'),
|
||||
msg('assistant', 'second'),
|
||||
msg('user', 'third'),
|
||||
msg('assistant', 'fourth'),
|
||||
]
|
||||
const result = formatMessageWithHistory(history, 'fifth')
|
||||
const firstIdx = result.indexOf('first')
|
||||
const secondIdx = result.indexOf('second')
|
||||
const thirdIdx = result.indexOf('third')
|
||||
const fourthIdx = result.indexOf('fourth')
|
||||
const fifthIdx = result.indexOf('fifth')
|
||||
expect(firstIdx).toBeLessThan(secondIdx)
|
||||
expect(secondIdx).toBeLessThan(thirdIdx)
|
||||
expect(thirdIdx).toBeLessThan(fourthIdx)
|
||||
expect(fourthIdx).toBeLessThan(fifthIdx)
|
||||
})
|
||||
})
|
||||
|
||||
// --- MAX_HISTORY_TOKENS ---
|
||||
|
||||
describe('MAX_HISTORY_TOKENS', () => {
|
||||
it('is a reasonable token limit', () => {
|
||||
expect(MAX_HISTORY_TOKENS).toBeGreaterThan(10_000)
|
||||
expect(MAX_HISTORY_TOKENS).toBeLessThan(200_000)
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeChat ---
|
||||
|
||||
describe('streamClaudeChat', () => {
|
||||
|
||||
@@ -76,6 +76,34 @@ export function nextMessageId(): string {
|
||||
return `msg-${++msgIdCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
// --- Conversation history ---
|
||||
|
||||
/** Max tokens of history to include in each request. */
|
||||
export const MAX_HISTORY_TOKENS = 100_000
|
||||
|
||||
/** Keep the most recent messages that fit within `maxTokens`. Drops oldest first. */
|
||||
export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMessage[] {
|
||||
let tokenCount = 0
|
||||
const result: ChatMessage[] = []
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const tokens = estimateTokens(history[i].content)
|
||||
if (tokenCount + tokens > maxTokens) break
|
||||
result.unshift(history[i])
|
||||
tokenCount += tokens
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Format conversation history + new message into a single prompt for the CLI. */
|
||||
export function formatMessageWithHistory(history: ChatMessage[], newMessage: string): string {
|
||||
if (history.length === 0) return newMessage
|
||||
|
||||
const lines = history.map(m => `[${m.role}]: ${m.content}`)
|
||||
lines.push(`[user]: ${newMessage}`)
|
||||
|
||||
return `<conversation_history>\n${lines.join('\n\n')}\n</conversation_history>\n\nContinue the conversation. Respond only to the latest [user] message.`
|
||||
}
|
||||
|
||||
// --- Claude CLI status ---
|
||||
|
||||
export interface ClaudeCliStatus {
|
||||
|
||||
71
tests/smoke/better-sync-error-ux.spec.ts
Normal file
71
tests/smoke/better-sync-error-ux.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Sync error UX — actionable push error messages', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('rejected push shows actionable "Pull first" message in toast', async ({ page }) => {
|
||||
// Override git_push mock to return a rejected result
|
||||
await page.evaluate(() => {
|
||||
window.__mockHandlers!.git_push = () => ({
|
||||
status: 'rejected',
|
||||
message: 'Push rejected: remote has new commits. Pull first, then push.',
|
||||
})
|
||||
})
|
||||
|
||||
// Open commit dialog via command palette
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Commit & Push')
|
||||
|
||||
// Wait for the CommitDialog textarea
|
||||
const textarea = page.locator('textarea[placeholder="Commit message..."]')
|
||||
await textarea.waitFor({ timeout: 5000 })
|
||||
await textarea.fill('test commit')
|
||||
|
||||
// Click the "Commit & Push" button in the dialog
|
||||
const commitButton = page.getByRole('button', { name: 'Commit & Push' })
|
||||
await commitButton.click()
|
||||
|
||||
// Verify the toast shows the actionable rejection message
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('Pull first', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('auth error push shows authentication message in toast', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
window.__mockHandlers!.git_push = () => ({
|
||||
status: 'auth_error',
|
||||
message: 'Push failed: authentication error. Check your credentials.',
|
||||
})
|
||||
})
|
||||
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Commit & Push')
|
||||
|
||||
const textarea = page.locator('textarea[placeholder="Commit message..."]')
|
||||
await textarea.waitFor({ timeout: 5000 })
|
||||
await textarea.fill('test commit')
|
||||
|
||||
await page.getByRole('button', { name: 'Commit & Push' }).click()
|
||||
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('authentication error', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('successful push shows normal success message', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Commit & Push')
|
||||
|
||||
const textarea = page.locator('textarea[placeholder="Commit message..."]')
|
||||
await textarea.waitFor({ timeout: 5000 })
|
||||
await textarea.fill('test commit')
|
||||
|
||||
await page.getByRole('button', { name: 'Commit & Push' }).click()
|
||||
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('Committed and pushed', { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user