Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions src/components/agents/config/__tests__/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import PrimeVue from 'primevue/config';

// PrimeVue overlays (Select, Dialog) query matchMedia, which jsdom lacks.
if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
import ToastService from 'primevue/toastservice';
import { RouterLinkStub } from '@vue/test-utils';
import ConfirmationService from 'primevue/confirmationservice';
import { createPinia, setActivePinia, type Pinia } from 'pinia';
import { usePermissionsStore } from '@/stores/permissions';

/** A pinia whose permission store is hydrated with `permissions`. */
export function piniaWith(permissions: Record<string, string[]>): Pinia {
const pinia = createPinia();
setActivePinia(pinia);
const store = usePermissionsStore();
store.permissions = permissions;
store.loaded = true;
return pinia;
}

export function globalWith(
pinia: Pinia,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extraStubs: Record<string, any> = {},
) {
return {
plugins: [pinia, PrimeVue, ToastService, ConfirmationService],
directives: {
tooltip: { mounted: () => undefined, updated: () => undefined },
},
stubs: {
// CodeMirror: specs that render editors mock '@/components/code-editor' with
// ./codeEditorMock (sync stand-ins); only CodeEditor.spec mounts the real editor.
RouterLink: RouterLinkStub,
...extraStubs,
},
};
}

export const ADMIN = {
admin: ['manage'],
agent: ['read', 'configure'],
};
export const READER = { agent: ['read'] };

// ---- Workspace harness: a real useAgentConfig + useConfigWorkspace over a fake API ----
import { computed, defineComponent, h, watch, type Component } from 'vue';
import { vi } from 'vitest';
import type {
AgentConfigApi,
InstancesPageQuery,
} from '@/composables/agent-config/api-types';
import type {
AgentInstanceSummary,
InstanceCounts,
} from '@/types/agent-config';
import { useAgentConfig } from '@/composables/agent-config/useAgentConfig';
import {
useConfigWorkspace,
type ConfigWorkspace,
} from '@/composables/agent-config/useConfigWorkspace';
import {
configRev7,
detailFor,
instanceDetailA,
instanceIds,
instancesMixed,
} from '@/composables/agent-config/__tests__/fixtures';

/**
* A listInstances that serves `items` as the paginated API does: `limit` (default 25) a page,
* page fields in meta, and counts over every item.
*/
export function pagedListInstances(items: AgentInstanceSummary[]) {
const counts: InstanceCounts = {
total: items.length,
fresh: items.filter((i) => !i.stale).length,
stale: items.filter((i) => i.stale).length,
inSync: items.filter((i) => i.syncStatus === 'in-sync').length,
outOfSync: items.filter((i) => i.syncStatus === 'out-of-sync').length,
pending: items.filter((i) => i.status === 'pending').length,
rejected: items.filter((i) => i.status === 'rejected').length,
failed: items.filter((i) => i.status === 'failed').length,
unknown: items.filter((i) => i.status === 'unknown').length,
};
return vi.fn(async (_agentId: string, q: InstancesPageQuery = {}) => {
const page = q.page ?? 1;
const limit = q.limit ?? 25;
return {
items: items.slice((page - 1) * limit, page * limit),
meta: {
desiredRevision: instancesMixed.meta.desiredRevision,
counts,
page,
limit,
total: items.length,
totalPages: Math.max(1, Math.ceil(items.length / limit)),
},
};
});
}

/** A fake API backed by the fixtures; override any method. */
export function fakeApi(over: Partial<AgentConfigApi> = {}): AgentConfigApi {
return {
getConfig: vi.fn().mockResolvedValue(configRev7),
putConfig: vi.fn(),
preview: vi.fn(),
listRevisions: vi
.fn()
.mockResolvedValue({ items: [], total: 0, totalPages: 1 }),
getRevision: vi.fn().mockResolvedValue(configRev7),
revert: vi.fn(),
listInstances: pagedListInstances(instancesMixed.items),
getInstance: vi.fn().mockImplementation(async (_a: string, id: string) => {
if (id === instanceIds.a) return instanceDetailA;
const s = instancesMixed.items.find((i) => i.instanceId === id)!;
return detailFor(s, configRev7.overlay ?? {});
}),
...over,
};
}

