total-recall-brain 3.18.1 → 3.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frontend/dist/assets/index-CGqvdwHT.js +5742 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/config.ts +16 -2
- package/frontend/src/api/keys.ts +111 -0
- package/frontend/src/api/notifications.ts +54 -6
- package/frontend/src/api/update.ts +12 -1
- package/frontend/src/components/BrainSelector.spec.tsx +71 -47
- package/frontend/src/components/BrainSelector.tsx +112 -35
- package/frontend/src/components/MobilePairing.spec.tsx +44 -14
- package/frontend/src/components/MobilePairing.tsx +229 -22
- package/frontend/src/pages/NotificationsPage.tsx +6 -3
- package/frontend/src/pages/RulesPage.spec.tsx +46 -15
- package/frontend/src/pages/RulesPage.tsx +41 -9
- package/frontend/src/pages/SecretsPage.tsx +316 -3
- package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
- package/frontend/src/pages/SettingsPage.tsx +206 -85
- package/package.json +1 -1
- package/src/cli/agent-dir.mjs +2 -1
- package/src/cli/secret.mjs +180 -3
- package/src/core/daemon-loop.mjs +31 -5
- package/src/core/embeddings.mjs +111 -38
- package/src/core/notifications.mjs +204 -34
- package/src/core/package-auto-update.mjs +99 -3
- package/src/core/pairing.mjs +168 -0
- package/src/core/provider-account-sync.mjs +1145 -0
- package/src/core/provider-catalog.mjs +11 -4
- package/src/core/research.mjs +98 -24
- package/src/core/sandbox.mjs +37 -22
- package/src/core/scheduler.mjs +31 -9
- package/src/core/schema.mjs +23 -0
- package/src/core/secret-integration-research.mjs +325 -0
- package/src/core/secrets-store.mjs +278 -14
- package/src/core/secrets-sync.mjs +1 -1
- package/src/core/source-adapters.mjs +247 -22
- package/src/core/task-executors.mjs +161 -14
- package/src/core/webhook-handlers.mjs +123 -14
- package/src/server/auth.mjs +6 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +56 -0
- package/src/server/routes/config.mjs +64 -23
- package/src/server/routes/mesh.mjs +2 -1
- package/src/server/routes/notifications.mjs +103 -0
- package/src/server/routes/rules.mjs +75 -20
- package/src/server/routes/secrets.mjs +110 -4
- package/src/server/routes/system.mjs +20 -1
- package/src/server/routes/update.mjs +11 -4
- package/frontend/dist/assets/index-D5xRw5uq.js +0 -5750
package/frontend/dist/index.html
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
18
18
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
19
19
|
<title>Total Recall — Portable Memory</title>
|
|
20
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
+
<script type="module" crossorigin src="/assets/index-CGqvdwHT.js"></script>
|
|
21
21
|
<link rel="stylesheet" crossorigin href="/assets/index-B4RASWbV.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
|
@@ -21,7 +21,14 @@ export async function saveConfig(name: string, content: string): Promise<void> {
|
|
|
21
21
|
|
|
22
22
|
export async function fetchConfigJson(): Promise<ConfigJson> {
|
|
23
23
|
const res = await apiFetch(`${API_BASE}/api/config-json`)
|
|
24
|
-
if (!res.ok)
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
let detail = `Config JSON API error: ${res.status}`
|
|
26
|
+
try {
|
|
27
|
+
const body = await res.json()
|
|
28
|
+
if (body?.error) detail = body.error
|
|
29
|
+
} catch { /* ignore */ }
|
|
30
|
+
throw new Error(detail)
|
|
31
|
+
}
|
|
25
32
|
return res.json()
|
|
26
33
|
}
|
|
27
34
|
|
|
@@ -31,5 +38,12 @@ export async function saveConfigJson(config: ConfigJson): Promise<void> {
|
|
|
31
38
|
headers: { 'Content-Type': 'application/json' },
|
|
32
39
|
body: JSON.stringify(config),
|
|
33
40
|
})
|
|
34
|
-
if (!res.ok)
|
|
41
|
+
if (!res.ok) {
|
|
42
|
+
let detail = `Config JSON API error: ${res.status}`
|
|
43
|
+
try {
|
|
44
|
+
const body = await res.json()
|
|
45
|
+
if (body?.error) detail = body.error
|
|
46
|
+
} catch { /* ignore */ }
|
|
47
|
+
throw new Error(detail)
|
|
48
|
+
}
|
|
35
49
|
}
|
package/frontend/src/api/keys.ts
CHANGED
|
@@ -146,6 +146,27 @@ export interface SecretCatalogKey {
|
|
|
146
146
|
headscale_url?: string | null
|
|
147
147
|
usage_30d: { events: number; cost_usd: number; input_tokens: number; output_tokens: number }
|
|
148
148
|
tiers: { id: string; label: string; monthly_usd?: number | null }[]
|
|
149
|
+
/** Live provider account/usage tracking */
|
|
150
|
+
tracking_status?: 'ok' | 'partial' | 'error' | 'exempt' | 'never' | string | null
|
|
151
|
+
tracking_error?: string | null
|
|
152
|
+
tracking_synced_at?: string | null
|
|
153
|
+
tracking_probe?: string | null
|
|
154
|
+
tracking_account?: unknown
|
|
155
|
+
tracking_usage?: unknown
|
|
156
|
+
tracking_subscription?: unknown
|
|
157
|
+
tracking_exempt?: boolean
|
|
158
|
+
account_api?: boolean | null
|
|
159
|
+
usage_api?: boolean | null
|
|
160
|
+
subscription_api?: boolean | null
|
|
161
|
+
key_valid?: boolean | null
|
|
162
|
+
/** Same credential value under multiple secret names / apps */
|
|
163
|
+
shared_value?: boolean
|
|
164
|
+
shared_with?: { key: string; app: string; repo?: string | null; provider?: string | null }[]
|
|
165
|
+
shared_apps?: string[]
|
|
166
|
+
shared_value_error?: string | null
|
|
167
|
+
shared_value_severity?: 'error' | 'ok' | string | null
|
|
168
|
+
shared_fingerprint?: string | null
|
|
169
|
+
shared_value_ok?: boolean
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
export interface SecretCatalog {
|
|
@@ -162,17 +183,107 @@ export interface SecretCatalog {
|
|
|
162
183
|
usage_30d: { events: number; cost_usd: number }
|
|
163
184
|
rotation_overdue: number
|
|
164
185
|
budget: Record<string, unknown>
|
|
186
|
+
tracking_healthy?: boolean
|
|
187
|
+
tracking_ok?: number
|
|
188
|
+
tracking_exempt?: number
|
|
189
|
+
tracking_errors?: number
|
|
190
|
+
tracking_error_keys?: {
|
|
191
|
+
key: string
|
|
192
|
+
provider?: string | null
|
|
193
|
+
tracking_status?: string
|
|
194
|
+
error?: string
|
|
195
|
+
}[]
|
|
196
|
+
shared_value_healthy?: boolean
|
|
197
|
+
shared_value_keys?: number
|
|
198
|
+
shared_value_multi_app_keys?: number
|
|
199
|
+
shared_value_groups?: number
|
|
200
|
+
shared_value_errors?: {
|
|
201
|
+
fingerprint?: string
|
|
202
|
+
apps?: string[]
|
|
203
|
+
keys?: string[]
|
|
204
|
+
error?: string | null
|
|
205
|
+
}[]
|
|
165
206
|
}
|
|
166
207
|
by_provider: { provider: string; keys: number; cost_30d: number; monthly_cost: number }[]
|
|
167
208
|
store: string
|
|
168
209
|
}
|
|
169
210
|
|
|
211
|
+
export interface SharedValueHealth {
|
|
212
|
+
healthy: boolean
|
|
213
|
+
message: string
|
|
214
|
+
error_groups: number
|
|
215
|
+
multi_app_groups: number
|
|
216
|
+
shared_keys: number
|
|
217
|
+
groups: {
|
|
218
|
+
fingerprint: string
|
|
219
|
+
apps: string[]
|
|
220
|
+
keys: string[]
|
|
221
|
+
severity: string
|
|
222
|
+
error?: string | null
|
|
223
|
+
members?: { key: string; app: string; provider?: string | null; masked?: string | null }[]
|
|
224
|
+
}[]
|
|
225
|
+
errors?: { fingerprint: string; apps: string[]; keys: string[]; error?: string | null }[]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface TrackingHealth {
|
|
229
|
+
healthy: boolean
|
|
230
|
+
message: string
|
|
231
|
+
ok: number
|
|
232
|
+
exempt: number
|
|
233
|
+
errors: number
|
|
234
|
+
error_keys: {
|
|
235
|
+
key: string
|
|
236
|
+
provider?: string | null
|
|
237
|
+
tracking_status?: string
|
|
238
|
+
error?: string
|
|
239
|
+
}[]
|
|
240
|
+
}
|
|
241
|
+
|
|
170
242
|
export async function fetchSecretsCatalog(): Promise<SecretCatalog> {
|
|
171
243
|
const res = await apiFetch(`${API_BASE}/api/secrets`)
|
|
172
244
|
if (!res.ok) throw new Error(`Secrets catalog error: ${res.status}`)
|
|
173
245
|
return res.json()
|
|
174
246
|
}
|
|
175
247
|
|
|
248
|
+
/** Same credential value reused across secret names / apps — 409 when unhealthy. */
|
|
249
|
+
export async function fetchSharedValues(): Promise<SharedValueHealth> {
|
|
250
|
+
const res = await apiFetch(`${API_BASE}/api/secrets/shared-values`)
|
|
251
|
+
const body = await res.json().catch(() => ({}))
|
|
252
|
+
if (!res.ok && res.status !== 409) {
|
|
253
|
+
throw new Error(body.error || `Shared values error: ${res.status}`)
|
|
254
|
+
}
|
|
255
|
+
return body as SharedValueHealth
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Provider account/usage tracking health — 409 when untracked keys remain. */
|
|
259
|
+
export async function fetchTrackingHealth(): Promise<TrackingHealth> {
|
|
260
|
+
const res = await apiFetch(`${API_BASE}/api/secrets/tracking-health`)
|
|
261
|
+
const body = await res.json().catch(() => ({}))
|
|
262
|
+
if (!res.ok && res.status !== 409) {
|
|
263
|
+
throw new Error(body.error || `Tracking health error: ${res.status}`)
|
|
264
|
+
}
|
|
265
|
+
return body as TrackingHealth
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Live-probe vendor account/usage APIs and persist tracking meta. */
|
|
269
|
+
export async function runAccountSync(opts: {
|
|
270
|
+
key?: string
|
|
271
|
+
keys?: string[]
|
|
272
|
+
strict?: boolean
|
|
273
|
+
use_ai?: boolean
|
|
274
|
+
} = {}): Promise<Record<string, unknown>> {
|
|
275
|
+
const res = await apiFetch(`${API_BASE}/api/secrets/account-sync`, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers: { 'Content-Type': 'application/json' },
|
|
278
|
+
body: JSON.stringify(opts),
|
|
279
|
+
})
|
|
280
|
+
const body = await res.json().catch(() => ({}))
|
|
281
|
+
if (!res.ok && res.status !== 409) {
|
|
282
|
+
throw new Error(body.error || `Account sync failed: ${res.status}`)
|
|
283
|
+
}
|
|
284
|
+
return body
|
|
285
|
+
}
|
|
286
|
+
|
|
176
287
|
export async function updateSecretMeta(key: string, patch: Record<string, unknown>): Promise<SecretCatalogKey> {
|
|
177
288
|
const res = await apiFetch(`${API_BASE}/api/secrets/${encodeURIComponent(key)}`, {
|
|
178
289
|
method: 'PATCH',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { apiFetch, getApiBase } from './_base';
|
|
2
2
|
|
|
3
3
|
export interface NotificationRule {
|
|
4
4
|
id: string;
|
|
@@ -18,24 +18,72 @@ export interface NotificationEntry {
|
|
|
18
18
|
timestamp: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
async function parseJsonOrThrow(res: Response, label: string): Promise<any> {
|
|
22
|
+
const text = await res.text();
|
|
23
|
+
let data: any = null;
|
|
24
|
+
if (text) {
|
|
25
|
+
try {
|
|
26
|
+
data = JSON.parse(text);
|
|
27
|
+
} catch {
|
|
28
|
+
data = null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const msg =
|
|
33
|
+
(data && (data.error || data.message)) ||
|
|
34
|
+
`${label} failed: ${res.status}`;
|
|
35
|
+
throw new Error(typeof msg === 'string' ? msg : `${label} failed: ${res.status}`);
|
|
36
|
+
}
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function asArray<T>(data: unknown): T[] {
|
|
41
|
+
if (Array.isArray(data)) return data as T[];
|
|
42
|
+
if (data && typeof data === 'object') {
|
|
43
|
+
const obj = data as Record<string, unknown>;
|
|
44
|
+
if (Array.isArray(obj.rules)) return obj.rules as T[];
|
|
45
|
+
if (Array.isArray(obj.history)) return obj.history as T[];
|
|
46
|
+
if (Array.isArray(obj.entries)) return obj.entries as T[];
|
|
47
|
+
}
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
21
51
|
export async function listNotificationRules(): Promise<NotificationRule[]> {
|
|
22
|
-
|
|
52
|
+
const res = await apiFetch(`${getApiBase()}/api/notifications/rules`);
|
|
53
|
+
const data = await parseJsonOrThrow(res, 'GET /api/notifications/rules');
|
|
54
|
+
return asArray<NotificationRule>(data);
|
|
23
55
|
}
|
|
24
56
|
|
|
25
57
|
export async function createNotificationRule(
|
|
26
58
|
rule: Omit<NotificationRule, 'id'>,
|
|
27
59
|
): Promise<NotificationRule> {
|
|
28
|
-
|
|
60
|
+
const res = await apiFetch(`${getApiBase()}/api/notifications/rules`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'Content-Type': 'application/json' },
|
|
63
|
+
body: JSON.stringify(rule),
|
|
64
|
+
});
|
|
65
|
+
return parseJsonOrThrow(res, 'POST /api/notifications/rules');
|
|
29
66
|
}
|
|
30
67
|
|
|
31
68
|
export async function deleteNotificationRule(id: string): Promise<void> {
|
|
32
|
-
|
|
69
|
+
const res = await apiFetch(
|
|
70
|
+
`${getApiBase()}/api/notifications/rules/${encodeURIComponent(id)}`,
|
|
71
|
+
{ method: 'DELETE' },
|
|
72
|
+
);
|
|
73
|
+
await parseJsonOrThrow(res, 'DELETE /api/notifications/rules');
|
|
33
74
|
}
|
|
34
75
|
|
|
35
76
|
export async function getNotificationHistory(): Promise<NotificationEntry[]> {
|
|
36
|
-
|
|
77
|
+
const res = await apiFetch(`${getApiBase()}/api/notifications/history`);
|
|
78
|
+
const data = await parseJsonOrThrow(res, 'GET /api/notifications/history');
|
|
79
|
+
return asArray<NotificationEntry>(data);
|
|
37
80
|
}
|
|
38
81
|
|
|
39
82
|
export async function sendTestNotification(): Promise<void> {
|
|
40
|
-
|
|
83
|
+
const res = await apiFetch(`${getApiBase()}/api/notifications/test`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify({}),
|
|
87
|
+
});
|
|
88
|
+
await parseJsonOrThrow(res, 'POST /api/notifications/test');
|
|
41
89
|
}
|
|
@@ -11,7 +11,18 @@ export interface UpdateCheckResult {
|
|
|
11
11
|
export async function checkUpdate(): Promise<UpdateCheckResult> {
|
|
12
12
|
const res = await apiFetch(`${API_BASE}/api/update/check`)
|
|
13
13
|
if (!res.ok) throw new Error(`Check update API error: ${res.status}`)
|
|
14
|
-
|
|
14
|
+
const data = await res.json()
|
|
15
|
+
// Normalize snake_case API fields for the Settings UI
|
|
16
|
+
const currentVersion = data.currentVersion ?? data.current ?? ''
|
|
17
|
+
const latestVersion = data.latestVersion ?? data.latest ?? ''
|
|
18
|
+
// Prefer host package comparison; fall back to API flag (includes consumers behind).
|
|
19
|
+
const hostBehind =
|
|
20
|
+
Boolean(latestVersion && currentVersion && latestVersion !== currentVersion)
|
|
21
|
+
return {
|
|
22
|
+
currentVersion,
|
|
23
|
+
latestVersion,
|
|
24
|
+
updateAvailable: hostBehind || Boolean(data.updateAvailable ?? data.update_available),
|
|
25
|
+
}
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
export async function runUpdate(): Promise<{ success: boolean; message: string }> {
|
|
@@ -1,73 +1,97 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
2
|
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
|
4
3
|
import BrainSelector from './BrainSelector';
|
|
5
|
-
import * as api from '../api';
|
|
6
4
|
|
|
7
|
-
vi.mock('../api')
|
|
5
|
+
vi.mock('../api', () => ({
|
|
6
|
+
apiFetch: vi.fn(),
|
|
7
|
+
getApiBase: vi.fn(() => ''),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { apiFetch } from '../api';
|
|
8
11
|
|
|
9
12
|
describe('BrainSelector', () => {
|
|
10
13
|
beforeEach(() => {
|
|
11
|
-
vi.
|
|
12
|
-
(global as any).fetch = vi.fn();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('renders loading state initially', () => {
|
|
16
|
-
vi.mocked(api.getApiBase).mockReturnValue('http://localhost:3000');
|
|
17
|
-
// Promise that never resolves for testing loading state
|
|
18
|
-
(global.fetch as any).mockReturnValue(new Promise(() => {}));
|
|
19
|
-
|
|
20
|
-
render(<BrainSelector activeBrainId="default" onBrainChange={() => {}} />);
|
|
21
|
-
expect(screen.getByText(/Brains…/i)).toBeInTheDocument();
|
|
14
|
+
vi.clearAllMocks();
|
|
22
15
|
});
|
|
23
16
|
|
|
24
|
-
it('
|
|
25
|
-
vi.mocked(
|
|
26
|
-
|
|
27
|
-
json: () =>
|
|
17
|
+
it('shows loading then brains from API', async () => {
|
|
18
|
+
vi.mocked(apiFetch).mockResolvedValue({
|
|
19
|
+
ok: true,
|
|
20
|
+
json: async () => ({
|
|
28
21
|
brains: [
|
|
29
|
-
{ id: '
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
22
|
+
{ id: 'global', name: 'Global Brain', layer: 'global', exists: true, node_count: 10 },
|
|
23
|
+
{
|
|
24
|
+
id: 'project:demo',
|
|
25
|
+
name: 'demo',
|
|
26
|
+
layer: 'project',
|
|
27
|
+
exists: true,
|
|
28
|
+
node_count: 5,
|
|
29
|
+
project_root: '/tmp/demo',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
}),
|
|
33
|
+
} as any);
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
const onBrainChange = vi.fn();
|
|
36
|
+
render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
|
|
35
37
|
|
|
38
|
+
expect(screen.getByTestId('brain-selector-loading')).toBeInTheDocument();
|
|
36
39
|
await waitFor(() => {
|
|
37
|
-
expect(screen.
|
|
40
|
+
expect(screen.getByTestId('brain-selector')).toBeInTheDocument();
|
|
38
41
|
});
|
|
42
|
+
expect(screen.getByText(/Global Brain/i)).toBeInTheDocument();
|
|
39
43
|
});
|
|
40
44
|
|
|
41
|
-
it('
|
|
42
|
-
vi.mocked(
|
|
43
|
-
|
|
44
|
-
json: () =>
|
|
45
|
+
it('selects a single brain on row click', async () => {
|
|
46
|
+
vi.mocked(apiFetch).mockResolvedValue({
|
|
47
|
+
ok: true,
|
|
48
|
+
json: async () => ({
|
|
45
49
|
brains: [
|
|
46
|
-
{ id: '
|
|
47
|
-
{ id: '
|
|
48
|
-
]
|
|
49
|
-
})
|
|
50
|
-
});
|
|
50
|
+
{ id: 'global', name: 'Global Brain', layer: 'global', exists: true, node_count: 10 },
|
|
51
|
+
{ id: 'project:demo', name: 'demo', layer: 'project', exists: true, node_count: 5 },
|
|
52
|
+
],
|
|
53
|
+
}),
|
|
54
|
+
} as any);
|
|
51
55
|
|
|
52
56
|
const onBrainChange = vi.fn();
|
|
53
|
-
render(<BrainSelector activeBrainId="
|
|
57
|
+
render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
await waitFor(() => expect(screen.getByTestId('brain-selector-trigger')).toBeInTheDocument());
|
|
60
|
+
fireEvent.click(screen.getByTestId('brain-selector-trigger'));
|
|
61
|
+
fireEvent.click(screen.getByTestId('brain-option-project:demo'));
|
|
62
|
+
expect(onBrainChange).toHaveBeenCalledWith('project:demo');
|
|
63
|
+
});
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
it('multi-select via checkbox', async () => {
|
|
66
|
+
vi.mocked(apiFetch).mockResolvedValue({
|
|
67
|
+
ok: true,
|
|
68
|
+
json: async () => ({
|
|
69
|
+
brains: [
|
|
70
|
+
{ id: 'global', name: 'Global Brain', layer: 'global', exists: true, node_count: 10 },
|
|
71
|
+
{ id: 'project:demo', name: 'demo', layer: 'project', exists: true, node_count: 5 },
|
|
72
|
+
],
|
|
73
|
+
}),
|
|
74
|
+
} as any);
|
|
63
75
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
expect(
|
|
76
|
+
const onBrainChange = vi.fn();
|
|
77
|
+
render(<BrainSelector activeBrainId="global" onBrainChange={onBrainChange} />);
|
|
78
|
+
await waitFor(() => expect(screen.getByTestId('brain-selector-trigger')).toBeInTheDocument());
|
|
79
|
+
fireEvent.click(screen.getByTestId('brain-selector-trigger'));
|
|
80
|
+
const checkbox = screen.getByLabelText('Include demo');
|
|
81
|
+
fireEvent.click(checkbox);
|
|
82
|
+
expect(onBrainChange).toHaveBeenCalledWith('global,project:demo');
|
|
83
|
+
});
|
|
67
84
|
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
it('shows error with retry when API fails', async () => {
|
|
86
|
+
vi.mocked(apiFetch).mockResolvedValue({
|
|
87
|
+
ok: false,
|
|
88
|
+
status: 401,
|
|
89
|
+
json: async () => ({ error: 'Authentication required' }),
|
|
90
|
+
} as any);
|
|
70
91
|
|
|
71
|
-
|
|
92
|
+
render(<BrainSelector activeBrainId="global" onBrainChange={vi.fn()} />);
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(screen.getByTestId('brain-selector-error')).toBeInTheDocument();
|
|
95
|
+
});
|
|
72
96
|
});
|
|
73
97
|
});
|