mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-28 10:21:17 +00:00
539 lines
16 KiB
TypeScript
539 lines
16 KiB
TypeScript
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { MemoryRouter, useLocation } from 'react-router-dom';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const api = vi.hoisted(() => ({
|
|
list: vi.fn(),
|
|
listSummaries: vi.fn(),
|
|
listReconciliation: vi.fn(),
|
|
resolveReconciliation: vi.fn(),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
settings: vi.fn(),
|
|
consolidate: vi.fn(),
|
|
remove: vi.fn(),
|
|
confirm: vi.fn(),
|
|
pin: vi.fn(),
|
|
restore: vi.fn(),
|
|
}));
|
|
|
|
const spaceStoreState = vi.hoisted(() => ({
|
|
activeSpaceId: 'space-1',
|
|
spaces: {
|
|
'space-1': {
|
|
id: 'space-1',
|
|
name: 'Design Space',
|
|
description: 'Product design work',
|
|
sourceType: 'folder' as const,
|
|
rootPath: '/work/design',
|
|
status: 'active' as const,
|
|
schemaVersion: 1,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
},
|
|
projectsBySpaceId: {
|
|
'space-1': {
|
|
'project-1': {
|
|
id: 'project-1',
|
|
spaceId: 'space-1',
|
|
name: 'Launch Plan',
|
|
status: 'active' as const,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/service/memoryApi', async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
listMemoryEntries: api.list,
|
|
listMemoryScopeSummaries: api.listSummaries,
|
|
listMemoryReconciliation: api.listReconciliation,
|
|
resolveMemoryReconciliation: api.resolveReconciliation,
|
|
createMemoryEntry: api.create,
|
|
updateMemoryEntry: api.update,
|
|
updateMemoryScopeSettings: api.settings,
|
|
consolidateMemoryScope: api.consolidate,
|
|
archiveMemoryEntry: api.remove,
|
|
confirmMemoryEntry: api.confirm,
|
|
pinMemoryEntry: api.pin,
|
|
restoreMemoryEntry: api.restore,
|
|
}));
|
|
vi.mock('@/store/projectStore', () => ({
|
|
useProjectStore: (selector: (state: object) => unknown) =>
|
|
selector({ activeProjectId: 'project-1' }),
|
|
}));
|
|
vi.mock('@/store/spaceStore', async (importOriginal) => ({
|
|
...(await importOriginal<object>()),
|
|
useSpaceStore: (selector: (state: object) => unknown) =>
|
|
selector(spaceStoreState),
|
|
}));
|
|
vi.mock('@/store/authStore', () => ({
|
|
getAuthStore: () => ({ user_id: 'user-1', token: null }),
|
|
useAuthStore: (selector: (state: object) => unknown) =>
|
|
selector({ user_id: 'user-1' }),
|
|
}));
|
|
|
|
import Memory from '@/components/Settings/Memory';
|
|
|
|
function LocationProbe() {
|
|
const location = useLocation();
|
|
return (
|
|
<output data-testid="memory-location">
|
|
{location.pathname}
|
|
{location.search}
|
|
</output>
|
|
);
|
|
}
|
|
|
|
const scopeState = {
|
|
scope_type: 'user',
|
|
scope_id: 'user-1',
|
|
owner_kind: 'desktop',
|
|
revision: 1,
|
|
capture_enabled: true,
|
|
use_enabled: true,
|
|
sync_scope: 'full_memory',
|
|
token_limit: 1024,
|
|
current_token_count: 10,
|
|
consolidate_threshold: 0.75,
|
|
processed_through_watermark: 'sqlite-project-v1:4',
|
|
extractor_version: 'memory-v2',
|
|
last_consolidated_at: null,
|
|
last_error: null,
|
|
updated_at: 1,
|
|
};
|
|
|
|
describe('Memory Center', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
api.list.mockResolvedValue({
|
|
scope_state: scopeState,
|
|
items: [],
|
|
sync_status: {
|
|
state: 'synced',
|
|
pending_count: 0,
|
|
blocked_count: 0,
|
|
last_error: null,
|
|
last_synced_at: 1,
|
|
},
|
|
});
|
|
api.create.mockResolvedValue({});
|
|
api.listSummaries.mockImplementation(
|
|
(scopes: Array<{ scopeType: 'space' | 'project'; scopeId: string }>) =>
|
|
Promise.resolve({
|
|
items: scopes.map((scope) => ({
|
|
scope_type: scope.scopeType,
|
|
scope_id: scope.scopeId,
|
|
entry_count: 1,
|
|
scope_state: {
|
|
...scopeState,
|
|
scope_type: scope.scopeType,
|
|
scope_id: scope.scopeId,
|
|
},
|
|
})),
|
|
})
|
|
);
|
|
api.settings.mockResolvedValue({
|
|
...scopeState,
|
|
revision: 2,
|
|
capture_enabled: false,
|
|
});
|
|
api.consolidate.mockResolvedValue({});
|
|
api.remove.mockResolvedValue({});
|
|
api.listReconciliation.mockResolvedValue({ items: [] });
|
|
api.resolveReconciliation.mockResolvedValue({});
|
|
});
|
|
|
|
it('offers bounded organization without exposing a sync chooser', async () => {
|
|
api.list.mockResolvedValue({
|
|
scope_state: scopeState,
|
|
items: [
|
|
{ memory_id: 'one', deleted_at: null },
|
|
{ memory_id: 'two', deleted_at: null },
|
|
],
|
|
sync_status: { state: 'synced' },
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<Memory />);
|
|
|
|
await screen.findByText(/Up to date on your Eigent account/);
|
|
expect(screen.queryByRole('combobox', { name: /sync/i })).toBeNull();
|
|
const organize = screen.getByRole('button', { name: /Organise/ });
|
|
await waitFor(() => expect(organize).toBeEnabled());
|
|
await user.click(organize);
|
|
|
|
expect(api.consolidate).toHaveBeenCalledWith('user', 'user-1');
|
|
});
|
|
|
|
it('ignores a stale scope response after the user switches scope', async () => {
|
|
let resolveUser!: (value: unknown) => void;
|
|
api.list.mockImplementation((scopeType: string) => {
|
|
if (scopeType === 'user') {
|
|
return new Promise((resolve) => {
|
|
resolveUser = resolve;
|
|
});
|
|
}
|
|
return Promise.resolve({
|
|
scope_state: {
|
|
...scopeState,
|
|
scope_type: 'space',
|
|
scope_id: 'space-1',
|
|
},
|
|
items: [
|
|
{
|
|
memory_id: 'space-memory',
|
|
scope_type: 'space',
|
|
scope_id: 'space-1',
|
|
kind: 'fact',
|
|
content: 'Space response',
|
|
priority: 'normal',
|
|
version: 1,
|
|
token_count: 2,
|
|
pinned_by_user: false,
|
|
confirmed_by_user: true,
|
|
created_by: 'user',
|
|
source_trust: 'user_confirmed',
|
|
sensitivity: 'normal',
|
|
source_refs: [],
|
|
deleted_at: null,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
const user = userEvent.setup();
|
|
render(<Memory />);
|
|
|
|
await user.click(screen.getByRole('tab', { name: 'Space' }));
|
|
expect(await screen.findByText('Space response')).toBeInTheDocument();
|
|
resolveUser({
|
|
scope_state: scopeState,
|
|
items: [{ content: 'Stale user response' }],
|
|
});
|
|
|
|
await waitFor(() =>
|
|
expect(screen.queryByText('Stale user response')).toBeNull()
|
|
);
|
|
});
|
|
|
|
it('explains the History boundary and creates editable Memory', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Memory />);
|
|
|
|
expect(
|
|
screen.getByText(/Canonical task history is stored separately/)
|
|
).toBeInTheDocument();
|
|
await waitFor(() =>
|
|
expect(api.list).toHaveBeenCalledWith('user', 'user-1', false)
|
|
);
|
|
|
|
await user.type(
|
|
screen.getByPlaceholderText(
|
|
'Add a preference, constraint, or fact for Eigent to remember'
|
|
),
|
|
'Use ISO dates.'
|
|
);
|
|
await user.click(screen.getByRole('button', { name: /Add/ }));
|
|
await waitFor(() =>
|
|
expect(api.create).toHaveBeenCalledWith(
|
|
'user',
|
|
'user-1',
|
|
expect.objectContaining({ content: 'Use ISO dates.' })
|
|
)
|
|
);
|
|
});
|
|
|
|
it('uses scope pills, one-row controls, and saved Memory tools', async () => {
|
|
api.list.mockResolvedValue({
|
|
scope_state: scopeState,
|
|
items: [
|
|
{
|
|
memory_id: 'memory-visible',
|
|
scope_type: 'user',
|
|
scope_id: 'user-1',
|
|
kind: 'fact',
|
|
content: 'Visible note',
|
|
priority: 'normal',
|
|
version: 1,
|
|
token_count: 2,
|
|
pinned_by_user: false,
|
|
confirmed_by_user: true,
|
|
created_by: 'user',
|
|
source_trust: 'user_confirmed',
|
|
sensitivity: 'normal',
|
|
source_refs: [],
|
|
deleted_at: null,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
},
|
|
],
|
|
sync_status: { state: 'synced' },
|
|
});
|
|
render(<Memory />);
|
|
|
|
await screen.findByText('Synced');
|
|
expect(
|
|
screen.getByRole('tablist', { name: 'Memory scope' })
|
|
).toBeInTheDocument();
|
|
expect(screen.getAllByRole('tab')).toHaveLength(3);
|
|
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
|
|
'User',
|
|
'Space',
|
|
'Project',
|
|
]);
|
|
expect(screen.getByRole('tab', { name: 'User' })).toHaveAttribute(
|
|
'data-state',
|
|
'active'
|
|
);
|
|
expect(screen.getByRole('switch', { name: 'Auto Memory' })).toBeChecked();
|
|
expect(
|
|
screen.getByRole('switch', { name: 'Use Personal Memory' })
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole('status', { name: '' })).toHaveTextContent(
|
|
'Synced'
|
|
);
|
|
const storageProgress = screen.getByRole('progressbar', {
|
|
name: 'Memory storage used',
|
|
});
|
|
expect(storageProgress).toHaveClass('bg-ds-bg-neutral-subtle-default');
|
|
expect(storageProgress.firstElementChild).toHaveClass(
|
|
'bg-ds-bg-brand-default-default'
|
|
);
|
|
expect(
|
|
screen.getByRole('textbox', { name: 'New Memory' })
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole('combobox', { name: 'Memory type' })
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole('combobox', { name: 'Order Memory' })
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole('button', { name: 'Show archived' })
|
|
).toHaveAttribute('aria-pressed', 'false');
|
|
});
|
|
|
|
it('shows searchable Space and Project Memory directories', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<MemoryRouter initialEntries={['/home?section=settings&tab=memory']}>
|
|
<Memory homeOverview />
|
|
<LocationProbe />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
expect(screen.getByRole('tab', { name: 'User' })).toHaveAttribute(
|
|
'data-state',
|
|
'active'
|
|
);
|
|
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([
|
|
'User',
|
|
'Space',
|
|
'Project',
|
|
]);
|
|
await waitFor(() =>
|
|
expect(api.list).toHaveBeenCalledWith('user', 'user-1', false)
|
|
);
|
|
const listCallCount = api.list.mock.calls.length;
|
|
|
|
await user.click(screen.getByRole('tab', { name: 'Space' }));
|
|
expect(await screen.findByText('Design Space')).toBeVisible();
|
|
expect(screen.getByText('1 Project')).toBeVisible();
|
|
expect(
|
|
screen.queryByRole('textbox', { name: 'New Memory' })
|
|
).not.toBeInTheDocument();
|
|
expect(api.list).toHaveBeenCalledTimes(listCallCount);
|
|
await user.click(
|
|
screen.getByRole('button', { name: 'Manage Memory for Design Space' })
|
|
);
|
|
expect(screen.getByTestId('memory-location')).toHaveTextContent(
|
|
'/home?section=spaces&spaceId=space-1&spaceTab=memory'
|
|
);
|
|
|
|
await user.click(screen.getByRole('tab', { name: 'Project' }));
|
|
expect(await screen.findByText('Launch Plan')).toBeVisible();
|
|
expect(screen.getByText('Space: Design Space')).toBeVisible();
|
|
expect(api.list).toHaveBeenCalledTimes(listCallCount);
|
|
await user.click(
|
|
screen.getByRole('button', { name: 'Manage Memory for Launch Plan' })
|
|
);
|
|
expect(screen.getByTestId('memory-location')).toHaveTextContent(
|
|
'/home?section=spaces&spaceId=space-1&spaceTab=memory&memoryScope=project&projectId=project-1'
|
|
);
|
|
});
|
|
|
|
it('locks the Space detail Memory view to the selected Space', async () => {
|
|
render(
|
|
<Memory
|
|
fixedScope={{ type: 'space', id: 'selected-space' }}
|
|
showScopeSelector={false}
|
|
/>
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(api.list).toHaveBeenCalledWith('space', 'selected-space', false)
|
|
);
|
|
expect(
|
|
screen.queryByRole('tablist', { name: 'Memory scope' })
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('updates Auto Memory immediately', async () => {
|
|
const user = userEvent.setup();
|
|
render(<Memory />);
|
|
|
|
await screen.findByText('Synced');
|
|
const autoMemory = await screen.findByRole('switch', {
|
|
name: 'Auto Memory',
|
|
});
|
|
expect(autoMemory).toBeEnabled();
|
|
expect(autoMemory).toBeChecked();
|
|
|
|
await user.click(autoMemory);
|
|
|
|
expect(autoMemory).not.toBeChecked();
|
|
expect(api.settings).toHaveBeenCalledWith('user', 'user-1', {
|
|
expectedRevision: 1,
|
|
captureEnabled: false,
|
|
});
|
|
});
|
|
|
|
it('shows archived entries only on request and restores them', async () => {
|
|
const archived = {
|
|
memory_id: 'memory-1',
|
|
scope_type: 'user',
|
|
scope_id: 'user-1',
|
|
kind: 'fact',
|
|
content: 'Old fact',
|
|
priority: 'normal',
|
|
version: 2,
|
|
token_count: 2,
|
|
pinned_by_user: false,
|
|
confirmed_by_user: false,
|
|
created_by: 'user',
|
|
source_trust: 'user_confirmed',
|
|
sensitivity: 'normal',
|
|
source_refs: [],
|
|
deleted_at: 2,
|
|
created_at: 1,
|
|
updated_at: 2,
|
|
};
|
|
api.list.mockImplementation(
|
|
(_scopeType: string, _scopeId: string, includeDeleted: boolean) =>
|
|
Promise.resolve({
|
|
scope_state: scopeState,
|
|
items: includeDeleted ? [archived] : [],
|
|
})
|
|
);
|
|
api.restore.mockResolvedValue({});
|
|
const user = userEvent.setup();
|
|
render(<Memory />);
|
|
|
|
await user.click(
|
|
await screen.findByRole('button', { name: 'View archived' })
|
|
);
|
|
expect(
|
|
screen.getByRole('button', { name: 'Hide archived' })
|
|
).toBeInTheDocument();
|
|
expect(await screen.findByText('Old fact')).toBeInTheDocument();
|
|
await user.click(
|
|
screen.getByRole('button', { name: 'More Memory actions' })
|
|
);
|
|
await user.click(
|
|
await screen.findByRole('menuitem', { name: 'Restore Memory' })
|
|
);
|
|
|
|
expect(api.restore).toHaveBeenCalledWith(archived);
|
|
});
|
|
|
|
it('stars Memory from item actions and shows starred state as a non-control', async () => {
|
|
const pinned = {
|
|
memory_id: 'memory-pinned',
|
|
scope_type: 'user',
|
|
scope_id: 'user-1',
|
|
kind: 'fact',
|
|
content: 'Pinned first',
|
|
priority: 'normal',
|
|
version: 2,
|
|
token_count: 2,
|
|
pinned_by_user: true,
|
|
confirmed_by_user: true,
|
|
created_by: 'user',
|
|
source_trust: 'user_confirmed',
|
|
sensitivity: 'normal',
|
|
source_refs: [],
|
|
deleted_at: null,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
};
|
|
const unpinned = {
|
|
...pinned,
|
|
memory_id: 'memory-unpinned',
|
|
content: 'Unpinned second',
|
|
pinned_by_user: false,
|
|
created_at: 2,
|
|
updated_at: 2,
|
|
};
|
|
api.list.mockResolvedValue({
|
|
scope_state: scopeState,
|
|
items: [unpinned, pinned],
|
|
sync_status: { state: 'synced' },
|
|
});
|
|
api.pin.mockResolvedValue({});
|
|
const user = userEvent.setup();
|
|
render(<Memory />);
|
|
|
|
expect(await screen.findByText('Pinned first')).toBeInTheDocument();
|
|
const memoryItems = screen
|
|
.getByText('Pinned first')
|
|
.closest('[data-settings-row]')
|
|
?.querySelectorAll('article');
|
|
expect(memoryItems?.[0]).toHaveTextContent('Pinned first');
|
|
|
|
// Starring is one-way until the backend gains an un-pin transition, so a
|
|
// starred entry renders the filled star as state rather than a control.
|
|
const starredIndicator = screen.getByRole('img', {
|
|
name: 'Starred Memory',
|
|
});
|
|
expect(starredIndicator.querySelector('svg')).toHaveClass('fill-current');
|
|
expect(
|
|
screen.queryByRole('button', { name: 'Unstar Memory' })
|
|
).not.toBeInTheDocument();
|
|
|
|
const starButton = screen.getByRole('button', { name: 'Star Memory' });
|
|
expect(starButton.querySelector('svg')).toHaveClass('fill-none');
|
|
|
|
await user.click(starButton);
|
|
expect(api.pin).toHaveBeenCalledWith(unpinned);
|
|
|
|
// Permanent delete needs a backend endpoint this branch does not ship.
|
|
await user.click(
|
|
screen.getAllByRole('button', { name: 'More Memory actions' })[0]
|
|
);
|
|
expect(
|
|
await screen.findByRole('menuitem', { name: 'Archive Memory' })
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.queryByRole('menuitem', { name: 'Delete Memory' })
|
|
).not.toBeInTheDocument();
|
|
});
|
|
});
|