406 lines
17 KiB
TypeScript
406 lines
17 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import { createCodingPluginsStore } from '@/stores/coding-plugins';
|
|
import type {
|
|
DataServiceCollectionRemoval,
|
|
DataServiceHostResult,
|
|
DataServiceInstanceRemoval,
|
|
DataServiceInstanceState,
|
|
} from '../../shared/data-service';
|
|
|
|
function projection(enabled = false, projectId = 'local-project') {
|
|
return {
|
|
schemaVersion: 1 as const,
|
|
project: { localProjectId: projectId, durableProjectId: null },
|
|
policyStatus: 'current' as const,
|
|
unknownPluginIds: [],
|
|
items: [{
|
|
id: 'makelore.data-service', version: '1.0.0', displayName: '开发数据服务', description: '项目数据',
|
|
enabled, state: enabled ? 'configuration_required' as const : 'disabled' as const,
|
|
backend: { status: 'unconfigured' as const }, skills: [{ id: 'data-service', assignedAgentIds: [] }],
|
|
capabilities: [], settingsSurface: 'data-service',
|
|
}],
|
|
};
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((done) => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
function dataServiceInstance(instanceId: string): DataServiceInstanceState {
|
|
return {
|
|
instance_id: instanceId, project_id: 'cloud-project', collections: [],
|
|
usage: { document_count: 0, total_bytes: 0 },
|
|
limits: {
|
|
max_collections: 20, max_documents: 1000, max_total_bytes: 20_971_520,
|
|
max_document_bytes: 65_536, list_default_limit: 50, list_max_limit: 100,
|
|
list_max_data_bytes: 1_048_576, mutations_per_minute: 120,
|
|
},
|
|
created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z',
|
|
};
|
|
}
|
|
|
|
function success<T>(data: T): DataServiceHostResult<T> {
|
|
return {
|
|
success: true, status: 200, code: null, error: null, retryable: false, data,
|
|
};
|
|
}
|
|
|
|
describe('coding plugins store', () => {
|
|
it('coalesces duplicate loads and mutations while retaining the last projection on failure', async () => {
|
|
let resolveLoad!: (value: ReturnType<typeof projection>) => void;
|
|
const list = vi.fn(() => new Promise<ReturnType<typeof projection>>((resolve) => { resolveLoad = resolve; }));
|
|
const setEnabled = vi.fn().mockResolvedValue(projection(true));
|
|
const store = createCodingPluginsStore({ list, setEnabled });
|
|
|
|
const first = store.getState().load('local-project');
|
|
const second = store.getState().load('local-project');
|
|
resolveLoad(projection());
|
|
await Promise.all([first, second]);
|
|
expect(list).toHaveBeenCalledOnce();
|
|
|
|
await Promise.all([
|
|
store.getState().setEnabled('local-project', 'makelore.data-service', true),
|
|
store.getState().setEnabled('local-project', 'makelore.data-service', true),
|
|
]);
|
|
expect(setEnabled).toHaveBeenCalledOnce();
|
|
expect(store.getState().projection?.items[0].enabled).toBe(true);
|
|
|
|
list.mockRejectedValueOnce(new Error('offline'));
|
|
await expect(store.getState().load('other-project')).rejects.toThrow('offline');
|
|
expect(store.getState().projection?.project.localProjectId).toBe('local-project');
|
|
expect(store.getState().loadState).toBe('error');
|
|
});
|
|
|
|
it('clears project-scoped Data Service data when another project loads', async () => {
|
|
const store = createCodingPluginsStore({ list: vi.fn().mockResolvedValue(projection(false, 'other-project')) });
|
|
store.setState({
|
|
projectId: 'local-project',
|
|
dataService: {
|
|
instance_id: 'instance-1', project_id: 'cloud-project', collections: [],
|
|
usage: { document_count: 0, total_bytes: 0 },
|
|
limits: { max_collections: 20, max_documents: 1000, max_total_bytes: 20971520, max_document_bytes: 65536, list_default_limit: 50, list_max_limit: 100, list_max_data_bytes: 1048576, mutations_per_minute: 120 },
|
|
created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z',
|
|
},
|
|
});
|
|
await store.getState().load('other-project');
|
|
expect(store.getState().dataService).toBeNull();
|
|
});
|
|
|
|
it('commits only the latest project load success or error', async () => {
|
|
const pending = new Map<string, {
|
|
resolve: (value: ReturnType<typeof projection>) => void;
|
|
reject: (reason: Error) => void;
|
|
}>();
|
|
const list = vi.fn((projectId: string) => new Promise<ReturnType<typeof projection>>((resolve, reject) => {
|
|
pending.set(projectId, { resolve, reject });
|
|
}));
|
|
const store = createCodingPluginsStore({ list });
|
|
|
|
const first = store.getState().load('project-a');
|
|
const second = store.getState().load('project-b');
|
|
pending.get('project-b')?.resolve(projection(false, 'project-b'));
|
|
await second;
|
|
pending.get('project-a')?.resolve(projection(false, 'project-a'));
|
|
await first;
|
|
expect(store.getState()).toMatchObject({
|
|
projectId: 'project-b', loadState: 'ready', error: null,
|
|
});
|
|
|
|
const third = store.getState().load('project-a');
|
|
const fourth = store.getState().load('project-b');
|
|
pending.get('project-b')?.resolve(projection(false, 'project-b'));
|
|
await fourth;
|
|
pending.get('project-a')?.reject(new Error('late project-a failure'));
|
|
await expect(third).rejects.toThrow('late project-a failure');
|
|
expect(store.getState()).toMatchObject({
|
|
projectId: 'project-b', loadState: 'ready', error: null,
|
|
});
|
|
});
|
|
|
|
it.each(['project-b first', 'project-a first'] as const)(
|
|
'renews a coalesced project load as the latest intent when %s completes',
|
|
async (completionOrder) => {
|
|
const pending = new Map<string, ReturnType<typeof deferred<ReturnType<typeof projection>>>>();
|
|
const list = vi.fn((projectId: string) => {
|
|
const request = deferred<ReturnType<typeof projection>>();
|
|
pending.set(projectId, request);
|
|
return request.promise;
|
|
});
|
|
const store = createCodingPluginsStore({ list });
|
|
store.setState({ projectId: 'project-a', projection: projection(false, 'project-a') });
|
|
|
|
const firstProjectA = store.getState().load('project-a');
|
|
const projectB = store.getState().load('project-b');
|
|
const renewedProjectA = store.getState().load('project-a');
|
|
|
|
expect(renewedProjectA).toBe(firstProjectA);
|
|
expect(list).toHaveBeenCalledTimes(2);
|
|
expect(Object.keys(store.getState().pending).sort()).toEqual([
|
|
'load:project-a', 'load:project-b',
|
|
]);
|
|
|
|
if (completionOrder === 'project-b first') {
|
|
pending.get('project-b')?.resolve(projection(false, 'project-b'));
|
|
await projectB;
|
|
expect(store.getState().projectId).toBe('project-a');
|
|
pending.get('project-a')?.resolve(projection(true, 'project-a'));
|
|
} else {
|
|
pending.get('project-a')?.resolve(projection(true, 'project-a'));
|
|
await firstProjectA;
|
|
pending.get('project-b')?.resolve(projection(false, 'project-b'));
|
|
}
|
|
|
|
await Promise.all([firstProjectA, projectB, renewedProjectA]);
|
|
expect(store.getState()).toMatchObject({
|
|
projectId: 'project-a', loadState: 'ready', error: null,
|
|
});
|
|
expect(store.getState().projection?.items[0].enabled).toBe(true);
|
|
expect(store.getState().pending).toEqual({});
|
|
},
|
|
);
|
|
|
|
it('does not let an older same-project load overwrite a completed enable mutation', async () => {
|
|
const staleLoad = deferred<ReturnType<typeof projection>>();
|
|
const store = createCodingPluginsStore({
|
|
list: vi.fn(() => staleLoad.promise),
|
|
setEnabled: vi.fn().mockResolvedValue(projection(true)),
|
|
});
|
|
store.setState({ projectId: 'local-project', projection: projection(false) });
|
|
|
|
const load = store.getState().load('local-project');
|
|
await store.getState().setEnabled('local-project', 'makelore.data-service', true);
|
|
staleLoad.resolve(projection(false));
|
|
await load;
|
|
|
|
expect(store.getState().projection?.items[0].enabled).toBe(true);
|
|
});
|
|
|
|
it('commits a delayed enable after a same-project refresh observes old state', async () => {
|
|
const enabled = deferred<ReturnType<typeof projection>>();
|
|
const store = createCodingPluginsStore({
|
|
list: vi.fn().mockResolvedValue(projection(false)),
|
|
setEnabled: vi.fn(() => enabled.promise),
|
|
});
|
|
store.setState({ projectId: 'local-project', projection: projection(false) });
|
|
|
|
const mutation = store.getState().setEnabled('local-project', 'makelore.data-service', true);
|
|
await store.getState().load('local-project');
|
|
expect(store.getState().projection?.items[0].enabled).toBe(false);
|
|
enabled.resolve(projection(true));
|
|
await mutation;
|
|
|
|
expect(store.getState().projection?.items[0].enabled).toBe(true);
|
|
});
|
|
|
|
const adjacentMutationCases = [
|
|
{
|
|
name: 'configure',
|
|
verify: async () => {
|
|
const configured = deferred<DataServiceHostResult<DataServiceInstanceState>>();
|
|
let loadCount = 0;
|
|
const list = vi.fn(async () => projection(++loadCount > 1));
|
|
const store = createCodingPluginsStore({
|
|
list,
|
|
configureDataService: vi.fn(() => configured.promise),
|
|
});
|
|
store.setState({ projectId: 'local-project', projection: projection(false) });
|
|
const mutation = store.getState().configure(['todos']);
|
|
await store.getState().load('local-project');
|
|
configured.resolve(success(dataServiceInstance('configured')));
|
|
await mutation;
|
|
expect(list).toHaveBeenCalledTimes(2);
|
|
expect(store.getState()).toMatchObject({
|
|
projection: { items: [{ enabled: true }] },
|
|
dataService: { instance_id: 'configured' },
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: 'reset',
|
|
verify: async () => {
|
|
const reset = deferred<DataServiceHostResult<DataServiceInstanceState>>();
|
|
const store = createCodingPluginsStore({
|
|
list: vi.fn().mockResolvedValue(projection(false)),
|
|
resetDataService: vi.fn(() => reset.promise),
|
|
});
|
|
store.setState({
|
|
projectId: 'local-project', projection: projection(false),
|
|
dataService: dataServiceInstance('before-reset'),
|
|
});
|
|
const mutation = store.getState().reset();
|
|
await store.getState().load('local-project');
|
|
reset.resolve(success(dataServiceInstance('after-reset')));
|
|
await mutation;
|
|
expect(store.getState().dataService?.instance_id).toBe('after-reset');
|
|
},
|
|
},
|
|
{
|
|
name: 'removeCollection',
|
|
verify: async () => {
|
|
const removed = deferred<DataServiceHostResult<DataServiceCollectionRemoval>>();
|
|
const inspectDataService = vi.fn().mockResolvedValue(success(dataServiceInstance('after-remove')));
|
|
const store = createCodingPluginsStore({
|
|
list: vi.fn().mockResolvedValue(projection(false)),
|
|
removeCollection: vi.fn(() => removed.promise),
|
|
inspectDataService,
|
|
});
|
|
store.setState({
|
|
projectId: 'local-project', projection: projection(false),
|
|
dataService: dataServiceInstance('before-remove'),
|
|
});
|
|
const mutation = store.getState().removeCollection('todos');
|
|
await store.getState().load('local-project');
|
|
removed.resolve(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } }));
|
|
await mutation;
|
|
expect(inspectDataService).toHaveBeenCalledOnce();
|
|
expect(store.getState().dataService?.instance_id).toBe('after-remove');
|
|
},
|
|
},
|
|
{
|
|
name: 'removeProject',
|
|
verify: async () => {
|
|
const removed = deferred<DataServiceHostResult<DataServiceInstanceRemoval>>();
|
|
const list = vi.fn().mockResolvedValue(projection(false));
|
|
const store = createCodingPluginsStore({
|
|
list,
|
|
removeProject: vi.fn(() => removed.promise),
|
|
});
|
|
store.setState({
|
|
projectId: 'local-project', projection: projection(false),
|
|
dataService: dataServiceInstance('before-remove-project'),
|
|
});
|
|
const mutation = store.getState().removeProject();
|
|
await store.getState().load('local-project');
|
|
removed.resolve(success({ removed: true }));
|
|
await mutation;
|
|
expect(list).toHaveBeenCalledTimes(2);
|
|
expect(store.getState().dataService).toBeNull();
|
|
},
|
|
},
|
|
];
|
|
|
|
it.each(adjacentMutationCases)(
|
|
'uses the same same-project ordering guard for $name',
|
|
async ({ verify }) => await verify(),
|
|
);
|
|
|
|
it('reloads the newest projection when collection removal succeeds but inspection fails', async () => {
|
|
const readyProjection = projection(true);
|
|
readyProjection.items[0] = {
|
|
...readyProjection.items[0], state: 'ready', backend: { status: 'ready' },
|
|
};
|
|
const afterRemoval = dataServiceInstance('after-remove-reload');
|
|
const inspectDataService = vi.fn()
|
|
.mockResolvedValueOnce({
|
|
success: false, status: 503, code: 'offline', error: 'offline', retryable: true, data: null,
|
|
})
|
|
.mockResolvedValueOnce(success(afterRemoval));
|
|
const list = vi.fn().mockResolvedValue(readyProjection);
|
|
const store = createCodingPluginsStore({
|
|
list,
|
|
inspectDataService,
|
|
removeCollection: vi.fn().mockResolvedValue(success({
|
|
removed: true, usage: { document_count: 0, total_bytes: 0 },
|
|
})),
|
|
});
|
|
store.setState({
|
|
projectId: 'local-project', projection: readyProjection,
|
|
dataService: dataServiceInstance('before-remove-reload'),
|
|
});
|
|
|
|
await store.getState().removeCollection('todos');
|
|
|
|
expect(list).toHaveBeenCalledOnce();
|
|
expect(inspectDataService).toHaveBeenCalledTimes(2);
|
|
expect(store.getState().dataService).toEqual(afterRemoval);
|
|
});
|
|
|
|
it('does not let a late project A enable mutation overwrite loaded project B', async () => {
|
|
const enabledA = deferred<ReturnType<typeof projection>>();
|
|
const inspectDataService = vi.fn();
|
|
const store = createCodingPluginsStore({
|
|
list: vi.fn().mockResolvedValue(projection(false, 'project-b')),
|
|
setEnabled: vi.fn(() => enabledA.promise),
|
|
inspectDataService,
|
|
});
|
|
store.setState({ projectId: 'project-a', projection: projection(false, 'project-a') });
|
|
|
|
const mutation = store.getState().setEnabled('project-a', 'makelore.data-service', true);
|
|
await store.getState().load('project-b');
|
|
enabledA.resolve(projection(true, 'project-a'));
|
|
await mutation;
|
|
|
|
expect(store.getState()).toMatchObject({
|
|
projectId: 'project-b', projection: { project: { localProjectId: 'project-b' } },
|
|
});
|
|
expect(inspectDataService).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not let adjacent late Data Service mutations commit or reload over project B', async () => {
|
|
const configure = deferred<DataServiceHostResult<DataServiceInstanceState>>();
|
|
const reset = deferred<DataServiceHostResult<DataServiceInstanceState>>();
|
|
const removeCollection = deferred<DataServiceHostResult<DataServiceCollectionRemoval>>();
|
|
const removeProject = deferred<DataServiceHostResult<DataServiceInstanceRemoval>>();
|
|
const inspectDataService = vi.fn();
|
|
const list = vi.fn().mockResolvedValue(projection(false, 'project-b'));
|
|
const store = createCodingPluginsStore({
|
|
list,
|
|
inspectDataService,
|
|
configureDataService: vi.fn(() => configure.promise),
|
|
resetDataService: vi.fn(() => reset.promise),
|
|
removeCollection: vi.fn(() => removeCollection.promise),
|
|
removeProject: vi.fn(() => removeProject.promise),
|
|
});
|
|
store.setState({ projectId: 'project-a', projection: projection(false, 'project-a') });
|
|
|
|
const mutations = [
|
|
store.getState().configure(['todos']),
|
|
store.getState().reset(),
|
|
store.getState().removeCollection('todos'),
|
|
store.getState().removeProject(),
|
|
];
|
|
await store.getState().load('project-b');
|
|
configure.resolve(success(dataServiceInstance('late-configure')));
|
|
reset.resolve(success(dataServiceInstance('late-reset')));
|
|
removeCollection.resolve(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } }));
|
|
removeProject.resolve(success({ removed: true }));
|
|
await Promise.all(mutations);
|
|
|
|
expect(store.getState()).toMatchObject({
|
|
projectId: 'project-b',
|
|
projection: { project: { localProjectId: 'project-b' } },
|
|
dataService: null,
|
|
});
|
|
expect(inspectDataService).not.toHaveBeenCalled();
|
|
expect(list).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('retains ready Data Service usage when enabling an existing instance', async () => {
|
|
const readyProjection = projection(true);
|
|
readyProjection.items[0] = {
|
|
...readyProjection.items[0], state: 'ready', backend: { status: 'ready' },
|
|
};
|
|
const existing = {
|
|
instance_id: 'instance-1', project_id: 'cloud-project', collections: ['todos'],
|
|
usage: { document_count: 3, total_bytes: 128 },
|
|
limits: { max_collections: 20, max_documents: 1000, max_total_bytes: 20971520, max_document_bytes: 65536, list_default_limit: 50, list_max_limit: 100, list_max_data_bytes: 1048576, mutations_per_minute: 120 },
|
|
created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z',
|
|
};
|
|
const inspectDataService = vi.fn().mockResolvedValue({
|
|
success: true, status: 200, code: null, error: null, retryable: false, data: existing,
|
|
});
|
|
const store = createCodingPluginsStore({
|
|
setEnabled: vi.fn().mockResolvedValue(readyProjection), inspectDataService,
|
|
});
|
|
store.setState({ projectId: 'local-project' });
|
|
|
|
await store.getState().setEnabled('local-project', 'makelore.data-service', true);
|
|
|
|
expect(inspectDataService).toHaveBeenCalledOnce();
|
|
expect(store.getState().dataService).toEqual(existing);
|
|
expect(store.getState().projection).toEqual(readyProjection);
|
|
});
|
|
});
|