mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix: keep AMR environment switching consistent * fix: package every AMR console environment * fix: discover daemon directly for profile switch * fix: apply selected AMR profile to billing * fix: gate personal runs by workspace balance * fix: complete feature-test account switching * fix: close AMR environment review gaps
816 lines
24 KiB
TypeScript
816 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { useSyncExternalStore } from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { App } from '../../src/App';
|
|
import { navigate, type Route } from '../../src/router';
|
|
import type { AppConfig } from '../../src/types';
|
|
import { loadConfig, mergeDaemonConfig, fetchDaemonConfig } from '../../src/state/config';
|
|
import {
|
|
daemonIsLive,
|
|
fetchAgentsStream,
|
|
fetchAppVersionInfo,
|
|
fetchDesignSystems,
|
|
fetchPromptTemplates,
|
|
fetchSkills,
|
|
} from '../../src/providers/registry';
|
|
import { fetchAmrModels, fetchVelaLoginStatus } from '../../src/providers/daemon';
|
|
import { listProjects, listTemplates } from '../../src/state/projects';
|
|
|
|
// Settings is now a full-page route (`/settings`): App.openSettings navigates
|
|
// instead of toggling a modal flag, so the router mock must feed navigate()
|
|
// calls back into useRoute() (like the production useSyncExternalStore router)
|
|
// for the settings surface to render at all.
|
|
const homeRouteMock = { kind: 'home' as const, view: 'home' as const };
|
|
const routeListeners = new Set<() => void>();
|
|
const useRouteMock = vi.fn<() => Route>(() => homeRouteMock);
|
|
|
|
vi.mock('../../src/router', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/router')>('../../src/router');
|
|
return {
|
|
...actual,
|
|
navigate: vi.fn((route: unknown) => {
|
|
useRouteMock.mockReturnValue(route as never);
|
|
routeListeners.forEach((notify) => notify());
|
|
}),
|
|
useRoute: () =>
|
|
useSyncExternalStore(
|
|
(onChange) => {
|
|
routeListeners.add(onChange);
|
|
return () => routeListeners.delete(onChange);
|
|
},
|
|
useRouteMock,
|
|
),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/components/EntryView', () => ({
|
|
EntryView: ({
|
|
agents,
|
|
config,
|
|
onOpenSettings,
|
|
}: {
|
|
agents: Array<{ id: string; models?: Array<{ id: string }>; authStatus?: string }>;
|
|
config: AppConfig;
|
|
onOpenSettings: () => void;
|
|
}) => (
|
|
<>
|
|
<div data-testid="amr-model">
|
|
{agents.find((agent) => agent.id === 'amr')?.models?.[0]?.id ?? 'none'}
|
|
</div>
|
|
<div data-testid="config-amr-model">
|
|
{config.agentModels?.amr?.model ?? 'none'}
|
|
</div>
|
|
<div data-testid="amr-profile">
|
|
{config.agentCliEnv?.amr?.OPEN_DESIGN_AMR_PROFILE ?? 'none'}
|
|
</div>
|
|
<div data-testid="codex-auth">
|
|
{agents.find((agent) => agent.id === 'codex')?.authStatus ?? 'none'}
|
|
</div>
|
|
<button onClick={() => onOpenSettings()}>open settings</button>
|
|
</>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../src/components/ProjectView', () => ({
|
|
ProjectView: () => <div>Project view</div>,
|
|
}));
|
|
|
|
vi.mock('../../src/components/pet/PetOverlay', () => ({
|
|
PetOverlay: () => null,
|
|
}));
|
|
|
|
vi.mock('../../src/components/pet/pets', () => ({
|
|
migrateCustomPetAtlas: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
vi.mock('../../src/components/SettingsDialog', () => ({
|
|
SettingsDialog: ({
|
|
onRefreshAgents,
|
|
onAmrLoginStatusChange,
|
|
onClose,
|
|
}: {
|
|
onRefreshAgents: (options?: { agentCliEnv?: AppConfig['agentCliEnv'] }) => void | Promise<void>;
|
|
onAmrLoginStatusChange?: (status: {
|
|
loggedIn: boolean;
|
|
loginInFlight?: boolean;
|
|
profile: string;
|
|
user: null;
|
|
configPath: string;
|
|
} | null) => void;
|
|
onClose: () => void;
|
|
}) => (
|
|
<>
|
|
<button
|
|
onClick={() =>
|
|
void onRefreshAgents({
|
|
agentCliEnv: {
|
|
amr: { VELA_PROFILE: 'next-profile' },
|
|
},
|
|
})}
|
|
>
|
|
rescan agents
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
window.dispatchEvent(new CustomEvent('od:amr-login-status-change'));
|
|
onAmrLoginStatusChange?.({
|
|
loggedIn: true,
|
|
profile: 'default',
|
|
user: null,
|
|
configPath: '/tmp/amr-config.json',
|
|
});
|
|
}}
|
|
>
|
|
mark amr signed in
|
|
</button>
|
|
<button onClick={onClose}>close settings</button>
|
|
</>
|
|
),
|
|
}));
|
|
|
|
vi.mock('../../src/providers/registry', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
|
'../../src/providers/registry',
|
|
);
|
|
return {
|
|
...actual,
|
|
daemonIsLive: vi.fn(),
|
|
fetchAgentsStream: vi.fn(),
|
|
fetchAppVersionInfo: vi.fn(),
|
|
fetchDesignSystems: vi.fn(),
|
|
fetchPromptTemplates: vi.fn(),
|
|
fetchSkills: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/providers/daemon', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/providers/daemon')>(
|
|
'../../src/providers/daemon',
|
|
);
|
|
return {
|
|
...actual,
|
|
fetchAmrModels: vi.fn(),
|
|
fetchVelaLoginStatus: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/state/projects', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
|
|
'../../src/state/projects',
|
|
);
|
|
return {
|
|
...actual,
|
|
listProjects: vi.fn(),
|
|
listTemplates: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../src/state/config', async () => {
|
|
const actual = await vi.importActual<typeof import('../../src/state/config')>(
|
|
'../../src/state/config',
|
|
);
|
|
return {
|
|
...actual,
|
|
fetchComposioConfigFromDaemon: vi.fn().mockResolvedValue(null),
|
|
loadConfig: vi.fn(),
|
|
mergeDaemonConfig: vi.fn(),
|
|
saveConfig: vi.fn(),
|
|
fetchDaemonConfig: vi.fn().mockResolvedValue({}),
|
|
fetchMediaProvidersFromDaemon: vi.fn().mockResolvedValue({
|
|
status: 'ok',
|
|
providers: null,
|
|
}),
|
|
syncComposioConfigToDaemon: vi.fn().mockResolvedValue(true),
|
|
syncConfigToDaemon: vi.fn().mockResolvedValue(undefined),
|
|
syncMediaProvidersToDaemon: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
const mockedDaemonIsLive = vi.mocked(daemonIsLive);
|
|
const mockedFetchAgentsStream = vi.mocked(fetchAgentsStream);
|
|
const mockedFetchAppVersionInfo = vi.mocked(fetchAppVersionInfo);
|
|
const mockedFetchDesignSystems = vi.mocked(fetchDesignSystems);
|
|
const mockedFetchPromptTemplates = vi.mocked(fetchPromptTemplates);
|
|
const mockedFetchSkills = vi.mocked(fetchSkills);
|
|
const mockedFetchAmrModels = vi.mocked(fetchAmrModels);
|
|
const mockedFetchVelaLoginStatus = vi.mocked(fetchVelaLoginStatus);
|
|
const mockedListProjects = vi.mocked(listProjects);
|
|
const mockedListTemplates = vi.mocked(listTemplates);
|
|
const mockedLoadConfig = vi.mocked(loadConfig);
|
|
const mockedMergeDaemonConfig = vi.mocked(mergeDaemonConfig);
|
|
const mockedFetchDaemonConfig = vi.mocked(fetchDaemonConfig);
|
|
const mockedNavigate = vi.mocked(navigate);
|
|
|
|
const baseConfig: AppConfig = {
|
|
mode: 'api',
|
|
apiKey: '',
|
|
apiProtocol: 'anthropic',
|
|
apiVersion: '',
|
|
baseUrl: 'https://api.anthropic.com',
|
|
model: 'claude-sonnet-4-5',
|
|
apiProviderBaseUrl: 'https://api.anthropic.com',
|
|
apiProtocolConfigs: {},
|
|
agentId: null,
|
|
skillId: null,
|
|
designSystemId: null,
|
|
onboardingCompleted: true,
|
|
mediaProviders: {},
|
|
composio: {},
|
|
agentModels: {},
|
|
agentCliEnv: {},
|
|
};
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
async function advanceTestClock(ms: number): Promise<void> {
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(ms);
|
|
});
|
|
}
|
|
|
|
describe('App AMR polling', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
useRouteMock.mockReturnValue(homeRouteMock);
|
|
mockedDaemonIsLive.mockResolvedValue(true);
|
|
mockedFetchAgentsStream.mockResolvedValue([
|
|
{
|
|
id: 'amr',
|
|
name: 'AMR',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '1.0.0',
|
|
models: [],
|
|
},
|
|
]);
|
|
mockedFetchSkills.mockResolvedValue([]);
|
|
mockedFetchDesignSystems.mockResolvedValue([]);
|
|
mockedFetchPromptTemplates.mockResolvedValue([]);
|
|
mockedFetchAppVersionInfo.mockResolvedValue(null);
|
|
mockedFetchVelaLoginStatus.mockResolvedValue(null);
|
|
mockedListProjects.mockResolvedValue([]);
|
|
mockedListTemplates.mockResolvedValue([]);
|
|
mockedLoadConfig.mockReturnValue({ ...baseConfig });
|
|
mockedMergeDaemonConfig.mockImplementation((local) => local);
|
|
mockedFetchDaemonConfig.mockResolvedValue({});
|
|
mockedFetchAmrModels
|
|
.mockResolvedValueOnce({
|
|
source: 'preset',
|
|
refreshing: true,
|
|
models: [{ id: 'preset-a', label: 'preset-a' }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
source: 'preset',
|
|
refreshing: true,
|
|
models: [{ id: 'preset-a', label: 'preset-a' }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'remote-a', label: 'remote-a' }],
|
|
});
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('keeps polling AMR models until the remote catalog replaces the preset list', async () => {
|
|
vi.useFakeTimers();
|
|
render(<App />);
|
|
|
|
await advanceTestClock(0);
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('preset-a');
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(1);
|
|
|
|
await advanceTestClock(1_999);
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2);
|
|
await advanceTestClock(1);
|
|
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('remote-a');
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('refreshes AMR status and model catalog when returning from an external upgrade flow', async () => {
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels
|
|
.mockResolvedValueOnce({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'locked-model', label: 'locked-model', enabled: false }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'unlocked-model', label: 'unlocked-model', enabled: true }],
|
|
});
|
|
mockedFetchVelaLoginStatus.mockResolvedValue({
|
|
loggedIn: true,
|
|
loginInFlight: false,
|
|
profile: 'local',
|
|
user: null,
|
|
configPath: '/tmp/amr-config.json',
|
|
account: { plan: 'pro' },
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('locked-model');
|
|
});
|
|
|
|
fireEvent(window, new Event('focus'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedFetchVelaLoginStatus).toHaveBeenCalledWith({ refresh: true });
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('unlocked-model');
|
|
});
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('returns every authenticated surface to onboarding when Cloud auth definitively expires', async () => {
|
|
mockedLoadConfig.mockReturnValue({
|
|
...baseConfig,
|
|
mode: 'daemon',
|
|
agentId: 'amr',
|
|
});
|
|
useRouteMock.mockReturnValue({
|
|
kind: 'project',
|
|
projectId: 'project-with-expired-auth',
|
|
conversationId: null,
|
|
fileName: null,
|
|
});
|
|
mockedFetchVelaLoginStatus.mockResolvedValue({
|
|
loggedIn: true,
|
|
loginInFlight: false,
|
|
profile: 'local',
|
|
user: { id: 'expired-user', email: 'expired@example.com' },
|
|
configPath: '/tmp/amr-config.json',
|
|
sessionState: 'reauth_required',
|
|
credentialRevision: 'expired-revision',
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedNavigate).toHaveBeenCalledWith(
|
|
{ kind: 'home', view: 'onboarding' },
|
|
{ replace: true },
|
|
);
|
|
});
|
|
});
|
|
|
|
it('starts AMR preset polling before the agent probe resolves', { timeout: 10_000 }, async () => {
|
|
let resolveAgents!: (value: Array<{
|
|
id: string;
|
|
name: string;
|
|
bin: string;
|
|
available: boolean;
|
|
version: string;
|
|
models: Array<{ id: string; label: string }>;
|
|
}>) => void;
|
|
mockedFetchAgentsStream.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveAgents = resolve;
|
|
}),
|
|
);
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels.mockResolvedValue({
|
|
source: 'preset',
|
|
refreshing: true,
|
|
models: [{ id: 'preset-a', label: 'preset-a' }],
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(1);
|
|
});
|
|
resolveAgents([
|
|
{
|
|
id: 'amr',
|
|
name: 'AMR',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '1.0.0',
|
|
models: [],
|
|
},
|
|
]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('preset-a');
|
|
});
|
|
});
|
|
|
|
it('rescans agents on window focus so external CLI auth changes are detected', async () => {
|
|
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(0);
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels.mockResolvedValue({
|
|
source: 'preset',
|
|
refreshing: false,
|
|
models: [{ id: 'preset-a', label: 'preset-a' }],
|
|
});
|
|
mockedFetchAgentsStream
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'codex',
|
|
name: 'Codex CLI',
|
|
bin: 'codex',
|
|
available: true,
|
|
version: 'codex-cli 9.9.9',
|
|
authStatus: 'missing',
|
|
models: [],
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'codex',
|
|
name: 'Codex CLI',
|
|
bin: 'codex',
|
|
available: true,
|
|
version: 'codex-cli 9.9.9',
|
|
authStatus: 'ok',
|
|
models: [],
|
|
},
|
|
]);
|
|
|
|
try {
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('codex-auth').textContent).toBe('missing');
|
|
});
|
|
|
|
fireEvent(window, new Event('focus'));
|
|
expect(mockedFetchAgentsStream).toHaveBeenCalledTimes(1);
|
|
|
|
await waitFor(() => {
|
|
nowSpy.mockReturnValue(10_001);
|
|
fireEvent(window, new Event('focus'));
|
|
expect(mockedFetchAgentsStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('codex-auth').textContent).toBe('ok');
|
|
});
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('restarts AMR polling after sign-in when preset refresh previously stopped on a remote error', async () => {
|
|
vi.useFakeTimers();
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels
|
|
.mockResolvedValueOnce({
|
|
source: 'preset',
|
|
refreshing: true,
|
|
models: [{ id: 'preset-a', label: 'preset-a' }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
source: 'preset',
|
|
refreshing: true,
|
|
remoteError: 'remote unavailable',
|
|
models: [{ id: 'preset-a', label: 'preset-a' }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'remote-a', label: 'remote-a' }],
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await advanceTestClock(0);
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(1);
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('preset-a');
|
|
|
|
await advanceTestClock(1_000);
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2);
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('preset-a');
|
|
|
|
await advanceTestClock(1_500);
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2);
|
|
mockedFetchVelaLoginStatus.mockResolvedValue({
|
|
loggedIn: true,
|
|
profile: 'default',
|
|
user: null,
|
|
configPath: '/tmp/amr-config.json',
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('open settings'));
|
|
expect(screen.getByText('mark amr signed in')).toBeTruthy();
|
|
fireEvent.click(screen.getByText('mark amr signed in'));
|
|
await advanceTestClock(0);
|
|
|
|
// Settings is a full-page route now; return home so the EntryView
|
|
// mock (which renders the amr-model probe) is mounted again.
|
|
fireEvent.click(screen.getByText('close settings'));
|
|
await advanceTestClock(0);
|
|
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(3);
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('remote-a');
|
|
});
|
|
|
|
it('does not restart AMR model polling for repeated signed-in status snapshots', async () => {
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels.mockResolvedValue({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'remote-a', label: 'remote-a' }],
|
|
});
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('open settings'));
|
|
await waitFor(() => {
|
|
expect(screen.getByText('mark amr signed in')).toBeTruthy();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('mark amr signed in'));
|
|
await waitFor(() => {
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('mark amr signed in'));
|
|
fireEvent.click(screen.getByText('mark amr signed in'));
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('stops polling after the preset retry budget is exhausted when remote never arrives', async () => {
|
|
vi.useFakeTimers();
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels.mockImplementation(async () => ({
|
|
source: 'preset',
|
|
refreshing: true,
|
|
models: [{ id: 'preset-a', label: 'preset-a' }],
|
|
}));
|
|
|
|
render(<App />);
|
|
|
|
await advanceTestClock(0);
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(1);
|
|
await advanceTestClock(10_000);
|
|
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(11);
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('preset-a');
|
|
|
|
await advanceTestClock(1_500);
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(11);
|
|
});
|
|
|
|
it('does not merge stale AMR remote models over a rescan with new agent env', async () => {
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels.mockResolvedValue({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'old-remote', label: 'old-remote' }],
|
|
});
|
|
mockedFetchAgentsStream
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'amr',
|
|
name: 'AMR',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '1.0.0',
|
|
models: [],
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'amr',
|
|
name: 'AMR',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '1.0.0',
|
|
models: [{ id: 'new-probe', label: 'new-probe' }],
|
|
},
|
|
]);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('old-remote');
|
|
});
|
|
|
|
fireEvent.click(screen.getByText('open settings'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('rescan agents')).toBeTruthy();
|
|
});
|
|
fireEvent.click(screen.getByText('rescan agents'));
|
|
|
|
// Settings is a full-page route now; return home so the EntryView
|
|
// mock (which renders the amr-model probe) is mounted again.
|
|
fireEvent.click(screen.getByText('close settings'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('new-probe');
|
|
});
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('refreshes renderer config and clears stale AMR models after a desktop app-config change event', async () => {
|
|
mockedLoadConfig.mockReturnValue({
|
|
...baseConfig,
|
|
agentModels: { amr: { model: 'old-remote', reasoning: 'default' } },
|
|
agentCliEnv: {
|
|
amr: { OPEN_DESIGN_AMR_PROFILE: 'prod' },
|
|
},
|
|
});
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels
|
|
.mockResolvedValueOnce({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'old-remote', label: 'old-remote' }],
|
|
})
|
|
.mockResolvedValueOnce({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'local-remote', label: 'local-remote' }],
|
|
});
|
|
mockedFetchDaemonConfig
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({
|
|
agentCliEnv: {
|
|
amr: { OPEN_DESIGN_AMR_PROFILE: 'local' },
|
|
},
|
|
});
|
|
mockedMergeDaemonConfig.mockImplementation((local, daemon) => ({
|
|
...local,
|
|
agentCliEnv: daemon?.agentCliEnv ?? local.agentCliEnv,
|
|
}));
|
|
mockedFetchAgentsStream
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'amr',
|
|
name: 'AMR',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '1.0.0',
|
|
models: [],
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'amr',
|
|
name: 'AMR',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '1.0.0',
|
|
models: [{ id: 'local-probe', label: 'local-probe' }],
|
|
},
|
|
]);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('old-remote');
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('config-amr-model').textContent).toBe('old-remote');
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-profile').textContent).toBe('prod');
|
|
});
|
|
|
|
const fetchMock = vi.mocked(globalThis.fetch);
|
|
const workspaceDirectoryReadsBefore = fetchMock.mock.calls.filter(([input]) =>
|
|
input.toString().includes('/api/workspace/directory')).length;
|
|
|
|
fireEvent(window, new CustomEvent('open-design:app-config-changed'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-profile').textContent).toBe('local');
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('config-amr-model').textContent).toBe('none');
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('local-remote');
|
|
});
|
|
await waitFor(() => {
|
|
expect(mockedFetchAgentsStream).toHaveBeenCalledTimes(2);
|
|
});
|
|
await waitFor(() => {
|
|
expect(fetchMock.mock.calls.filter(([input]) =>
|
|
input.toString().includes('/api/workspace/directory')).length,
|
|
).toBeGreaterThan(workspaceDirectoryReadsBefore);
|
|
});
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('ignores stale in-flight AMR model polls after a desktop app-config change restarts polling', async () => {
|
|
const oldRemotePoll = deferred<Awaited<ReturnType<typeof fetchAmrModels>>>();
|
|
const localRemotePoll = deferred<Awaited<ReturnType<typeof fetchAmrModels>>>();
|
|
mockedLoadConfig.mockReturnValue({
|
|
...baseConfig,
|
|
agentCliEnv: {
|
|
amr: { OPEN_DESIGN_AMR_PROFILE: 'prod' },
|
|
},
|
|
});
|
|
mockedFetchDaemonConfig
|
|
.mockResolvedValueOnce({})
|
|
.mockResolvedValueOnce({
|
|
agentCliEnv: {
|
|
amr: { OPEN_DESIGN_AMR_PROFILE: 'local' },
|
|
},
|
|
});
|
|
mockedMergeDaemonConfig.mockImplementation((local, daemon) => ({
|
|
...local,
|
|
agentCliEnv: daemon?.agentCliEnv ?? local.agentCliEnv,
|
|
}));
|
|
mockedFetchAmrModels.mockReset();
|
|
mockedFetchAmrModels
|
|
.mockReturnValueOnce(oldRemotePoll.promise)
|
|
.mockReturnValueOnce(localRemotePoll.promise);
|
|
mockedFetchAgentsStream
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'amr',
|
|
name: 'AMR',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '1.0.0',
|
|
models: [],
|
|
},
|
|
])
|
|
.mockResolvedValueOnce([
|
|
{
|
|
id: 'amr',
|
|
name: 'AMR',
|
|
bin: 'vela',
|
|
available: true,
|
|
version: '1.0.0',
|
|
models: [{ id: 'local-probe', label: 'local-probe' }],
|
|
},
|
|
]);
|
|
|
|
render(<App />);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
fireEvent(window, new CustomEvent('open-design:app-config-changed'));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedFetchAmrModels).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
localRemotePoll.resolve({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'local-remote', label: 'local-remote' }],
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-profile').textContent).toBe('local');
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('local-remote');
|
|
});
|
|
|
|
oldRemotePoll.resolve({
|
|
source: 'remote',
|
|
refreshing: false,
|
|
models: [{ id: 'old-remote', label: 'old-remote' }],
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('amr-model').textContent).toBe('local-remote');
|
|
});
|
|
});
|
|
});
|