/**
* A host component that loads the agent config and provides a workspace to `inner`
* (rendered with `props` once the configuration is ready). `out.ws` exposes the workspace.
* Like the Configuration tab, it loads every instance's detail for editors.
*/
export function workspaceHost(
api: AgentConfigApi,
inner: Component,
props: () => Record<string, unknown> = () => ({}),
out: { ws?: ConfigWorkspace } = {},
agentId = 'agent-1',
) {
return defineComponent({
name: 'WorkspaceHost',
setup() {
const state = useAgentConfig(
computed(() => agentId),
api,
);
const ws = useConfigWorkspace(agentId, api, state);
out.ws = ws;
// As the Configuration tab does: editors load every reporting instance's file.
watch(
() =>
state.status.value === 'ready' &&
!state.instanceLoading.value &&
ws.canConfigure.value,
(go) => {
if (go && !ws.detailsLoaded.value && !ws.detailsLoading.value)
ws.loadDetails();
},
{ immediate: true },
);
state.load();
return () =>
state.status.value === 'ready'
? h(inner, props())
: h('div', 'loading');
},
});
}
218 changes: 218 additions & 0 deletions src/composables/agent-config/__tests__/useAgentConfig.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ref } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { usePermissionsStore } from '@/stores/permissions';
import { AxiosError, AxiosHeaders } from 'axios';
import type { AgentConfigApi } from '../api-types';
import { createHttpAgentConfigApi } from '../useAgentConfigApi';
import type { AgentInstanceDetail } from '@/types/agent-config';
import {
configRev6,
configRev7,
detailFor,
instanceIds,
instancesMixed,
} from './fixtures';
import { useAgentConfig } from '../useAgentConfig';
import { MAX_INSTANCE_PAGES } from '../instancePages';

function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}

function makeApi(over: Partial<AgentConfigApi> = {}): AgentConfigApi {
return {
getConfig: vi.fn().mockResolvedValue(configRev7),
putConfig: vi.fn(),
preview: vi.fn(),
listRevisions: vi.fn(),
getRevision: vi.fn().mockResolvedValue(configRev6),
revert: vi.fn(),
listInstances: vi.fn().mockResolvedValue(instancesMixed),
getInstance: vi
.fn()
.mockImplementation(async (_a: string, id: string) =>
detailFor(instancesMixed.items.find((i) => i.instanceId === id)!, {}),
),
...over,
};
}

