mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
1978 lines
72 KiB
TypeScript
1978 lines
72 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import type { ComponentProps } from 'react';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { MemorySection } from '../../src/components/MemorySection';
|
|
import { I18nProvider } from '../../src/i18n';
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const originalEventSource = globalThis.EventSource;
|
|
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
|
|
|
class StubEventSource {
|
|
url: string;
|
|
listeners = new Map<string, Array<(event: MessageEvent) => void>>();
|
|
static instances: StubEventSource[] = [];
|
|
constructor(url: string | URL) {
|
|
this.url = String(url);
|
|
StubEventSource.instances.push(this);
|
|
}
|
|
addEventListener(type: string, listener: (event: MessageEvent) => void) {
|
|
const existing = this.listeners.get(type) ?? [];
|
|
existing.push(listener);
|
|
this.listeners.set(type, existing);
|
|
}
|
|
emit(type: string, data: unknown) {
|
|
const event = { data: JSON.stringify(data) } as MessageEvent;
|
|
for (const listener of this.listeners.get(type) ?? []) {
|
|
listener(event);
|
|
}
|
|
}
|
|
close() {}
|
|
}
|
|
|
|
function renderMemorySection(props: Partial<ComponentProps<typeof MemorySection>> = {}) {
|
|
return render(
|
|
<I18nProvider initial="en">
|
|
<MemorySection {...props} />
|
|
</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
async function findMemoryIndexTextarea() {
|
|
const indexDetails = (await screen.findByText('MEMORY.md (index)'))
|
|
.closest('details') as HTMLElement;
|
|
return within(indexDetails).getByRole('textbox') as HTMLTextAreaElement;
|
|
}
|
|
|
|
describe('MemorySection', () => {
|
|
afterEach(() => {
|
|
cleanup();
|
|
globalThis.fetch = originalFetch;
|
|
StubEventSource.instances = [];
|
|
if (originalEventSource) {
|
|
globalThis.EventSource = originalEventSource;
|
|
} else {
|
|
// @ts-expect-error jsdom shim cleanup
|
|
delete globalThis.EventSource;
|
|
}
|
|
vi.restoreAllMocks();
|
|
if (originalScrollIntoView) {
|
|
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
|
} else {
|
|
delete (HTMLElement.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
|
}
|
|
window.sessionStorage.clear();
|
|
});
|
|
|
|
it('shows the no-provider banner when the latest extraction skipped for missing credentials', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({
|
|
extractions: [
|
|
{
|
|
id: 'ex-1',
|
|
phase: 'skipped',
|
|
reason: 'no-provider',
|
|
kind: 'llm',
|
|
startedAt: Date.now(),
|
|
userMessagePreview: 'Remember my UI preferences',
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
expect(await screen.findByText('LLM memory extraction is not running')).toBeTruthy();
|
|
expect(
|
|
screen.getByText(/No API key found for the memory extractor/i),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('creates a new memory entry and refreshes the list', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
let entries = [] as Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
type: string;
|
|
updatedAt: number;
|
|
}>;
|
|
const createBodies: unknown[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries,
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory' && init?.method === 'POST') {
|
|
const body = JSON.parse(String(init.body));
|
|
createBodies.push(body);
|
|
entries = [
|
|
{
|
|
id: 'user_ui_preferences',
|
|
name: body.name,
|
|
description: body.description,
|
|
type: body.type,
|
|
updatedAt: Date.now(),
|
|
},
|
|
];
|
|
return new Response(JSON.stringify({
|
|
entry: {
|
|
id: 'user_ui_preferences',
|
|
name: body.name,
|
|
description: body.description,
|
|
type: body.type,
|
|
body: body.body,
|
|
updatedAt: Date.now(),
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'New memory' }));
|
|
fireEvent.change(screen.getByPlaceholderText('e.g. UI preferences'), {
|
|
target: { value: 'UI preferences' },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText('One sentence — what is this memory about?'), {
|
|
target: { value: 'Persistent UI rendering preferences' },
|
|
});
|
|
fireEvent.change(
|
|
screen.getByPlaceholderText(/- Rule one[\s\S]*When to apply: optional scope/),
|
|
{
|
|
target: { value: '- Prefer dark mode\n- Prefer generous spacing' },
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('UI preferences')).toBeTruthy();
|
|
});
|
|
expect(screen.getByText('✓ Memory created')).toBeTruthy();
|
|
expect(createBodies).toEqual([
|
|
{
|
|
name: 'UI preferences',
|
|
description: 'Persistent UI rendering preferences',
|
|
type: 'user',
|
|
body: '- Prefer dark mode\n- Prefer generous spacing',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('renders the memory tree and opens an entry editor from a tree node', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const entry = {
|
|
id: 'project_design_agent_goal',
|
|
name: 'Design agent goal',
|
|
description: 'Open Design should evolve from accepted work',
|
|
type: 'project',
|
|
body: '- Keep design-system extraction in the loop',
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [entry],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/tree') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
tree: [
|
|
{
|
|
id: 'folder:project',
|
|
parentId: null,
|
|
path: '/project',
|
|
name: 'Project',
|
|
kind: 'folder',
|
|
type: 'project',
|
|
scope: 'project',
|
|
sourcePacketIds: [],
|
|
proposalIds: [],
|
|
createdAt: new Date(entry.updatedAt).toISOString(),
|
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
childrenCount: 1,
|
|
},
|
|
{
|
|
id: entry.id,
|
|
parentId: 'folder:project',
|
|
path: `/project/${entry.id}`,
|
|
name: entry.name,
|
|
description: entry.description,
|
|
kind: 'entry',
|
|
type: 'project',
|
|
scope: 'project',
|
|
sourcePacketIds: [],
|
|
proposalIds: [],
|
|
createdAt: new Date(entry.updatedAt).toISOString(),
|
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
childrenCount: 0,
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === `/api/memory/${entry.id}`) {
|
|
return new Response(JSON.stringify({ entry }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const treeSummary = await screen.findByText('Memory tree');
|
|
const treeDetails = treeSummary.closest('details')!;
|
|
expect(within(treeDetails).getByText('/project')).toBeTruthy();
|
|
expect(within(treeDetails).getByText('project_design_agent_goal')).toBeTruthy();
|
|
|
|
fireEvent.click(within(treeDetails).getByTitle('Edit'));
|
|
|
|
await waitFor(() => {
|
|
expect((screen.getByPlaceholderText('e.g. UI preferences') as HTMLInputElement).value).toBe(
|
|
'Design agent goal',
|
|
);
|
|
});
|
|
expect(screen.getByDisplayValue('- Keep design-system extraction in the loop')).toBeTruthy();
|
|
});
|
|
|
|
it('anchors memory tree entry edit controls in the right-side action zone', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const entry = {
|
|
id: 'project_design_agent_goal',
|
|
name: 'Design agent goal',
|
|
description: 'Open Design should evolve from accepted work',
|
|
type: 'project',
|
|
body: '- Keep design-system extraction in the loop',
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [entry],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/tree') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
tree: [
|
|
{
|
|
id: 'folder:project',
|
|
parentId: null,
|
|
path: '/project',
|
|
name: 'Project',
|
|
kind: 'folder',
|
|
type: 'project',
|
|
scope: 'project',
|
|
sourcePacketIds: [],
|
|
proposalIds: [],
|
|
createdAt: new Date(entry.updatedAt).toISOString(),
|
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
childrenCount: 1,
|
|
},
|
|
{
|
|
id: entry.id,
|
|
parentId: 'folder:project',
|
|
path: `/project/${entry.id}`,
|
|
name: entry.name,
|
|
description: entry.description,
|
|
kind: 'entry',
|
|
type: 'project',
|
|
scope: 'project',
|
|
sourcePacketIds: [],
|
|
proposalIds: [],
|
|
createdAt: new Date(entry.updatedAt).toISOString(),
|
|
updatedAt: new Date(entry.updatedAt).toISOString(),
|
|
childrenCount: 0,
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const treeDetails = (await screen.findByText('Memory tree')).closest('details')!;
|
|
const childRow = within(treeDetails)
|
|
.getByText('Design agent goal')
|
|
.closest('.memory-tree-child-row') as HTMLElement;
|
|
const editButton = within(childRow).getByTitle('Edit');
|
|
const actionZone = editButton.closest('.memory-card-actions');
|
|
const childContent = childRow.firstElementChild as HTMLElement;
|
|
|
|
expect(actionZone).toBeTruthy();
|
|
expect(actionZone?.parentElement).toBe(childRow);
|
|
expect(childContent.contains(editButton)).toBe(false);
|
|
});
|
|
|
|
it('shows unsaved index state and saves the updated index', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
let savedIndex = '# Memory\n\n- Existing bullet\n';
|
|
const putBodies: string[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: savedIndex,
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/index' && init?.method === 'PUT') {
|
|
const body = JSON.parse(String(init.body));
|
|
putBodies.push(body.index);
|
|
savedIndex = body.index;
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
fireEvent.click(await screen.findByText('MEMORY.md (index)'));
|
|
const indexArea = await findMemoryIndexTextarea();
|
|
fireEvent.change(indexArea, {
|
|
target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' },
|
|
});
|
|
|
|
expect(await screen.findByText(/Unsaved changes/i)).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save index' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('✓ Index saved')).toBeTruthy();
|
|
});
|
|
expect(putBodies).toEqual(['# Memory\n\n- Existing bullet\n- New bullet\n']);
|
|
});
|
|
|
|
it('reveals the editor after editing an existing memory entry', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const scrollIntoView = vi.fn();
|
|
HTMLElement.prototype.scrollIntoView = scrollIntoView as Element['scrollIntoView'];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [
|
|
{
|
|
id: 'user_ui_preferences',
|
|
name: 'UI preferences',
|
|
description: 'Persistent UI rendering preferences',
|
|
type: 'user',
|
|
updatedAt: Date.now(),
|
|
},
|
|
],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/user_ui_preferences') {
|
|
return new Response(JSON.stringify({
|
|
entry: {
|
|
id: 'user_ui_preferences',
|
|
name: 'UI preferences',
|
|
description: 'Persistent UI rendering preferences',
|
|
type: 'user',
|
|
body: '- Prefer dark mode',
|
|
updatedAt: Date.now(),
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const entryCard = (await screen.findByText('UI preferences')).closest('.library-card') as HTMLElement;
|
|
fireEvent.click(within(entryCard).getByTitle('Edit'));
|
|
|
|
const nameInput = await screen.findByDisplayValue('UI preferences');
|
|
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', behavior: 'smooth' });
|
|
expect(document.activeElement).toBe(nameInput);
|
|
});
|
|
|
|
it('renders saved records in a separate memory records section', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [
|
|
{
|
|
id: 'user_ui_preferences',
|
|
name: 'UI preferences',
|
|
description: 'Initial preference',
|
|
type: 'user',
|
|
updatedAt: Date.now(),
|
|
},
|
|
{
|
|
id: 'feedback_density',
|
|
name: 'Feedback density',
|
|
description: 'Prefer compact review cards',
|
|
type: 'feedback',
|
|
updatedAt: Date.now(),
|
|
},
|
|
{
|
|
id: 'project_brand_rule',
|
|
name: 'Project brand rule',
|
|
description: 'Use the project brand kit',
|
|
type: 'project',
|
|
updatedAt: Date.now(),
|
|
},
|
|
],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({
|
|
extractions: [
|
|
{
|
|
id: 'ex-1',
|
|
phase: 'success',
|
|
kind: 'llm',
|
|
startedAt: Date.now(),
|
|
finishedAt: Date.now() + 1200,
|
|
userMessagePreview: 'Remember I prefer dark mode',
|
|
writtenCount: 1,
|
|
},
|
|
],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const savedRow = await screen.findByText('UI preferences');
|
|
const extractionRow = await screen.findByText('Remember I prefer dark mode');
|
|
const indexSummary = screen.getByText('MEMORY.md (index)')
|
|
.closest('summary') as HTMLElement;
|
|
|
|
expect(savedRow.closest('.library-card')).toBeTruthy();
|
|
expect(savedRow.closest('.memory-records-section')).toBeTruthy();
|
|
expect(screen.getByRole('tab', { name: 'Add manually' }).closest('.memory-records-section')).toBeNull();
|
|
expect(extractionRow.closest('.library-card')?.className).toContain(
|
|
'memory-extraction-card',
|
|
);
|
|
expect(indexSummary.closest('.memory-advanced-section')).toBeTruthy();
|
|
expect(indexSummary.closest('.memory-records-section')).toBeNull();
|
|
expect(screen.queryByText('Extraction history')).toBeNull();
|
|
expect(indexSummary.className).toContain('memory-details-summary');
|
|
expect(document.querySelector('.memory-records-section .library-group-title')).toBeNull();
|
|
});
|
|
|
|
it('suggests and saves memory from selected connected apps', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
let entries: Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
type: string;
|
|
updatedAt: number;
|
|
}> = [];
|
|
const authWindow = {
|
|
document: {
|
|
title: '',
|
|
body: { innerHTML: '' },
|
|
},
|
|
location: { replace: vi.fn() },
|
|
close: vi.fn(),
|
|
};
|
|
vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window);
|
|
const suggestionBodies: unknown[] = [];
|
|
const createBodies: unknown[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries,
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/connectors/discovery?hydrateTools=false') {
|
|
return new Response(JSON.stringify({
|
|
connectors: [
|
|
{
|
|
id: 'notion',
|
|
name: 'Notion',
|
|
provider: 'composio',
|
|
category: 'Productivity',
|
|
status: 'connected',
|
|
accountLabel: 'Product wiki',
|
|
tools: [{ name: 'notion.notion_search' }],
|
|
},
|
|
{
|
|
id: 'github',
|
|
name: 'GitHub',
|
|
provider: 'composio',
|
|
category: 'Developer',
|
|
status: 'available',
|
|
tools: [],
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({
|
|
results: {
|
|
github: { status: 'ready', authConfigId: 'ac_github' },
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/connectors/github/connect' && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({
|
|
connector: {
|
|
id: 'github',
|
|
name: 'GitHub',
|
|
provider: 'composio',
|
|
category: 'Developer',
|
|
status: 'available',
|
|
tools: [],
|
|
},
|
|
auth: {
|
|
kind: 'redirect_required',
|
|
redirectUrl: 'https://example.com/github-oauth',
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/connectors/status') {
|
|
return new Response(JSON.stringify({
|
|
statuses: {
|
|
github: { status: 'available' },
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') {
|
|
suggestionBodies.push(JSON.parse(String(init.body)));
|
|
return new Response(JSON.stringify({
|
|
suggestions: [
|
|
{
|
|
id: 'project_memory_context_1',
|
|
name: 'Memory context',
|
|
description: 'Connector-derived context',
|
|
type: 'project',
|
|
body: 'OpenDesign connector memory should focus on design preferences, UI decisions, and visual references from Notion.',
|
|
source: {
|
|
kind: 'connector',
|
|
connectorId: 'notion',
|
|
connectorName: 'Notion',
|
|
accountLabel: 'Product wiki',
|
|
toolName: 'notion.notion_search',
|
|
toolTitle: 'Search Notion',
|
|
},
|
|
},
|
|
],
|
|
attemptedLLM: true,
|
|
contextBytes: 128,
|
|
connectors: [
|
|
{
|
|
connectorId: 'notion',
|
|
connectorName: 'Notion',
|
|
accountLabel: 'Product wiki',
|
|
status: 'succeeded',
|
|
toolName: 'notion.notion_search',
|
|
toolTitle: 'Search Notion',
|
|
summary: 'Found product memory notes.',
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/project_memory_context_1' && init?.method === 'PUT') {
|
|
const body = JSON.parse(String(init.body));
|
|
createBodies.push(body);
|
|
entries = [
|
|
{
|
|
id: 'project_memory_context_1',
|
|
name: body.name,
|
|
description: body.description,
|
|
type: body.type,
|
|
updatedAt: Date.now(),
|
|
},
|
|
];
|
|
return new Response(JSON.stringify({
|
|
entry: {
|
|
id: 'project_memory_context_1',
|
|
name: body.name,
|
|
description: body.description,
|
|
type: body.type,
|
|
body: body.body,
|
|
updatedAt: Date.now(),
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection({
|
|
chatAgentId: 'opencode',
|
|
chatModel: 'openai/gpt-5',
|
|
});
|
|
|
|
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
|
|
|
|
expect(await screen.findByText('Product wiki')).toBeTruthy();
|
|
const notionRow = document.querySelector('[data-memory-connector-id="notion"]') as HTMLElement;
|
|
const githubRow = document.querySelector('[data-memory-connector-id="github"]') as HTMLElement;
|
|
expect(within(notionRow).getByText('Select')).toBeTruthy();
|
|
expect(within(notionRow).queryByText('Connected')).toBeNull();
|
|
const connectGitHubButton = within(githubRow).getByRole('button', { name: 'Connect GitHub' });
|
|
expect(connectGitHubButton).toBeTruthy();
|
|
expect(within(githubRow).queryByText('Not connected')).toBeNull();
|
|
expect(screen.queryByText('Connect first')).toBeNull();
|
|
const extractButton = await screen.findByRole('button', { name: /Select apps to scan/i });
|
|
await waitFor(() => {
|
|
expect((extractButton as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
expect(screen.getByText('Selected 0 of 1 connected app.')).toBeTruthy();
|
|
|
|
fireEvent.click(connectGitHubButton);
|
|
await waitFor(() => {
|
|
expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth');
|
|
});
|
|
expect(within(githubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy();
|
|
expect(within(githubRow).queryByText('Select')).toBeNull();
|
|
expect((within(githubRow).getByRole('button', { name: 'Connect GitHub' }) as HTMLButtonElement).disabled).toBe(true);
|
|
|
|
fireEvent.click(screen.getByLabelText('Use Notion for memory extraction'));
|
|
await waitFor(() => {
|
|
expect((extractButton as HTMLButtonElement).disabled).toBe(false);
|
|
});
|
|
expect(screen.getByText('Selected')).toBeTruthy();
|
|
fireEvent.click(extractButton);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Found 1 suggested memory from 1 app/)).toBeTruthy();
|
|
});
|
|
expect(screen.getByText('Last scan')).toBeTruthy();
|
|
expect(screen.getByText('128 B read')).toBeTruthy();
|
|
expect(screen.getByText('Read Notion')).toBeTruthy();
|
|
expect(screen.getByText(/Search Notion · Found product memory notes/)).toBeTruthy();
|
|
expect(screen.getByText('Suggested memories')).toBeTruthy();
|
|
expect(suggestionBodies).toEqual([{
|
|
connectorIds: ['notion'],
|
|
chatAgentId: 'opencode',
|
|
chatModel: 'openai/gpt-5',
|
|
}]);
|
|
expect(screen.getByText('Memory context')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Save selected/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Saved 1 memory from connected apps/)).toBeTruthy();
|
|
});
|
|
expect(createBodies).toEqual([
|
|
{
|
|
id: 'project_memory_context_1',
|
|
name: 'Memory context',
|
|
description: 'Connector-derived context',
|
|
type: 'project',
|
|
body: 'OpenDesign connector memory should focus on design preferences, UI decisions, and visual references from Notion.',
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('saves same-name connector suggestions as distinct memory records', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
let entries: Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
type: string;
|
|
updatedAt: number;
|
|
}> = [];
|
|
const putBodies: unknown[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries,
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/connectors/discovery?hydrateTools=false') {
|
|
return new Response(JSON.stringify({
|
|
connectors: [
|
|
{
|
|
id: 'notion',
|
|
name: 'Notion',
|
|
provider: 'composio',
|
|
category: 'Productivity',
|
|
status: 'connected',
|
|
accountLabel: 'Product wiki',
|
|
tools: [{ name: 'notion.notion_search' }],
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/connectors/status') {
|
|
return new Response(JSON.stringify({
|
|
statuses: {
|
|
notion: { status: 'connected', accountLabel: 'Product wiki' },
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({
|
|
suggestions: [
|
|
{
|
|
id: 'project_comfyui_logo_1',
|
|
name: 'ComfyUI logo',
|
|
description: 'Use the black logo in product visuals.',
|
|
type: 'project',
|
|
body: 'ComfyUI design work should use the black logo.',
|
|
},
|
|
{
|
|
id: 'project_comfyui_logo_2',
|
|
name: 'ComfyUI logo',
|
|
description: 'Keep the visual style dark.',
|
|
type: 'project',
|
|
body: 'ComfyUI related layouts should prefer a dark visual style.',
|
|
},
|
|
],
|
|
attemptedLLM: true,
|
|
contextBytes: 256,
|
|
connectors: [
|
|
{
|
|
connectorId: 'notion',
|
|
connectorName: 'Notion',
|
|
accountLabel: 'Product wiki',
|
|
status: 'succeeded',
|
|
toolName: 'notion.notion_search',
|
|
toolTitle: 'Search Notion',
|
|
summary: 'Read page content: 设计思路.',
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url.startsWith('/api/memory/project_comfyui_logo_') && init?.method === 'PUT') {
|
|
const body = JSON.parse(String(init.body));
|
|
putBodies.push({ url, body });
|
|
entries = [
|
|
...entries.filter((entry) => entry.id !== body.id),
|
|
{
|
|
id: body.id,
|
|
name: body.name,
|
|
description: body.description,
|
|
type: body.type,
|
|
updatedAt: Date.now(),
|
|
},
|
|
];
|
|
return new Response(JSON.stringify({
|
|
entry: {
|
|
id: body.id,
|
|
name: body.name,
|
|
description: body.description,
|
|
type: body.type,
|
|
body: body.body,
|
|
updatedAt: Date.now(),
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
|
|
fireEvent.click(await screen.findByLabelText('Use Notion for memory extraction'));
|
|
fireEvent.click(await screen.findByRole('button', { name: /Scan selected apps/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Found 2 suggested memories from 1 app/)).toBeTruthy();
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: /Save selected/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Saved 2 memories from connected apps/)).toBeTruthy();
|
|
});
|
|
expect(putBodies).toEqual([
|
|
expect.objectContaining({
|
|
url: '/api/memory/project_comfyui_logo_1',
|
|
body: expect.objectContaining({ id: 'project_comfyui_logo_1' }),
|
|
}),
|
|
expect.objectContaining({
|
|
url: '/api/memory/project_comfyui_logo_2',
|
|
body: expect.objectContaining({ id: 'project_comfyui_logo_2' }),
|
|
}),
|
|
]);
|
|
expect(entries.map((entry) => entry.id).sort()).toEqual([
|
|
'project_comfyui_logo_1',
|
|
'project_comfyui_logo_2',
|
|
]);
|
|
});
|
|
|
|
it('keeps connector authorization pending after the memory panel remounts', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const authWindow = {
|
|
document: {
|
|
title: '',
|
|
body: { innerHTML: '' },
|
|
},
|
|
location: { replace: vi.fn() },
|
|
close: vi.fn(),
|
|
};
|
|
vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window);
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/connectors/discovery?hydrateTools=false') {
|
|
return new Response(JSON.stringify({
|
|
connectors: [
|
|
{
|
|
id: 'github',
|
|
name: 'GitHub',
|
|
provider: 'composio',
|
|
category: 'Developer',
|
|
status: 'available',
|
|
tools: [],
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/connectors/status') {
|
|
return new Response(JSON.stringify({
|
|
statuses: {
|
|
github: { status: 'available' },
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({
|
|
results: {
|
|
github: { status: 'ready', authConfigId: 'ac_github' },
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/connectors/github/connect' && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({
|
|
connector: {
|
|
id: 'github',
|
|
name: 'GitHub',
|
|
provider: 'composio',
|
|
category: 'Developer',
|
|
status: 'available',
|
|
tools: [],
|
|
},
|
|
auth: {
|
|
kind: 'redirect_required',
|
|
redirectUrl: 'https://example.com/github-oauth',
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
const first = renderMemorySection();
|
|
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
|
|
const githubRow = await waitFor(() => {
|
|
const row = document.querySelector('[data-memory-connector-id="github"]');
|
|
expect(row).toBeTruthy();
|
|
return row as HTMLElement;
|
|
});
|
|
fireEvent.click(within(githubRow).getByRole('button', { name: 'Connect GitHub' }));
|
|
|
|
await waitFor(() => {
|
|
expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth');
|
|
});
|
|
expect(within(githubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy();
|
|
expect(within(githubRow).queryByText('Select')).toBeNull();
|
|
|
|
first.unmount();
|
|
|
|
renderMemorySection();
|
|
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
|
|
const remountedGithubRow = await waitFor(() => {
|
|
const row = document.querySelector('[data-memory-connector-id="github"]');
|
|
expect(row).toBeTruthy();
|
|
return row as HTMLElement;
|
|
});
|
|
|
|
expect(within(remountedGithubRow).getByText('Finish authorization in your browser, then return here')).toBeTruthy();
|
|
expect(within(remountedGithubRow).queryByText('Select')).toBeNull();
|
|
expect((within(remountedGithubRow).getByRole('button', { name: 'Connect GitHub' }) as HTMLButtonElement).disabled).toBe(true);
|
|
});
|
|
|
|
it('shows reconnect guidance for memory connectors with stale authorization', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const lastError = 'GitHub authorization expired. Reconnect GitHub.';
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/connectors/discovery?hydrateTools=false') {
|
|
return new Response(JSON.stringify({
|
|
connectors: [
|
|
{
|
|
id: 'github',
|
|
name: 'GitHub',
|
|
provider: 'composio',
|
|
category: 'Developer',
|
|
status: 'error',
|
|
lastError,
|
|
tools: [],
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/connectors/status') {
|
|
return new Response(JSON.stringify({
|
|
statuses: {
|
|
github: { status: 'error', lastError },
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
|
|
|
|
const githubRow = await waitFor(() => {
|
|
const row = document.querySelector('[data-memory-connector-id="github"]');
|
|
expect(row).toBeTruthy();
|
|
return row as HTMLElement;
|
|
});
|
|
expect(within(githubRow).getByText(lastError)).toBeTruthy();
|
|
expect(within(githubRow).getByRole('button', { name: 'Reconnect GitHub' })).toBeTruthy();
|
|
expect(within(githubRow).queryByText('Select')).toBeNull();
|
|
});
|
|
|
|
it('shows connector read failures instead of a generic empty state', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/connectors/discovery?hydrateTools=false') {
|
|
return new Response(JSON.stringify({
|
|
connectors: [
|
|
{
|
|
id: 'notion',
|
|
name: 'Notion',
|
|
provider: 'composio',
|
|
category: 'Productivity',
|
|
status: 'connected',
|
|
accountLabel: 'Product wiki',
|
|
tools: [{ name: 'notion.notion_search' }],
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/connectors/suggest' && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({
|
|
suggestions: [],
|
|
attemptedLLM: false,
|
|
contextBytes: 0,
|
|
connectors: [
|
|
{
|
|
connectorId: 'notion',
|
|
connectorName: 'Notion',
|
|
accountLabel: 'Product wiki',
|
|
status: 'failed',
|
|
summary: 'No safe connector read completed.',
|
|
error: 'Tool NOTION_SEARCH not found',
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
|
|
fireEvent.click(await screen.findByLabelText('Use Notion for memory extraction'));
|
|
fireEvent.click(await screen.findByRole('button', { name: /Scan selected apps/i }));
|
|
|
|
const alert = await screen.findByRole('alert');
|
|
expect(alert.textContent).toContain("Couldn't read Notion.");
|
|
expect(alert.textContent).toContain('Tool NOTION_SEARCH not found');
|
|
expect(screen.getByText('Last scan')).toBeTruthy();
|
|
expect(screen.getByText('No data read')).toBeTruthy();
|
|
expect(screen.getByText('Could not read Notion')).toBeTruthy();
|
|
expect(screen.getAllByText(/Tool NOTION_SEARCH not found/).length).toBeGreaterThan(0);
|
|
expect(
|
|
screen.queryByText(/could not read useful content from the selected app/i),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('clears extraction history after clicking Clear', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const deletedUrls: string[] = [];
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
extractions: [
|
|
{
|
|
id: 'ex-1',
|
|
phase: 'success',
|
|
kind: 'llm',
|
|
startedAt: Date.now(),
|
|
finishedAt: Date.now() + 1200,
|
|
userMessagePreview: 'Remember I prefer dark mode',
|
|
proposedCount: 1,
|
|
writtenCount: 1,
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions' && init?.method === 'DELETE') {
|
|
deletedUrls.push(url);
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Remember I prefer dark mode')).toBeNull();
|
|
});
|
|
expect(deletedUrls).toEqual(['/api/memory/extractions']);
|
|
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it('does not clear extraction history when Clear is cancelled', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const deletedUrls: string[] = [];
|
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
extractions: [
|
|
{
|
|
id: 'ex-1',
|
|
phase: 'success',
|
|
kind: 'llm',
|
|
startedAt: Date.now(),
|
|
finishedAt: Date.now() + 1200,
|
|
userMessagePreview: 'Remember I prefer dark mode',
|
|
proposedCount: 1,
|
|
writtenCount: 1,
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions' && init?.method === 'DELETE') {
|
|
deletedUrls.push(url);
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
|
|
|
expect(deletedUrls).toEqual([]);
|
|
expect(screen.getByText('Remember I prefer dark mode')).toBeTruthy();
|
|
expect(confirmSpy).toHaveBeenCalledTimes(1);
|
|
confirmSpy.mockRestore();
|
|
});
|
|
|
|
it('loads preview, edits an entry, and refreshes the saved content', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
let entryBody = '- Prefer compact cards';
|
|
let entryDescription = 'Initial preference';
|
|
const putBodies: unknown[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [
|
|
{
|
|
id: 'user_ui_preferences',
|
|
name: 'UI preferences',
|
|
description: entryDescription,
|
|
type: 'user',
|
|
updatedAt: Date.now(),
|
|
},
|
|
],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/user_ui_preferences' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
entry: {
|
|
id: 'user_ui_preferences',
|
|
name: 'UI preferences',
|
|
description: entryDescription,
|
|
type: 'user',
|
|
body: entryBody,
|
|
updatedAt: Date.now(),
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/user_ui_preferences' && init?.method === 'PUT') {
|
|
const body = JSON.parse(String(init.body));
|
|
putBodies.push(body);
|
|
entryDescription = body.description;
|
|
entryBody = body.body;
|
|
return new Response(JSON.stringify({
|
|
entry: {
|
|
id: 'user_ui_preferences',
|
|
name: body.name,
|
|
description: body.description,
|
|
type: body.type,
|
|
body: body.body,
|
|
updatedAt: Date.now(),
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const card = await screen.findByText('UI preferences');
|
|
const row = card.closest('.library-card') as HTMLElement;
|
|
|
|
fireEvent.click(within(row).getByTitle('Preview'));
|
|
expect(await screen.findByText('Prefer compact cards')).toBeTruthy();
|
|
|
|
fireEvent.click(within(row).getByTitle('Edit'));
|
|
fireEvent.change(await screen.findByDisplayValue('Initial preference'), {
|
|
target: { value: 'Updated preference' },
|
|
});
|
|
fireEvent.change(
|
|
await screen.findByDisplayValue('- Prefer compact cards'),
|
|
{ target: { value: '- Prefer spacious layouts' } },
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('✓ Memory saved')).toBeTruthy();
|
|
});
|
|
expect(putBodies).toEqual([
|
|
{
|
|
id: 'user_ui_preferences',
|
|
name: 'UI preferences',
|
|
description: 'Updated preference',
|
|
type: 'user',
|
|
body: '- Prefer spacious layouts',
|
|
},
|
|
]);
|
|
expect(screen.getByText('Updated preference')).toBeTruthy();
|
|
});
|
|
|
|
it('keeps the expanded preview control visually distinct from delete', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [
|
|
{
|
|
id: 'project_scope',
|
|
name: 'Project scope',
|
|
description: 'Current project constraints',
|
|
type: 'project',
|
|
updatedAt: Date.now(),
|
|
},
|
|
],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/project_scope' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
entry: {
|
|
id: 'project_scope',
|
|
name: 'Project scope',
|
|
description: 'Current project constraints',
|
|
type: 'project',
|
|
body: '- Keep memory actions clear',
|
|
updatedAt: Date.now(),
|
|
},
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const card = (await screen.findByText('Project scope')).closest('.library-card') as HTMLElement;
|
|
const previewButton = within(card).getByTitle('Preview');
|
|
const deleteButton = within(card).getByTitle('Delete');
|
|
fireEvent.click(previewButton);
|
|
|
|
await screen.findByText('Keep memory actions clear');
|
|
const previewIcon = previewButton.querySelector('svg');
|
|
const deleteIcon = deleteButton.querySelector('svg');
|
|
const previewPaths = Array.from(
|
|
previewIcon?.querySelectorAll('path') ?? [],
|
|
).map((path) => path.getAttribute('d'));
|
|
const deletePaths = Array.from(
|
|
deleteIcon?.querySelectorAll('path') ?? [],
|
|
).map((path) => path.getAttribute('d'));
|
|
|
|
expect(previewIcon).toBeTruthy();
|
|
expect(deleteIcon).toBeTruthy();
|
|
expect(previewPaths).not.toEqual(deletePaths);
|
|
});
|
|
|
|
it('deletes an existing memory entry from the list', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
let entries = [
|
|
{
|
|
id: 'user_ui_preferences',
|
|
name: 'UI preferences',
|
|
description: 'Persistent UI rendering preferences',
|
|
type: 'user',
|
|
updatedAt: Date.now(),
|
|
},
|
|
];
|
|
const deletedUrls: string[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries,
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/user_ui_preferences' && init?.method === 'DELETE') {
|
|
deletedUrls.push(url);
|
|
entries = [];
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const card = (await screen.findByText('UI preferences')).closest('.library-card') as HTMLElement;
|
|
fireEvent.click(within(card).getByTitle('Delete'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('✓ Memory deleted')).toBeTruthy();
|
|
expect(screen.getByText(/No memory yet\./)).toBeTruthy();
|
|
});
|
|
expect(deletedUrls).toEqual(['/api/memory/user_ui_preferences']);
|
|
});
|
|
|
|
it('keeps the editor open when saving a memory entry fails', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory' && init?.method === 'POST') {
|
|
return new Response(JSON.stringify({ error: 'write failed' }), {
|
|
status: 500,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'New memory' }));
|
|
fireEvent.change(screen.getByPlaceholderText('e.g. UI preferences'), {
|
|
target: { value: 'UI preferences' },
|
|
});
|
|
fireEvent.change(screen.getByPlaceholderText('One sentence — what is this memory about?'), {
|
|
target: { value: 'Persistent UI rendering preferences' },
|
|
});
|
|
fireEvent.change(
|
|
screen.getByPlaceholderText(/- Rule one[\s\S]*When to apply: optional scope/),
|
|
{
|
|
target: { value: '- Prefer dark mode\n- Prefer generous spacing' },
|
|
},
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByDisplayValue('UI preferences')).toBeTruthy();
|
|
});
|
|
expect(screen.queryByText('✓ Memory created')).toBeNull();
|
|
expect(screen.queryByText('UI preferences')).toBeTruthy();
|
|
});
|
|
|
|
it('keeps unsaved index edits when saving the index fails', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n\n- Existing bullet\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/index' && init?.method === 'PUT') {
|
|
return new Response(JSON.stringify({ error: 'disk full' }), {
|
|
status: 500,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
fireEvent.click(await screen.findByText('MEMORY.md (index)'));
|
|
const indexArea = await findMemoryIndexTextarea();
|
|
fireEvent.change(indexArea, {
|
|
target: { value: '# Memory\n\n- Existing bullet\n- New bullet\n' },
|
|
});
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save index' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Unsaved changes/i)).toBeTruthy();
|
|
});
|
|
expect(indexArea.value).toContain('- New bullet');
|
|
expect(screen.queryByText('✓ Index saved')).toBeNull();
|
|
});
|
|
|
|
it('deletes a single extraction row without clearing the whole history', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const deletedUrls: string[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
extractions: [
|
|
{
|
|
id: 'ex-1',
|
|
phase: 'success',
|
|
kind: 'llm',
|
|
startedAt: Date.now(),
|
|
userMessagePreview: 'Remember I prefer dark mode',
|
|
writtenCount: 1,
|
|
},
|
|
{
|
|
id: 'ex-2',
|
|
phase: 'skipped',
|
|
reason: 'no-match',
|
|
kind: 'heuristic',
|
|
startedAt: Date.now() - 1000,
|
|
userMessagePreview: 'No durable memory in this turn',
|
|
},
|
|
],
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions/ex-1' && init?.method === 'DELETE') {
|
|
deletedUrls.push(url);
|
|
return new Response(JSON.stringify({ ok: true }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
expect(await screen.findByText('Remember I prefer dark mode')).toBeTruthy();
|
|
expect(screen.getByText('No durable memory in this turn')).toBeTruthy();
|
|
|
|
const row = screen.getByText('Remember I prefer dark mode')
|
|
.closest('.library-card') as HTMLElement;
|
|
fireEvent.click(within(row).getByRole('button', { name: 'Delete' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('Remember I prefer dark mode')).toBeNull();
|
|
});
|
|
expect(screen.getByText('No durable memory in this turn')).toBeTruthy();
|
|
expect(deletedUrls).toEqual(['/api/memory/extractions/ex-1']);
|
|
});
|
|
|
|
it('applies extraction and change SSE events to the visible lists', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
let entries = [
|
|
{
|
|
id: 'user_ui_preferences',
|
|
name: 'UI preferences',
|
|
description: 'Initial preference',
|
|
type: 'user',
|
|
updatedAt: Date.now(),
|
|
},
|
|
];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries,
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
expect(await screen.findByText('UI preferences')).toBeTruthy();
|
|
|
|
expect(screen.queryByText('Remember I prefer dark mode')).toBeNull();
|
|
|
|
const es = StubEventSource.instances[0]!;
|
|
es.emit('extraction', {
|
|
id: 'ex-1',
|
|
phase: 'running',
|
|
kind: 'llm',
|
|
startedAt: Date.now(),
|
|
userMessagePreview: 'Remember I prefer dark mode',
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Remember I prefer dark mode')).toBeTruthy();
|
|
expect(screen.getAllByText('Running…').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
entries = [
|
|
...entries,
|
|
{
|
|
id: 'project_brief',
|
|
name: 'Project brief',
|
|
description: 'Pinned project context',
|
|
type: 'project',
|
|
updatedAt: Date.now(),
|
|
},
|
|
];
|
|
es.emit('change', { kind: 'upsert', id: 'project_brief' });
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Add manually' }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Project brief')).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it('renders failed extraction rows with user-facing error details', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({
|
|
extractions: [
|
|
{
|
|
id: 'ex-failed',
|
|
phase: 'failed',
|
|
kind: 'llm',
|
|
startedAt: Date.now(),
|
|
finishedAt: Date.now() + 2500,
|
|
userMessagePreview: 'Remember my dashboard preference',
|
|
error: 'provider returned 429 quota exceeded',
|
|
},
|
|
],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
expect(await screen.findByText('Remember my dashboard preference')).toBeTruthy();
|
|
expect(screen.getByText('Memory model quota or rate limit hit')).toBeTruthy();
|
|
expect(screen.getByText('Try again later or switch the Memory extraction model.')).toBeTruthy();
|
|
expect(screen.queryByText('provider returned 429 quota exceeded')).toBeNull();
|
|
expect(screen.getByText('Failed')).toBeTruthy();
|
|
});
|
|
|
|
it('renders connector model authentication failures without raw provider JSON', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({
|
|
extractions: [
|
|
{
|
|
id: 'ex-connector-failed',
|
|
phase: 'failed',
|
|
kind: 'connector',
|
|
provider: {
|
|
kind: 'openai',
|
|
model: 'gpt-4o-mini',
|
|
credentialSource: 'memory-config',
|
|
},
|
|
startedAt: Date.now(),
|
|
finishedAt: Date.now() + 1300,
|
|
userMessagePreview: 'Suggest durable OpenDesign memories from connected apps.',
|
|
error: 'openai 401: { "error": { "message": "Your authentication token has expired. Please try signing in again.", "type": "invalid_request_error", "code": "token_expired", "param": null }, "status": 401 }',
|
|
},
|
|
],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Import from apps' }));
|
|
|
|
expect(await screen.findByText('Connected app scan failed')).toBeTruthy();
|
|
expect(
|
|
within(screen.getByLabelText('Connected app memory run status'))
|
|
.getByText('Connected app scan failed'),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.queryByText('Suggest durable OpenDesign memories from connected apps.'),
|
|
).toBeNull();
|
|
expect(
|
|
within(document.querySelector('.memory-unified-list') as HTMLElement)
|
|
.queryByText('Suggest durable OpenDesign memories from connected apps.'),
|
|
).toBeNull();
|
|
expect(screen.getByText('OpenAI authentication expired')).toBeTruthy();
|
|
expect(
|
|
screen.getByText('Connected apps were read, but OpenDesign could not turn that context into memory.'),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByText('Update the Memory extraction model key or sign in again.'),
|
|
).toBeTruthy();
|
|
expect(screen.queryByText(/token_expired/)).toBeNull();
|
|
});
|
|
|
|
it('renders Local CLI extraction failures without API-key guidance', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({
|
|
extractions: [
|
|
{
|
|
id: 'ex-cli-failed',
|
|
phase: 'failed',
|
|
kind: 'connector',
|
|
provider: {
|
|
kind: 'anthropic',
|
|
model: 'default',
|
|
credentialSource: 'chat-cli',
|
|
},
|
|
startedAt: Date.now(),
|
|
finishedAt: Date.now() + 900,
|
|
userMessagePreview: 'Suggest durable OpenDesign memories from connected apps.',
|
|
error: 'Claude Code CLI exit 1: authentication token has expired',
|
|
},
|
|
],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
fireEvent.click(screen.getByRole('tab', { name: 'Import from apps' }));
|
|
|
|
expect(await screen.findByText('Claude Code authentication expired')).toBeTruthy();
|
|
expect(screen.getByLabelText('Connected app memory run status')).toBeTruthy();
|
|
expect(
|
|
screen.getByText('Sign in to the selected Local CLI or choose a different Memory model.'),
|
|
).toBeTruthy();
|
|
expect(screen.queryByText(/Update the Memory extraction model key/)).toBeNull();
|
|
});
|
|
|
|
it('renders the disabled banner when memory starts disabled', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory') {
|
|
return new Response(JSON.stringify({
|
|
enabled: false,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const banner = await screen.findByRole('status');
|
|
expect(banner.textContent).toContain('Memory is currently OFF.');
|
|
});
|
|
|
|
it('toggles memory injection off and persists the PATCH payload', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const patchBodies: unknown[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/config' && init?.method === 'PATCH') {
|
|
patchBodies.push(JSON.parse(String(init.body)));
|
|
return new Response(JSON.stringify({ enabled: false, extraction: null }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
const toggle = await screen.findByRole('checkbox', { name: 'Enable memory injection' }) as HTMLInputElement;
|
|
|
|
fireEvent.click(toggle);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('status').textContent).toContain('Memory is currently OFF.');
|
|
});
|
|
expect(patchBodies).toEqual([{ enabled: false }]);
|
|
});
|
|
|
|
it('toggles chat conversation learning off and persists the PATCH payload', async () => {
|
|
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
|
const patchBodies: unknown[] = [];
|
|
|
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = input.toString();
|
|
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
chatExtractionEnabled: true,
|
|
rootDir: '/tmp/memory',
|
|
index: '# Memory\n',
|
|
entries: [],
|
|
extraction: null,
|
|
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
}
|
|
if (url === '/api/memory/extractions') {
|
|
return new Response(JSON.stringify({ extractions: [] }), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
if (url === '/api/memory/config' && init?.method === 'PATCH') {
|
|
patchBodies.push(JSON.parse(String(init.body)));
|
|
return new Response(JSON.stringify({
|
|
enabled: true,
|
|
chatExtractionEnabled: false,
|
|
extraction: null,
|
|
}), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({}), { status: 404 });
|
|
}) as typeof fetch;
|
|
|
|
renderMemorySection();
|
|
|
|
fireEvent.click(await screen.findByRole('tab', { name: 'Learn from chats' }));
|
|
const toggle = screen.getByRole('checkbox', {
|
|
name: 'Learn from chat conversations',
|
|
}) as HTMLInputElement;
|
|
|
|
expect(toggle.checked).toBe(true);
|
|
fireEvent.click(toggle);
|
|
|
|
await waitFor(() => expect(toggle.checked).toBe(false));
|
|
expect(screen.getByText('Off')).toBeTruthy();
|
|
expect(patchBodies).toEqual([{ chatExtractionEnabled: false }]);
|
|
});
|
|
});
|