eigent/test/unit/components/SettingsMemory.test.tsx
4pmtong 008c0d28e0
Some checks are pending
Pre-commit / pre-commit (push) Waiting to run
Test / Run Web + Local Brain Smoke (push) Waiting to run
Test / Run Frontend Guardrails (push) Waiting to run
Test / Run Python Tests (push) Waiting to run
feat: automate scoped memory extraction
2026-08-20 00:38:08 +08:00

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