mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Fixes local batch coverage for #810, #811, and #871. - Lock provider form cancellation and duplicate submission while save is in flight. - Persist the active Settings tab while navigating away and back. - Carry an empty-session composer draft onto the replacement session when switching project. Tested: cd desktop && bun run test -- --run src/__tests__/generalSettings.test.tsx src/components/chat/ChatInput.test.tsx Tested: cd desktop && bun run test -- --run src/__tests__/diagnosticsSettings.test.tsx Tested: bun run check:desktop Not-tested: full bun run verify; local handoff only, not PR/push-ready. Confidence: high Scope-risk: narrow
525 lines
17 KiB
TypeScript
525 lines
17 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
import '@testing-library/jest-dom'
|
|
|
|
import { MemorySettings } from '../pages/MemorySettings'
|
|
import { useMemoryStore } from '../stores/memoryStore'
|
|
import { useSessionStore } from '../stores/sessionStore'
|
|
import { useSettingsStore } from '../stores/settingsStore'
|
|
import { useUIStore } from '../stores/uiStore'
|
|
|
|
const { memoryApiMock } = vi.hoisted(() => ({
|
|
memoryApiMock: {
|
|
listProjects: vi.fn(),
|
|
listFiles: vi.fn(),
|
|
readFile: vi.fn(),
|
|
saveFile: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
vi.mock('../api/memory', () => ({
|
|
memoryApi: memoryApiMock,
|
|
}))
|
|
|
|
vi.mock('../components/markdown/MarkdownRenderer', () => ({
|
|
MarkdownRenderer: ({ content, onLinkClick }: { content: string; onLinkClick?: (href: string) => boolean | void }) => {
|
|
const match = content.match(/\[([^\]]+)\]\(([^)]+)\)/)
|
|
const linkText = match?.[1]
|
|
const linkHref = match?.[2]
|
|
return (
|
|
<div data-testid="markdown-preview">
|
|
{content}
|
|
{linkText && linkHref ? (
|
|
<a
|
|
href={linkHref}
|
|
onClick={(event) => {
|
|
if (onLinkClick?.(linkHref)) {
|
|
event.preventDefault()
|
|
}
|
|
}}
|
|
>
|
|
{linkText}
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
)
|
|
},
|
|
}))
|
|
|
|
describe('MemorySettings', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
useSettingsStore.setState({ locale: 'en' })
|
|
useSessionStore.setState({
|
|
sessions: [
|
|
{
|
|
id: 'session-1',
|
|
title: 'Active session',
|
|
createdAt: '2026-05-01T00:00:00.000Z',
|
|
modifiedAt: '2026-05-01T00:00:00.000Z',
|
|
messageCount: 1,
|
|
projectPath: '/workspace/demo',
|
|
workDir: '/workspace/demo',
|
|
workDirExists: true,
|
|
},
|
|
],
|
|
activeSessionId: 'session-1',
|
|
})
|
|
useMemoryStore.setState({
|
|
projects: [],
|
|
files: [],
|
|
selectedProjectId: null,
|
|
selectedFile: null,
|
|
draftContent: '',
|
|
isLoadingProjects: false,
|
|
isLoadingFiles: false,
|
|
isLoadingFile: false,
|
|
isSaving: false,
|
|
error: null,
|
|
lastSavedAt: null,
|
|
})
|
|
useUIStore.setState({ activeSettingsTab: 'providers', pendingMemoryPath: null, pendingSettingsTab: null })
|
|
|
|
memoryApiMock.listProjects.mockResolvedValue({
|
|
projects: [
|
|
{
|
|
id: '-workspace-demo',
|
|
label: '/workspace/demo',
|
|
memoryDir: '/tmp/claude/projects/-workspace-demo/memory',
|
|
exists: true,
|
|
fileCount: 1,
|
|
isCurrent: true,
|
|
},
|
|
],
|
|
})
|
|
memoryApiMock.listFiles.mockResolvedValue({
|
|
files: [
|
|
{
|
|
path: 'MEMORY.md',
|
|
name: 'MEMORY.md',
|
|
title: 'MEMORY.md',
|
|
bytes: 18,
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
type: 'project',
|
|
description: 'Project conventions.',
|
|
isIndex: true,
|
|
},
|
|
],
|
|
})
|
|
memoryApiMock.readFile.mockResolvedValue({
|
|
file: {
|
|
path: 'MEMORY.md',
|
|
content: '# Project Memory\n',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
bytes: 18,
|
|
},
|
|
})
|
|
memoryApiMock.saveFile.mockResolvedValue({
|
|
ok: true,
|
|
file: {
|
|
path: 'MEMORY.md',
|
|
updatedAt: '2026-05-01T00:01:00.000Z',
|
|
bytes: 28,
|
|
},
|
|
})
|
|
})
|
|
|
|
it('opens memory files in preview mode, edits on demand, and returns to preview after save', async () => {
|
|
render(<MemorySettings />)
|
|
|
|
expect(await screen.findByText('Project Memory')).toBeInTheDocument()
|
|
expect(memoryApiMock.listProjects).toHaveBeenCalledWith('/workspace/demo')
|
|
expect(await screen.findAllByText('workspace/demo')).not.toHaveLength(0)
|
|
expect(await screen.findAllByText('MEMORY.md')).not.toHaveLength(0)
|
|
expect(screen.queryByText('Project conventions.')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('Index')).not.toBeInTheDocument()
|
|
expect(screen.queryByPlaceholderText('MEMORY.md or notes/project.md')).not.toBeInTheDocument()
|
|
expect(screen.queryByRole('button', { name: /create memory file/i })).not.toBeInTheDocument()
|
|
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory')
|
|
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
|
|
|
const editor = await screen.findByLabelText('Editor')
|
|
expect(editor).toHaveValue('# Project Memory\n')
|
|
|
|
fireEvent.change(editor, {
|
|
target: { value: '# Project Memory\n\n- Prefer small diffs.\n' },
|
|
})
|
|
expect(screen.getByText('Unsaved')).toBeInTheDocument()
|
|
expect(screen.queryByTestId('markdown-preview')).not.toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /save/i }))
|
|
|
|
await waitFor(() => {
|
|
expect(memoryApiMock.saveFile).toHaveBeenCalledWith({
|
|
projectId: '-workspace-demo',
|
|
path: 'MEMORY.md',
|
|
content: '# Project Memory\n\n- Prefer small diffs.\n',
|
|
})
|
|
})
|
|
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Prefer small diffs')
|
|
})
|
|
|
|
it('does not select a missing current project with no memory files', async () => {
|
|
memoryApiMock.listProjects.mockResolvedValue({
|
|
projects: [
|
|
{
|
|
id: '-programs-claude-code',
|
|
label: 'C:\\Programs\\claude-code',
|
|
memoryDir: 'C:\\Users\\HUAWEI\\.claude\\projects\\-programs-claude-code\\memory',
|
|
exists: false,
|
|
fileCount: 0,
|
|
isCurrent: true,
|
|
},
|
|
],
|
|
})
|
|
|
|
render(<MemorySettings />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByText('Programs/claude-code')).toHaveLength(1)
|
|
})
|
|
expect(screen.getByText('Missing')).toBeInTheDocument()
|
|
expect(screen.getAllByText('No file selected').length).toBeGreaterThan(0)
|
|
expect(screen.getByText('Select a project.')).toBeInTheDocument()
|
|
expect(screen.queryByText(/HUAWEI/)).not.toBeInTheDocument()
|
|
expect(memoryApiMock.listFiles).not.toHaveBeenCalled()
|
|
expect(useMemoryStore.getState().selectedProjectId).toBeNull()
|
|
})
|
|
|
|
it('lets the markdown editor fill the remaining detail pane height', async () => {
|
|
render(<MemorySettings />)
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
|
|
|
|
const editor = await screen.findByLabelText('Editor')
|
|
expect(editor).toHaveClass('min-h-0', 'flex-1', 'resize-none')
|
|
expect(editor.parentElement).toHaveClass('flex', 'min-h-0', 'flex-1', 'flex-col')
|
|
})
|
|
|
|
it('cancels edit mode by discarding the unsaved draft', async () => {
|
|
render(<MemorySettings />)
|
|
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
|
|
|
const editor = await screen.findByLabelText('Editor')
|
|
fireEvent.change(editor, {
|
|
target: { value: '# Changed Memory\n' },
|
|
})
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
|
|
|
expect(memoryApiMock.saveFile).not.toHaveBeenCalled()
|
|
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory')
|
|
expect(screen.queryByText('Changed Memory')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('keeps unsaved edits when file switching is not confirmed', async () => {
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
|
memoryApiMock.listFiles.mockResolvedValue({
|
|
files: [
|
|
{
|
|
path: 'MEMORY.md',
|
|
name: 'MEMORY.md',
|
|
title: 'MEMORY.md',
|
|
bytes: 18,
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
type: 'project',
|
|
description: 'Project conventions.',
|
|
isIndex: true,
|
|
},
|
|
{
|
|
path: 'notes/manual.md',
|
|
name: 'manual.md',
|
|
title: 'Manual',
|
|
bytes: 42,
|
|
updatedAt: '2026-05-01T00:02:00.000Z',
|
|
type: 'guidance',
|
|
description: 'Operator workflow.',
|
|
isIndex: false,
|
|
},
|
|
],
|
|
})
|
|
memoryApiMock.readFile.mockImplementation((_projectId: string, path: string) => Promise.resolve({
|
|
file: {
|
|
path,
|
|
content: path === 'notes/manual.md' ? '# Manual\n' : '# Project Memory\n',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
bytes: 18,
|
|
},
|
|
}))
|
|
|
|
render(<MemorySettings />)
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
|
|
const editor = await screen.findByLabelText('Editor')
|
|
fireEvent.change(editor, {
|
|
target: { value: '# Unsaved Memory\n' },
|
|
})
|
|
|
|
fireEvent.click(screen.getByText('Manual'))
|
|
|
|
expect(confirmSpy).toHaveBeenCalled()
|
|
expect(memoryApiMock.readFile).not.toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
|
|
expect(screen.getByLabelText('Editor')).toHaveValue('# Unsaved Memory\n')
|
|
|
|
confirmSpy.mockRestore()
|
|
})
|
|
|
|
it('saves with the platform shortcut while editing and returns to preview mode', async () => {
|
|
render(<MemorySettings />)
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit' }))
|
|
const editor = await screen.findByLabelText('Editor')
|
|
fireEvent.change(editor, {
|
|
target: { value: '# Project Memory\n\n- Shortcut save.\n' },
|
|
})
|
|
|
|
fireEvent.keyDown(document, { key: 's', metaKey: true })
|
|
|
|
await waitFor(() => {
|
|
expect(memoryApiMock.saveFile).toHaveBeenCalledWith({
|
|
projectId: '-workspace-demo',
|
|
path: 'MEMORY.md',
|
|
content: '# Project Memory\n\n- Shortcut save.\n',
|
|
})
|
|
})
|
|
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Shortcut save')
|
|
})
|
|
|
|
it('filters the unified resource tree by project path', async () => {
|
|
memoryApiMock.listProjects.mockResolvedValue({
|
|
projects: [
|
|
{
|
|
id: '-workspace-alpha',
|
|
label: '/workspace/alpha',
|
|
memoryDir: '/tmp/claude/projects/-workspace-alpha/memory',
|
|
exists: true,
|
|
fileCount: 1,
|
|
isCurrent: true,
|
|
},
|
|
{
|
|
id: '-workspace-beta',
|
|
label: '/workspace/beta',
|
|
memoryDir: '/tmp/claude/projects/-workspace-beta/memory',
|
|
exists: true,
|
|
fileCount: 2,
|
|
isCurrent: false,
|
|
},
|
|
],
|
|
})
|
|
|
|
render(<MemorySettings />)
|
|
|
|
expect(await screen.findAllByText('workspace/alpha')).not.toHaveLength(0)
|
|
expect(await screen.findByText('workspace/beta')).toBeInTheDocument()
|
|
|
|
fireEvent.change(screen.getByLabelText('Search projects or memory files...'), {
|
|
target: { value: 'beta' },
|
|
})
|
|
|
|
expect(screen.queryByRole('button', { name: /workspace\/alpha/ })).not.toBeInTheDocument()
|
|
expect(screen.getAllByText('workspace/beta').length).toBeGreaterThan(0)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Toggle workspace/beta' }))
|
|
await waitFor(() => {
|
|
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-beta')
|
|
})
|
|
})
|
|
|
|
it('renders nested memory files as a collapsible resource tree', async () => {
|
|
memoryApiMock.listFiles.mockResolvedValue({
|
|
files: [
|
|
{
|
|
path: 'MEMORY.md',
|
|
name: 'MEMORY.md',
|
|
title: 'MEMORY.md',
|
|
bytes: 18,
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
type: 'project',
|
|
description: 'Project conventions.',
|
|
isIndex: true,
|
|
},
|
|
{
|
|
path: 'notes/manual.md',
|
|
name: 'manual.md',
|
|
title: 'Manual',
|
|
bytes: 42,
|
|
updatedAt: '2026-05-01T00:02:00.000Z',
|
|
type: 'guidance',
|
|
description: 'Operator workflow.',
|
|
isIndex: false,
|
|
},
|
|
{
|
|
path: 'notes/archive/old.md',
|
|
name: 'old.md',
|
|
title: 'Old note',
|
|
bytes: 24,
|
|
updatedAt: '2026-05-01T00:03:00.000Z',
|
|
isIndex: false,
|
|
},
|
|
],
|
|
})
|
|
memoryApiMock.readFile.mockImplementation((_projectId: string, path: string) => Promise.resolve({
|
|
file: {
|
|
path,
|
|
content: path === 'notes/manual.md' ? '# Manual\n' : '# Project Memory\n',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
bytes: 18,
|
|
},
|
|
}))
|
|
|
|
render(<MemorySettings />)
|
|
|
|
expect(await screen.findByRole('button', { name: 'Toggle notes' })).toBeInTheDocument()
|
|
expect(screen.getByRole('button', { name: 'Toggle archive' })).toBeInTheDocument()
|
|
expect(screen.getByText('Manual')).toBeInTheDocument()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Toggle notes' }))
|
|
expect(screen.queryByText('Manual')).not.toBeInTheDocument()
|
|
|
|
fireEvent.change(screen.getByLabelText('Search projects or memory files...'), {
|
|
target: { value: 'manual' },
|
|
})
|
|
|
|
expect(screen.getByText('Manual')).toBeInTheDocument()
|
|
fireEvent.click(screen.getByText('Manual'))
|
|
|
|
await waitFor(() => {
|
|
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
|
|
})
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Manual')
|
|
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('opens linked memory markdown files from the rendered preview', async () => {
|
|
memoryApiMock.listFiles.mockResolvedValue({
|
|
files: [
|
|
{
|
|
path: 'MEMORY.md',
|
|
name: 'MEMORY.md',
|
|
title: 'MEMORY.md',
|
|
bytes: 48,
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
type: 'project',
|
|
description: 'Project conventions.',
|
|
isIndex: true,
|
|
},
|
|
{
|
|
path: 'notes/manual.md',
|
|
name: 'manual.md',
|
|
title: 'Manual',
|
|
bytes: 24,
|
|
updatedAt: '2026-05-01T00:02:00.000Z',
|
|
type: 'guidance',
|
|
isIndex: false,
|
|
},
|
|
],
|
|
})
|
|
memoryApiMock.readFile.mockImplementation((_projectId: string, path: string) => Promise.resolve({
|
|
file: {
|
|
path,
|
|
content: path === 'notes/manual.md'
|
|
? '# Manual\n'
|
|
: '# Project Memory\n\n- [Manual](notes/manual.md)\n',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
bytes: 48,
|
|
},
|
|
}))
|
|
|
|
render(<MemorySettings />)
|
|
|
|
fireEvent.click(await screen.findByRole('link', { name: 'Manual' }))
|
|
|
|
await waitFor(() => {
|
|
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-demo', 'notes/manual.md')
|
|
})
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Manual')
|
|
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
|
|
})
|
|
|
|
it('keeps frontmatter editable but removes it from the rendered preview', async () => {
|
|
memoryApiMock.readFile.mockResolvedValue({
|
|
file: {
|
|
path: 'MEMORY.md',
|
|
content: '---\ntype: project\n---\n\n# Project Memory\n',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
bytes: 39,
|
|
},
|
|
})
|
|
|
|
render(<MemorySettings />)
|
|
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Project Memory')
|
|
expect(screen.getByTestId('markdown-preview')).not.toHaveTextContent('type: project')
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
|
|
|
const editor = await screen.findByLabelText('Editor')
|
|
expect(editor).toHaveValue('---\ntype: project\n---\n\n# Project Memory\n')
|
|
})
|
|
|
|
it('opens the exact memory file requested from chat', async () => {
|
|
memoryApiMock.listProjects.mockResolvedValue({
|
|
projects: [
|
|
{
|
|
id: '-workspace-demo',
|
|
label: '/workspace/demo',
|
|
memoryDir: '/tmp/claude/projects/-workspace-demo/memory',
|
|
exists: true,
|
|
fileCount: 0,
|
|
isCurrent: true,
|
|
},
|
|
{
|
|
id: '-workspace-other',
|
|
label: '/workspace/other',
|
|
memoryDir: '/tmp/claude/projects/-workspace-other/memory',
|
|
exists: true,
|
|
fileCount: 1,
|
|
isCurrent: false,
|
|
},
|
|
],
|
|
})
|
|
memoryApiMock.listFiles.mockImplementation((projectId: string) => Promise.resolve({
|
|
files: projectId === '-workspace-other'
|
|
? [
|
|
{
|
|
path: 'preferences.md',
|
|
name: 'preferences.md',
|
|
title: 'preferences.md',
|
|
bytes: 24,
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
type: 'preference',
|
|
isIndex: false,
|
|
},
|
|
]
|
|
: [],
|
|
}))
|
|
memoryApiMock.readFile.mockResolvedValue({
|
|
file: {
|
|
path: 'preferences.md',
|
|
content: '# Preferences\n',
|
|
updatedAt: '2026-05-01T00:00:00.000Z',
|
|
bytes: 24,
|
|
},
|
|
})
|
|
useUIStore.setState({
|
|
pendingMemoryPath: '/tmp/claude/projects/-workspace-other/memory/preferences.md',
|
|
})
|
|
|
|
render(<MemorySettings />)
|
|
|
|
expect(await screen.findByTestId('markdown-preview')).toHaveTextContent('Preferences')
|
|
expect(screen.queryByLabelText('Editor')).not.toBeInTheDocument()
|
|
expect(memoryApiMock.readFile).toHaveBeenCalledWith('-workspace-other', 'preferences.md')
|
|
expect(useMemoryStore.getState().selectedProjectId).toBe('-workspace-other')
|
|
expect(useUIStore.getState().pendingMemoryPath).toBeNull()
|
|
})
|
|
})
|