describe('useAgentConfig', () => {
let store: ReturnType<typeof usePermissionsStore>;
beforeEach(() => {
setActivePinia(createPinia());
store = usePermissionsStore();
});

it('waits for permission hydration and never fetches without agent:read (D-21)', async () => {
const hydrated = deferred<void>();
store.hydrate = vi.fn(async () => {
await hydrated.promise;
store.permissions = { agent: [] };
store.loaded = true;
return store.permissions;
}) as unknown as typeof store.hydrate;
const api = makeApi();
const state = useAgentConfig(ref('agent-1'), api);
const loading = state.load();
await Promise.resolve();
// Optimistic can() would allow it; the composable waits instead.
expect(api.getConfig).not.toHaveBeenCalled();
hydrated.resolve();
await loading;
expect(api.getConfig).not.toHaveBeenCalled();
expect(state.status.value).toBe('error');
});

it('publishes an instance detail together with its applied overlay; stale selections are dropped', async () => {
store.permissions = { agent: ['read'] };
store.loaded = true;
const slowB = deferred<AgentInstanceDetail>();
const api = makeApi({
getInstance: vi
.fn()
.mockImplementation(async (_a: string, id: string) => {
if (id === instanceIds.b) return slowB.promise;
return detailFor(
instancesMixed.items.find((i) => i.instanceId === id)!,
{},
);
}),
});
const state = useAgentConfig(ref('agent-1'), api);
await state.load();
expect(state.selectedInstance.value?.instanceId).toBe(instanceIds.a);

const toB = state.selectInstance(instanceIds.b);
expect(state.selectedInstanceCurrent.value).toBe(false);
const toF = state.selectInstance(instanceIds.f);
await toF;
expect(state.selectedInstance.value?.instanceId).toBe(instanceIds.f);
// ip-f runs r6: its overlay comes with it.
expect(state.appliedOverlay.value).toEqual(configRev6.overlay);
slowB.resolve(detailFor(instancesMixed.items[1], {}));
await toB;
// The late ip-b response must not replace the newer selection.
expect(state.selectedInstance.value?.instanceId).toBe(instanceIds.f);
expect(state.selectedInstanceCurrent.value).toBe(true);
});

it('flags the provenance fallback when the applied revision cannot be loaded', async () => {
store.permissions = { agent: ['read'] };
store.loaded = true;
const api = makeApi({
getRevision: vi.fn().mockRejectedValue(new Error('gone')),
});
const state = useAgentConfig(ref('agent-1'), api);
await state.load();
await state.selectInstance(instanceIds.f);
expect(state.appliedOverlayFallback.value).toBe(true);
expect(state.appliedOverlay.value).toEqual(configRev7.overlay);
});

it('reports an older API as unsupported even when listInstances 404s first', async () => {
store.permissions = { agent: ['read'] };
store.loaded = true;
// An older API has neither route: both 404 with no {errors:{body}}.
const notFound = () => {
const err = new AxiosError('Not Found', 'ERR_BAD_REQUEST');
err.response = {
status: 404,
data: { message: 'Not Found' },
statusText: '',
headers: {},
config: { headers: new AxiosHeaders() },
};
return err;
};
const cfg = deferred<never>();
const get = vi.fn((url: string) =>
url.endsWith('/instances') ? Promise.reject(notFound()) : cfg.promise,
);
const api = createHttpAgentConfigApi({ get } as never);
const state = useAgentConfig(ref('agent-1'), api);
await state.load();
expect(get).toHaveBeenCalledTimes(2);
expect(state.status.value).toBe('unsupported');
cfg.reject(notFound());
});

/** `n` copies of ip-a served 25 per page, as the paginated API does. */
function pagedApi(n: number) {
const rows = Array.from({ length: n }, (_, i) => ({
...instancesMixed.items[0],
instanceId: `i${i + 1}`,
}));
return makeApi({
listInstances: vi.fn(
async (_a: string, q: { page?: number; limit?: number } = {}) => {
const page = q.page ?? 1;
const limit = q.limit ?? 25;
return {
items: rows.slice((page - 1) * limit, page * limit),
meta: {
desiredRevision: 7,
counts: { ...instancesMixed.meta.counts, total: n, inSync: n },
page,
limit,
total: n,
totalPages: Math.ceil(n / limit),
},
};
},
),
});
}

it('loads every page of instances; fleet numbers come from meta.counts', async () => {
store.permissions = { agent: ['read'] };
store.loaded = true;
const api = pagedApi(60);
const state = useAgentConfig(ref('agent-1'), api);
await state.load();
expect(api.listInstances).toHaveBeenCalledTimes(3);
expect(state.instances.value).toHaveLength(60);
expect(state.instancesPartial.value).toBe(false);
expect(state.instanceTotal.value).toBe(60);
expect(state.syncSummary.value.inSync).toBe(60);
});

it('past the page cap the list is partial; totals still cover the fleet', async () => {
store.permissions = { agent: ['read'] };
store.loaded = true;
const api = pagedApi(130);
const state = useAgentConfig(ref('agent-1'), api);
await state.load();
expect(api.listInstances).toHaveBeenCalledTimes(MAX_INSTANCE_PAGES);
expect(state.instances.value).toHaveLength(100);
expect(state.instancesPartial.value).toBe(true);
expect(state.instanceTotal.value).toBe(130);
expect(state.syncSummary.value.total).toBe(130);
expect(state.syncSummary.value.partial).toBe(true);
});

it('ignores the failure of a superseded load', async () => {
store.permissions = { agent: ['read'] };
store.loaded = true;
const first = deferred<typeof configRev7>();
const getConfig = vi
.fn()
.mockReturnValueOnce(first.promise)
.mockResolvedValue(configRev7);
const state = useAgentConfig(ref('agent-1'), makeApi({ getConfig }));
const a = state.load();
await Promise.resolve();
await state.refresh();
expect(state.status.value).toBe('ready');
first.reject(new Error('late failure'));
await a;
expect(state.status.value).toBe('ready');
});
});
Loading
Loading