From b8d70fa50648e94182bb0c5751b85910986cf62f Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 5 Oct 2026 07:40:54 -0300 Subject: [PATCH 1/4] test(agents): integration specs for the Configuration tab Layer 21 of 21 in the stacked split of compliance-framework/ui#318. Co-Authored-By: Claude Opus 5.5 --- .../config/__tests__/AddPluginGating.spec.ts | 251 +++++++++++ .../config/__tests__/InlineEditing.spec.ts | 338 +++++++++++++++ .../config/__tests__/PendingChanges.spec.ts | 408 ++++++++++++++++++ .../config/__tests__/PluginTabs.spec.ts | 270 ++++++++++++ 4 files changed, 1267 insertions(+) create mode 100644 src/components/agents/config/__tests__/AddPluginGating.spec.ts create mode 100644 src/components/agents/config/__tests__/InlineEditing.spec.ts create mode 100644 src/components/agents/config/__tests__/PendingChanges.spec.ts create mode 100644 src/components/agents/config/__tests__/PluginTabs.spec.ts diff --git a/src/components/agents/config/__tests__/AddPluginGating.spec.ts b/src/components/agents/config/__tests__/AddPluginGating.spec.ts new file mode 100644 index 00000000..bba2d2cb --- /dev/null +++ b/src/components/agents/config/__tests__/AddPluginGating.spec.ts @@ -0,0 +1,251 @@ +// R71 for adding a plugin: the add action is disabled when no reporting instance could install +// a new plugin, shielded when only some could, plain when all could; the dialog re-evaluates +// with the concrete source, and the new plugin's tab/fields keep the shield. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + flushPromises, + mount, + type VueWrapper, + enableAutoUnmount, +} from '@vue/test-utils'; +import type { AgentConfigApi } from '@/composables/agent-config/useAgentConfigApi'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; +import type { AgentInstanceSummary } from '@/types/agent-config'; +import type { Agent } from '@/types/agents'; +import { + instancesMixed, + remoteConfigSafe, +} from '@/composables/agent-config/__tests__/fixtures'; +import { ADMIN, fakeApi, globalWith, piniaWith } from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); +const api = vi.hoisted(() => ({ current: null as unknown as AgentConfigApi })); +vi.mock('@/composables/agent-config/useAgentConfigApi', async () => { + const actual = await vi.importActual< + typeof import('@/composables/agent-config/useAgentConfigApi') + >('@/composables/agent-config/useAgentConfigApi'); + return { ...actual, useAgentConfigApi: () => api.current }; +}); + +import AgentConfigTab from '../AgentConfigTab.vue'; + +// PrimeVue's TabList schedules a 150 ms ink-bar update on mount and never clears it; a wrapper +// left mounted lets it fire after this file's jsdom environment is torn down +// ("HTMLElement is not defined"). Unmounting nulls its refs, so the timer becomes a no-op. +enableAutoUnmount(afterEach); + +const agent: Agent = { + id: 'agent-1', + name: 'ssh agent', + isActive: true, + serviceAccountKeyCount: 1, + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', +}; + +// Fixture fleet (fresh, reported): ip-a, ip-b, ip-f, ip-g apply_safe trusting +// ghcr.io/compliance-framework/*, ip-c report-only. +const fresh = instancesMixed.items.filter( + (i) => !i.stale && i.reportedAt != null, +); + +function withInstances(items: AgentInstanceSummary[]) { + api.current = fakeApi({ + listInstances: vi + .fn() + .mockResolvedValue({ items, meta: instancesMixed.meta }), + }); +} + +async function mountTab() { + const wrapper = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(ADMIN), { teleport: true }), + attachTo: document.body, + }); + await flushPromises(); + const ws = (wrapper.vm as unknown as { ws: ConfigWorkspace }).ws; + return { wrapper, ws }; +} + +const addButton = (w: VueWrapper) => w.find('[data-test="add-plugin"]'); + +async function openDialog(w: VueWrapper, source: string, name = 'extra') { + await addButton(w).trigger('click'); + await w.find('[data-test="add-plugin-name"]').setValue(name); + await w.find('[data-test="add-plugin-source"]').setValue(source); +} + +describe('add-plugin gating (R71)', () => { + beforeEach(() => { + resetAgentDrafts(); + api.current = fakeApi(); + }); + + it('none could install: disabled, with the reasons', async () => { + withInstances( + fresh.map((i) => ({ + ...i, + mode: 'report' as const, + remoteConfig: { ...remoteConfigSafe, mode: 'report' as const }, + })), + ); + const { wrapper } = await mountTab(); + expect(addButton(wrapper).attributes('disabled')).toBeDefined(); + const why = wrapper + .find('[data-test="add-plugin-wrapper"]') + .attributes('aria-label'); + expect(why).toContain('No reporting instance would install a new plugin'); + expect(why).toContain('report-only mode'); + expect( + wrapper.find('[data-test="add-plugin-wrapper"]').attributes('tabindex'), + ).toBe('0'); + }); + + it('some could: enabled with a shield', async () => { + const { wrapper } = await mountTab(); + expect(addButton(wrapper).attributes('disabled')).toBeUndefined(); + expect( + wrapper + .find('[data-test="add-plugin-restricted"]') + .attributes('aria-label'), + ).toContain('ip-c'); + }); + + it('all could: enabled without a shield', async () => { + withInstances(fresh.filter((i) => i.mode !== 'report')); + const { wrapper } = await mountTab(); + expect(addButton(wrapper).attributes('disabled')).toBeUndefined(); + expect(wrapper.find('[data-test="add-plugin-restricted"]').exists()).toBe( + false, + ); + }); + + it('apply_safe without trusted_sources could only reuse a source its file has', async () => { + withInstances( + fresh + .filter((i) => i.mode !== 'report') + .map((i) => ({ + ...i, + remoteConfig: { ...remoteConfigSafe, trusted_sources: [] }, + })), + ); + const { wrapper } = await mountTab(); + // Their files use sources (fixture base), so adding stays possible but shielded. + expect(addButton(wrapper).attributes('disabled')).toBeUndefined(); + expect( + wrapper + .find('[data-test="add-plugin-restricted"]') + .attributes('aria-label'), + ).toContain('only a source this host already uses'); + await openDialog( + wrapper, + 'ghcr.io/compliance-framework/plugin-local-ssh:v1.2.0', + ); + expect( + wrapper.find('[data-test="add-plugin-access"]').attributes('data-state'), + ).toBe('editable'); + await wrapper + .find('[data-test="add-plugin-source"]') + .setValue('ghcr.io/compliance-framework/plugin-new:v1'); + expect( + wrapper.find('[data-test="add-plugin-access"]').attributes('data-state'), + ).toBe('readonly'); + expect( + wrapper.find('[data-test="add-plugin-submit"]').attributes('disabled'), + ).toBeDefined(); + }); + + it('no reporting instance (all stale): enabled without a shield, and the dialog says why', async () => { + // The Effective view needs a reported instance; with only stale reports none is counted. + withInstances(fresh.map((i) => ({ ...i, stale: true }))); + const { wrapper } = await mountTab(); + expect(addButton(wrapper).attributes('disabled')).toBeUndefined(); + expect(wrapper.find('[data-test="add-plugin-restricted"]').exists()).toBe( + false, + ); + await openDialog(wrapper, 'docker.io/acme/p:v1'); + const access = wrapper.find('[data-test="add-plugin-access"]'); + expect(access.attributes('data-state')).toBe('editable'); + expect(access.text()).toContain('No instance has a fresh report'); + }); + + it('screen-reader descriptions stay inside a positioned box (no second page scrollbar)', async () => { + // A position:absolute sr-only element without a positioned ancestor is placed against the + // document and, deep inside the scrolling
, stretches the page itself. + const { wrapper } = await mountTab(); + const srOnly = wrapper.findAll('.sr-only'); + expect(srOnly.length).toBeGreaterThan(0); + for (const el of srOnly) { + expect(el.element.parentElement!.closest('.relative')).not.toBeNull(); + } + expect(wrapper.find('[data-test="plugin-tabs"]').classes()).toContain( + 'relative', + ); + }); + + it('the dialog re-evaluates with the concrete source', async () => { + const { wrapper } = await mountTab(); + await openDialog(wrapper, 'ghcr.io/compliance-framework/plugin-extra:v1'); + let access = wrapper.find('[data-test="add-plugin-access"]'); + expect(access.attributes('data-state')).toBe('restricted'); + expect(access.text()).toContain('May not be installed on 1 of 5'); + expect(access.text()).toContain('ip-c'); + + // Untrusted OCI source: no apply_safe instance installs it, ip-c never does. + await wrapper + .find('[data-test="add-plugin-source"]') + .setValue('docker.io/acme/extra:v1'); + access = wrapper.find('[data-test="add-plugin-access"]'); + expect(access.attributes('data-state')).toBe('readonly'); + expect(access.text()).toContain('matches no trusted_sources entry'); + expect( + wrapper.find('[data-test="add-plugin-submit"]').attributes('disabled'), + ).toBeDefined(); + + // A local path is forbidden without apply_all + allow_local_sources. + await wrapper.find('[data-test="add-plugin-source"]').setValue('/opt/p'); + expect(wrapper.find('[data-test="add-plugin-access"]').text()).toContain( + 'allow_local_sources', + ); + }); + + it("keeps the shield on the new plugin's tab, card and fields", async () => { + withInstances([ + ...fresh.filter((i) => i.mode === 'apply_safe'), + { + ...fresh[0], + instanceId: '0f5e2c1a-0000-4000-8000-0000000000aa', + hostname: 'ip-all', + mode: 'apply_all', + remoteConfig: { ...remoteConfigSafe, mode: 'apply_all' }, + }, + ]); + const { wrapper, ws } = await mountTab(); + await openDialog(wrapper, 'docker.io/acme/extra:v1'); + expect( + wrapper.find('[data-test="add-plugin-access"]').attributes('data-state'), + ).toBe('restricted'); + await wrapper.find('[data-test="add-plugin-form"]').trigger('submit'); + await flushPromises(); + expect(ws.draft.overlay.value.plugins?.extra?.source).toBe( + 'docker.io/acme/extra:v1', + ); + const tab = wrapper.find('[data-test="plugin-tab-extra"]'); + expect(tab.find('[data-test="plugin-tab-restricted"]').exists()).toBe(true); + const desc = document.getElementById(tab.attributes('aria-describedby')!); + expect(desc?.textContent).toContain('May not be installed on 4 of 5'); + const panel = wrapper.find('[data-test="plugin-panel-extra"]'); + expect( + panel + .find('[data-test="plugin-install-access"]') + .attributes('data-state'), + ).toBe('restricted'); + expect( + panel + .find('[data-test="field-/plugins/extra/schedule"]') + .attributes('data-state'), + ).toBe('restricted'); + }); +}); diff --git a/src/components/agents/config/__tests__/InlineEditing.spec.ts b/src/components/agents/config/__tests__/InlineEditing.spec.ts new file mode 100644 index 00000000..2310f815 --- /dev/null +++ b/src/components/agents/config/__tests__/InlineEditing.spec.ts @@ -0,0 +1,338 @@ +// R69 / R71: inline pencils on the Effective view write to the shared pending-changes draft; +// fields show editable / restricted / forbidden states. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + flushPromises, + mount, + type VueWrapper, + enableAutoUnmount, +} from '@vue/test-utils'; +import type { AgentConfigApi } from '@/composables/agent-config/useAgentConfigApi'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; +import type { Agent } from '@/types/agents'; +import { instancesMixed } from '@/composables/agent-config/__tests__/fixtures'; +import { ADMIN, READER, fakeApi, globalWith, piniaWith } from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); +// Confirmations accept immediately. +vi.mock('primevue/useconfirm', () => ({ + useConfirm: () => ({ + require: (o: { accept?: () => void }) => o.accept?.(), + }), +})); +const api = vi.hoisted(() => ({ current: null as unknown as AgentConfigApi })); +vi.mock('@/composables/agent-config/useAgentConfigApi', async () => { + const actual = await vi.importActual< + typeof import('@/composables/agent-config/useAgentConfigApi') + >('@/composables/agent-config/useAgentConfigApi'); + return { ...actual, useAgentConfigApi: () => api.current }; +}); + +import AgentConfigTab from '../AgentConfigTab.vue'; +import Select from '@/volt/Select.vue'; + +// PrimeVue's TabList schedules a 150 ms ink-bar update on mount and never clears it; a wrapper +// left mounted lets it fire after this file's jsdom environment is torn down +// ("HTMLElement is not defined"). Unmounting nulls its refs, so the timer becomes a no-op. +enableAutoUnmount(afterEach); + +const agent: Agent = { + id: 'agent-1', + name: 'ssh agent', + isActive: true, + serviceAccountKeyCount: 1, + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', +}; + +async function mountTab(perms: Record = ADMIN) { + const wrapper = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(perms)), + attachTo: document.body, + }); + await flushPromises(); + const ws = (wrapper.vm as unknown as { ws: ConfigWorkspace }).ws; + return { wrapper, ws }; +} + +const sel = (ptr: string) => `[data-test="field-${ptr}"]`; + +async function openEditor(wrapper: VueWrapper, ptr: string) { + await wrapper.find(`[data-test="edit-${ptr}"]`).trigger('click'); + return wrapper.find(`[data-test="editor-${ptr}"]`); +} + +describe('inline editing on the Effective view (R69)', () => { + beforeEach(() => { + resetAgentDrafts(); + api.current = fakeApi(); + }); + + it('schedule: validates, applies to the draft, and offers file value / agent default', async () => { + const { wrapper, ws } = await mountTab(); + const ptr = '/plugins/local-ssh/schedule'; + let editor = await openEditor(wrapper, ptr); + await editor.find('[data-test="scalar-input"]').setValue('not a cron'); + expect(editor.find('[data-test="scalar-error"]').text()).toContain( + 'Invalid schedule', + ); + expect( + editor.find('[data-test="scalar-apply"]').attributes('disabled'), + ).toBeDefined(); + await editor.find('[data-test="scalar-input"]').setValue('0 * * * *'); + await editor.find('form').trigger('submit'); + expect(ws.draft.overlay.value.plugins?.['local-ssh']?.schedule).toBe( + '0 * * * *', + ); + expect(wrapper.find(`[data-test="pending-${ptr}"]`).text()).toContain( + '0 * * * *', + ); + editor = await openEditor(wrapper, ptr); + await editor.find('[data-test="scalar-agent-default"]').trigger('click'); + expect(ws.draft.overlay.value.plugins?.['local-ssh']?.schedule).toBeNull(); + editor = await openEditor(wrapper, ptr); + await editor.find('[data-test="scalar-file-value"]').trigger('click'); + expect( + ws.draft.overlay.value.plugins?.['local-ssh'] && + 'schedule' in ws.draft.overlay.value.plugins['local-ssh']!, + ).toBe(false); + }); + + it('booleans, verbosity and duration use small editors (R56: file value omits)', async () => { + const { wrapper, ws } = await mountTab(); + let editor = await openEditor(wrapper, '/plugins/local-ssh/enabled'); + await editor.findComponent(Select).vm.$emit('update:modelValue', false); + await editor.find('form').trigger('submit'); + expect(ws.draft.overlay.value.plugins?.['local-ssh']?.enabled).toBe(false); + + editor = await openEditor(wrapper, '/verbosity'); + await editor.findComponent(Select).vm.$emit('update:modelValue', 2); + await editor.find('form').trigger('submit'); + expect(ws.draft.overlay.value.verbosity).toBe(2); + editor = await openEditor(wrapper, '/verbosity'); + await editor + .findComponent(Select) + .vm.$emit('update:modelValue', '__file__'); + await editor.find('form').trigger('submit'); + expect('verbosity' in ws.draft.overlay.value).toBe(false); + + editor = await openEditor(wrapper, '/agent_evidence/interval'); + await editor.find('[data-test="scalar-input"]').setValue('-5m'); + expect(editor.find('[data-test="scalar-error"]').exists()).toBe(true); + await editor.find('[data-test="scalar-input"]').setValue('15m'); + await editor.find('form').trigger('submit'); + expect(ws.draft.overlay.value.agent_evidence?.interval).toBe('15m'); + }); + + it('Apply on an untouched field does not pin the file value', async () => { + const { wrapper, ws } = await mountTab(); + let editor = await openEditor(wrapper, '/plugins/local-ssh/enabled'); + await editor.find('form').trigger('submit'); + editor = await openEditor(wrapper, '/agent_evidence/interval'); + await editor.find('form').trigger('submit'); + expect(ws.draft.isDirty.value).toBe(false); + }); + + it('config keys: per-key editor with Remove, and never pre-fills a masked value (R25)', async () => { + const { wrapper, ws } = await mountTab(); + const editor = await openEditor(wrapper, '/plugins/local-ssh/config/port'); + await editor.find('[data-test="scalar-input"]').setValue('2200'); + await editor.find('form').trigger('submit'); + expect(ws.draft.overlay.value.plugins?.['local-ssh']?.config?.port).toBe( + '2200', + ); + const again = await openEditor(wrapper, '/plugins/local-ssh/config/port'); + await again.find('[data-test="scalar-remove"]').trigger('click'); + expect(ws.draft.overlay.value.plugins?.['local-ssh']?.config?.port).toBe( + null, + ); + }); + + it('emptying a config key a file defines brings the file value back (as the map editor does)', async () => { + const { wrapper, ws } = await mountTab(); + const ptr = '/plugins/local-ssh/config/port'; + let editor = await openEditor(wrapper, ptr); + await editor.find('[data-test="scalar-input"]').setValue('2200'); + await editor.find('form').trigger('submit'); + expect(ws.draft.overlay.value.plugins?.['local-ssh']?.config?.port).toBe( + '2200', + ); + editor = await openEditor(wrapper, ptr); + await editor.find('[data-test="scalar-input"]').setValue(''); + expect(editor.find('[data-test="scalar-hint"]').text()).toBe( + 'Empty: the file value applies.', + ); + await editor.find('form').trigger('submit'); + // Unset (not pinned to ""): the overlay no longer has the key. + const config = ws.draft.overlay.value.plugins?.['local-ssh']?.config ?? {}; + expect('port' in config).toBe(false); + }); + + it('labels, policy data and policy assignment use the map / per-value / list editors', async () => { + const { wrapper, ws } = await mountTab(); + let editor = await openEditor(wrapper, '/plugins/local-ssh/labels'); + await editor.find('[data-test="kv-new-key"]').setValue('team'); + await editor.find('[data-test="kv-new-value"]').setValue('sec'); + await editor.find('form').trigger('submit'); + expect(ws.draft.overlay.value.plugins?.['local-ssh']?.labels).toEqual({ + team: 'sec', + }); + await editor + .find('[data-test="done-/plugins/local-ssh/labels"]') + .trigger('click'); + + // policy_data: each value has its own pencil (no whole-object editor). + const pdPtr = '/plugins/local-ssh/policy_data/max_auth_tries'; + await wrapper.find(`[data-test="pd-edit-${pdPtr}"]`).trigger('click'); + const pdForm = wrapper.find(`[data-test="pd-edit-form-${pdPtr}"]`); + await pdForm.find('[data-test="pd-edit-value"]').setValue('5'); + await pdForm.trigger('submit'); + expect(ws.draft.overlay.value.plugins?.['local-ssh']?.policy_data).toEqual({ + max_auth_tries: 5, + }); + + editor = await openEditor(wrapper, '/plugins/ubuntu-packages/policies'); + await editor + .find('[data-test="new-policy"]') + .setValue('ghcr.io/compliance-framework/plugin-extra-policies:v1'); + await editor.find('form').trigger('submit'); + expect( + ws.draft.overlay.value.plugins?.['ubuntu-packages']?.policies, + ).toContain('ghcr.io/compliance-framework/plugin-extra-policies:v1'); + }); + + it('removes and restores a plugin as a pending change', async () => { + const { wrapper, ws } = await mountTab(); + await wrapper + .find( + '[data-test="plugin-card-ubuntu-packages"] [data-test="remove-plugin"]', + ) + .trigger('click'); + expect(ws.draft.overlay.value.plugins?.['ubuntu-packages']).toBeNull(); + const card = wrapper.find('[data-test="plugin-card-ubuntu-packages"]'); + expect(card.find('[data-test="pending-removal"]').exists()).toBe(true); + await card.find('[data-test="plugin-undo-removal"]').trigger('click'); + expect(ws.draft.isDirty.value).toBe(false); + }); +}); + +describe('field states (R71)', () => { + beforeEach(() => { + resetAgentDrafts(); + api.current = fakeApi(); + }); + + it('forbidden fields are locked without a pencil', async () => { + const { wrapper } = await mountTab(); + const locked = wrapper.find('[data-test="locked-keys"]'); + expect(locked.findAll('[data-state="forbidden"]').length).toBe(8); + expect(locked.find('[data-test^="edit-"]').exists()).toBe(false); + }); + + it('none apply: read-only with the reasons; some apply: pencil + shield', async () => { + // Fixture fleet (fresh, reported): ip-a, ip-b, ip-f, ip-g in apply_safe with + // `local-ssh:port` / `timeout` flags, ip-c in report mode. ip-d (stale) and ip-e (never + // reported) are not counted. + const { wrapper } = await mountTab(); + const password = wrapper.find(sel('/plugins/local-ssh/config/password')); + expect(password.attributes('data-state')).toBe('readonly'); + expect( + password + .find('[data-test="edit-/plugins/local-ssh/config/password"]') + .exists(), + ).toBe(false); + expect(password.find('[data-test="field-restricted"]').exists()).toBe( + false, + ); + const why = password + .find('[data-test="field-readonly"]') + .attributes('aria-label'); + expect(why).toContain('no reporting instance would apply'); + expect(why).toContain('overridable_config_flags'); + expect(why).toContain('ip-a, ip-b, ip-f, ip-g'); + expect(why).toContain('report-only mode'); + expect(why).not.toContain('ip-d'); + + // `port` is overridable on every apply_safe instance; only ip-c (report) won't apply it. + const port = wrapper.find(sel('/plugins/local-ssh/config/port')); + expect(port.attributes('data-state')).toBe('restricted'); + expect( + port.find('[data-test="edit-/plugins/local-ssh/config/port"]').exists(), + ).toBe(true); + const shield = port + .find('[data-test="field-restricted"]') + .attributes('aria-label'); + expect(shield).toContain('1 of 5'); + expect(shield).toContain('ip-c'); + + // The config map editor shows the read-only key locked and disabled. + const editor = await openEditor(wrapper, '/plugins/local-ssh/config'); + const row = editor.find('[data-row="password"]'); + // Row actions sit next to the key, before the value input. + const order = Array.from(row.element.children).map( + (c) => c.getAttribute('data-test') ?? c.tagName, + ); + expect(order.indexOf('kv-actions')).toBeLessThan(order.indexOf('INPUT')); + expect(row.find('[data-test="kv-lock"]').exists()).toBe(true); + expect(row.find('input').attributes('disabled')).toBeDefined(); + expect( + editor + .find('[data-row="port"] [data-test="kv-restricted"]') + .attributes('aria-label'), + ).toContain('ip-c'); + }); + + it('all apply: pencil only, no shield', async () => { + api.current = fakeApi({ + listInstances: vi.fn().mockResolvedValue({ + ...instancesMixed, + items: instancesMixed.items.filter((i) => i.mode !== 'report'), + }), + }); + const { wrapper } = await mountTab(); + const port = wrapper.find(sel('/plugins/local-ssh/config/port')); + expect(port.attributes('data-state')).toBe('editable'); + expect(port.find('[data-test="field-restricted"]').exists()).toBe(false); + expect( + port.find('[data-test="edit-/plugins/local-ssh/config/port"]').exists(), + ).toBe(true); + }); + + it('no reporting instance yet: editable without shields', async () => { + api.current = fakeApi({ + listInstances: vi.fn().mockResolvedValue({ + items: [], + meta: instancesMixed.meta, + }), + }); + const tab = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(ADMIN)), + }); + await flushPromises(); + const ws = (tab.vm as unknown as { ws: ConfigWorkspace }).ws; + expect(ws.accessAt('/plugins/local-ssh/config/password').state).toBe( + 'editable', + ); + expect(ws.canEditPointer('/plugins/local-ssh/config/password')).toBe(true); + expect(ws.canEditPointer('/api/url')).toBe(false); + }); + + it('a reader gets no pencils and no plugin actions', async () => { + const { wrapper } = await mountTab(READER); + expect( + wrapper.find('[data-test="edit-/plugins/local-ssh/policies"]').exists(), + ).toBe(false); + expect(wrapper.find('[data-test="edit-/verbosity"]').exists()).toBe(false); + expect( + wrapper.find('[data-test="edit-/plugins/local-ssh/schedule"]').exists(), + ).toBe(false); + expect(wrapper.find('[data-test="remove-plugin"]').exists()).toBe(false); + expect(wrapper.find('[data-test="add-plugin"]').exists()).toBe(false); + // Access hints describe what an edit would do: hidden from readers. + expect(wrapper.find('[data-test="field-restricted"]').exists()).toBe(false); + expect(wrapper.find('[data-test="field-readonly"]').exists()).toBe(false); + }); +}); diff --git a/src/components/agents/config/__tests__/PendingChanges.spec.ts b/src/components/agents/config/__tests__/PendingChanges.spec.ts new file mode 100644 index 00000000..7ba987c7 --- /dev/null +++ b/src/components/agents/config/__tests__/PendingChanges.spec.ts @@ -0,0 +1,408 @@ +// R69: the sticky pending-changes bar, Review & save as ONE revision (If-Match, 409 flow), +// gated on agent:configure. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + flushPromises, + mount, + enableAutoUnmount, + type VueWrapper, +} from '@vue/test-utils'; +import PrimeDialog from 'primevue/dialog'; +import type { AgentConfigApi } from '@/composables/agent-config/useAgentConfigApi'; +import { AgentConfigApiError } from '@/composables/agent-config/api-types'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; +import { + configRev7, + instanceIds, + overlayRev7, + previewMixed, +} from '@/composables/agent-config/__tests__/fixtures'; +import type { ConfigPreview, SaveResult } from '@/types/agent-config'; +import type { Agent } from '@/types/agents'; +import FieldHints from '../editor/FieldHints.vue'; +import SavePreviewPanel from '../editor/SavePreviewPanel.vue'; +import { + ADMIN, + READER, + fakeApi, + globalWith, + piniaWith, + workspaceHost, +} from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); +vi.mock('primevue/useconfirm', () => ({ + useConfirm: () => ({ + require: (o: { accept?: () => void }) => o.accept?.(), + }), +})); +const api = vi.hoisted(() => ({ current: null as unknown as AgentConfigApi })); +vi.mock('@/composables/agent-config/useAgentConfigApi', async () => { + const actual = await vi.importActual< + typeof import('@/composables/agent-config/useAgentConfigApi') + >('@/composables/agent-config/useAgentConfigApi'); + return { ...actual, useAgentConfigApi: () => api.current }; +}); + +import AgentConfigTab from '../AgentConfigTab.vue'; + +// PrimeVue's TabList schedules a 150 ms ink-bar update on mount and never clears it; a wrapper +// left mounted lets it fire after this file's jsdom environment is torn down +// ("HTMLElement is not defined"). Unmounting nulls its refs, so the timer becomes a no-op. +enableAutoUnmount(afterEach); + +const agent: Agent = { + id: 'agent-1', + name: 'ssh agent', + isActive: true, + serviceAccountKeyCount: 1, + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', +}; + +const cleanPreview: ConfigPreview = { + ...previewMixed, + overlayErrors: [], + instances: previewMixed.instances.map((i) => ({ ...i, errors: [] })), +}; + +/** Lets the async review dialog chunk load and its preview resolve. */ +async function settle() { + await vi.dynamicImportSettled(); + await flushPromises(); +} + +/** Runs the live preview now instead of waiting for the debounce (R89 gate). */ +async function checked(ws: ConfigWorkspace) { + await flushPromises(); + await ws.preview.run().catch(() => undefined); + await flushPromises(); +} + +/** The PrimeVue Dialog of the review (its header starts with "Review"). */ +function reviewDialog(wrapper: VueWrapper) { + return wrapper + .findAllComponents(PrimeDialog) + .find((d) => String(d.props('header')).startsWith('Review'))!; +} + +async function mountTab(perms: Record = ADMIN) { + const wrapper = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(perms), { teleport: true }), + }); + await flushPromises(); + const ws = (wrapper.vm as unknown as { ws: ConfigWorkspace }).ws; + return { wrapper, ws }; +} + +describe('pending-changes bar (R69)', () => { + beforeEach(() => { + resetAgentDrafts(); + api.current = fakeApi({ preview: vi.fn().mockResolvedValue(cleanPreview) }); + }); + + it('appears with the count, lists and undoes changes, and discards', async () => { + const { wrapper, ws } = await mountTab(); + expect(wrapper.find('[data-test="pending-bar"]').exists()).toBe(false); + ws.draft.set('/verbosity', 2); + ws.draft.set('/plugins/local-ssh/enabled', false); + await flushPromises(); + expect(wrapper.find('[data-test="pending-count"]').text()).toBe( + '2 pending changes', + ); + await wrapper.find('[data-test="pending-toggle"]').trigger('click'); + await wrapper + .find('[data-test="pending-undo-/verbosity"]') + .trigger('click'); + expect(wrapper.find('[data-test="pending-count"]').text()).toBe( + '1 pending change', + ); + await wrapper.find('[data-test="pending-discard"]').trigger('click'); + expect(ws.draft.isDirty.value).toBe(false); + expect(wrapper.find('[data-test="pending-bar"]').exists()).toBe(false); + }); + + it('Review & save previews, then saves ONE revision with If-Match and the comment', async () => { + const saved = { ...configRev7, revision: 8, overlay: { verbosity: 2 } }; + api.current.putConfig = vi + .fn() + .mockResolvedValue({ revision: saved, created: true }); + const { wrapper, ws } = await mountTab(); + ws.draft.set('/verbosity', 2); + ws.draft.set('/plugins/local-ssh/schedule', '0 * * * *'); + await checked(ws); + await wrapper.find('[data-test="pending-review"]').trigger('click'); + await settle(); + expect(api.current.preview).toHaveBeenCalled(); + expect(wrapper.find('[data-test="save-preview"]').exists()).toBe(true); + await wrapper.find('[data-test="save-comment"]').setValue('tune'); + (api.current.getConfig as ReturnType).mockResolvedValue( + saved, + ); + await wrapper.find('[data-test="save-config"]').trigger('click'); + await flushPromises(); + expect(api.current.putConfig).toHaveBeenCalledTimes(1); + const [agentId, body, ifMatch] = ( + api.current.putConfig as ReturnType + ).mock.calls[0]; + expect(agentId).toBe('agent-1'); + expect(ifMatch).toBe(7); + expect(body.comment).toBe('tune'); + expect(body.overlay.verbosity).toBe(2); + expect(body.overlay.plugins['local-ssh'].schedule).toBe('0 * * * *'); + // The saved revision is the new base; the bar is gone. + expect(ws.draft.baseRevision.value).toBe(8); + expect(ws.draft.isDirty.value).toBe(false); + expect(wrapper.find('[data-test="pending-bar"]').exists()).toBe(false); + }); + + it('409: shows the conflict and keeps the changes on "Keep my changes"', async () => { + const latest = { ...configRev7, revision: 8, createdBy: 'bob' }; + api.current.putConfig = vi.fn().mockRejectedValue( + new AgentConfigApiError({ + kind: 'conflict', + status: 409, + message: 'conflict', + currentRevision: 8, + }), + ); + const { wrapper, ws } = await mountTab(); + ws.draft.set('/verbosity', 2); + await checked(ws); + await wrapper.find('[data-test="pending-review"]').trigger('click'); + await settle(); + (api.current.getConfig as ReturnType).mockResolvedValue( + latest, + ); + await wrapper.find('[data-test="save-config"]').trigger('click'); + await flushPromises(); + expect(wrapper.find('[data-test="conflict-banner"]').text()).toContain( + 'r8 was saved by bob', + ); + await wrapper.find('[data-test="conflict-keep"]').trigger('click'); + await flushPromises(); + expect(ws.draft.baseRevision.value).toBe(8); + expect(ws.draft.overlay.value.verbosity).toBe(2); + expect(wrapper.find('[data-test="conflict-banner"]').exists()).toBe(false); + }); + + it('a reader can neither preview nor review a draft', async () => { + const { wrapper, ws } = await mountTab(READER); + ws.draft.set('/verbosity', 2); + await flushPromises(); + expect(ws.preview.pending.value).toBe(false); + expect(api.current.preview).not.toHaveBeenCalled(); + expect(wrapper.find('[data-test="pending-review"]').exists()).toBe(false); + expect(ws.saveDisabledReason.value).toBe( + "You don't have permission to change this configuration", + ); + }); + + it('client-only problems disable Review and the live preview', async () => { + const { wrapper, ws } = await mountTab(); + ws.draft.set('/plugins/local-ssh/config/password', '••••'); + await flushPromises(); + expect(wrapper.find('[data-test="pending-blocking"]').text()).toContain( + '1 problem', + ); + expect(ws.preview.pending.value).toBe(false); + expect( + wrapper.find('[data-test="pending-review"]').attributes('disabled'), + ).toBeDefined(); + }); + + it('R89: Review waits for the preview and is disabled by its errors', async () => { + api.current.preview = vi.fn().mockResolvedValue({ + ...cleanPreview, + overlayErrors: [ + { path: '/plugins/local-ssh/schedule', message: 'bad cron' }, + ], + } satisfies ConfigPreview); + const { wrapper, ws } = await mountTab(); + ws.draft.set('/plugins/local-ssh/schedule', 'nope'); + await flushPromises(); + // Debouncing: Review cannot race the preview. + expect(ws.preview.pending.value).toBe(true); + expect(ws.reviewDisabledReason.value).toBe('Checking the pending changes…'); + await checked(ws); + expect(ws.preview.pending.value).toBe(false); + expect(wrapper.find('[data-test="pending-blocking"]').text()).toContain( + '1 problem', + ); + await wrapper.find('[data-test="pending-toggle"]').trigger('click'); + const issues = wrapper.find('[data-test="pending-issues"]').text(); + expect(issues).toContain('/plugins/local-ssh/schedule — bad cron'); + expect( + wrapper.find('[data-test="pending-review"]').attributes('disabled'), + ).toBeDefined(); + // The preview's problems describe that draft only. + ws.draft.set('/plugins/local-ssh/schedule', '@hourly'); + await flushPromises(); + expect(ws.blockingCount.value).toBe(0); + }); + it('409: "Keep my changes" keeps the other revision and flags pointers both changed', async () => { + const latest = { + ...configRev7, + revision: 8, + createdBy: 'bob', + overlay: { + ...overlayRev7, + verbosity: 3, + plugins: { + ...overlayRev7.plugins, + c: { source: 'ghcr.io/x/c:v1' }, + }, + }, + }; + api.current.putConfig = vi.fn().mockRejectedValue( + new AgentConfigApiError({ + kind: 'conflict', + status: 409, + message: 'conflict', + currentRevision: 8, + }), + ); + const { wrapper, ws } = await mountTab(); + ws.draft.set('/verbosity', 2); + ws.draft.set('/plugins/local-ssh/enabled', false); + await checked(ws); + await wrapper.find('[data-test="pending-review"]').trigger('click'); + await settle(); + (api.current.getConfig as ReturnType).mockResolvedValue( + latest, + ); + await wrapper.find('[data-test="save-config"]').trigger('click'); + await flushPromises(); + await wrapper.find('[data-test="conflict-keep"]').trigger('click'); + await flushPromises(); + const overlay = ws.draft.overlay.value as { + verbosity: number; + plugins: Record>; + }; + expect(overlay.plugins.c).toEqual({ source: 'ghcr.io/x/c:v1' }); + expect(overlay.plugins['local-ssh'].enabled).toBe(false); + expect(overlay.verbosity).toBe(2); + expect(ws.draft.changedPaths.value).toEqual([ + '/plugins/local-ssh/enabled', + '/verbosity', + ]); + expect(wrapper.find('[data-test="rebase-conflicts"]').text()).toContain( + '/verbosity', + ); + expect(wrapper.find('[data-test="rebase-conflicts"]').text()).not.toContain( + 'enabled', + ); + }); + + it('cannot be closed while saving; edits made meanwhile stay pending', async () => { + let resolvePut!: (r: SaveResult) => void; + api.current.putConfig = vi + .fn() + .mockReturnValue(new Promise((r) => (resolvePut = r))); + const { wrapper, ws } = await mountTab(); + ws.draft.set('/verbosity', 2); + await checked(ws); + await wrapper.find('[data-test="pending-review"]').trigger('click'); + await settle(); + expect(reviewDialog(wrapper).props('closable')).toBe(true); + await wrapper.find('[data-test="save-config"]').trigger('click'); + await flushPromises(); + expect(reviewDialog(wrapper).props('closable')).toBe(false); + expect(reviewDialog(wrapper).props('closeOnEscape')).toBe(false); + expect( + wrapper.find('[data-test="review-back"]').attributes('disabled'), + ).toBeDefined(); + // An edit lands while the PUT is in flight (e.g. from another component). + ws.draft.set('/plugins/local-ssh/enabled', false); + const sent = (api.current.putConfig as ReturnType).mock + .calls[0][1].overlay; + const saved = { ...configRev7, revision: 8, overlay: sent }; + (api.current.getConfig as ReturnType).mockResolvedValue( + saved, + ); + resolvePut({ revision: saved, created: true }); + await flushPromises(); + expect(ws.draft.baseRevision.value).toBe(8); + expect(ws.draft.original.value).toEqual(sent); + expect(ws.draft.changedPaths.value).toEqual(['/plugins/local-ssh/enabled']); + }); + + it("Esc in a nested dialog doesn't close the review", async () => { + const { wrapper, ws } = await mountTab(); + ws.draft.set('/verbosity', 2); + await checked(ws); + await wrapper.find('[data-test="pending-review"]').trigger('click'); + await settle(); + expect(reviewDialog(wrapper).props('closeOnEscape')).toBe(true); + wrapper.findComponent(SavePreviewPanel).vm.$emit('childOpen', true); + await flushPromises(); + expect(reviewDialog(wrapper).props('closeOnEscape')).toBe(false); + wrapper.findComponent(SavePreviewPanel).vm.$emit('childOpen', false); + await flushPromises(); + expect(reviewDialog(wrapper).props('closeOnEscape')).toBe(true); + }); + + it('a failed live check shows the error and retries; Review stays enabled', async () => { + api.current.preview = vi + .fn() + .mockRejectedValueOnce(new Error('upstream 500')) + .mockResolvedValue(cleanPreview); + const { wrapper, ws } = await mountTab(); + ws.draft.set('/verbosity', 2); + await checked(ws); + expect(ws.preview.status.value).toBe('failed'); + const failed = wrapper.find('[data-test="live-check-failed"]'); + expect(failed.text()).toContain('upstream 500'); + expect( + wrapper.find('[data-test="pending-review"]').attributes('disabled'), + ).toBeUndefined(); + await wrapper.find('[data-test="live-check-retry"]').trigger('click'); + await flushPromises(); + expect(api.current.preview).toHaveBeenCalledTimes(2); + expect(wrapper.find('[data-test="live-check-failed"]').exists()).toBe( + false, + ); + expect(wrapper.find('[data-test="live-check"]').text()).toContain( + 'Checked', + ); + }); +}); + +describe('preview hints follow the current draft', () => { + beforeEach(() => resetAgentDrafts()); + + it('discarding the draft clears the shield of the last preview', async () => { + const ptr = '/plugins/local-ssh/source'; + const unsafe: ConfigPreview = { + ...cleanPreview, + instances: [ + { + ...cleanPreview.instances.find( + (i) => i.instanceId === instanceIds.a, + )!, + stale: false, + changes: [ + { path: ptr, safety: 'unsafe', reason: 'untrusted-source' }, + ], + }, + ], + }; + const fake = fakeApi({ preview: vi.fn().mockResolvedValue(unsafe) }); + const out: { ws?: ConfigWorkspace } = {}; + const wrapper = mount( + workspaceHost(fake, FieldHints, () => ({ ptr }), out), + { global: globalWith(piniaWith(ADMIN)) }, + ); + await flushPromises(); + const ws = out.ws!; + ws.draft.set(ptr, 'ghcr.io/evil/ssh:v1'); + await checked(ws); + expect(wrapper.find(`[data-test="shield-${ptr}"]`).exists()).toBe(true); + ws.discard(); + await flushPromises(); + expect(ws.ctx.currentPreview.value).toBeNull(); + expect(wrapper.find(`[data-test="shield-${ptr}"]`).exists()).toBe(false); + }); +}); diff --git a/src/components/agents/config/__tests__/PluginTabs.spec.ts b/src/components/agents/config/__tests__/PluginTabs.spec.ts new file mode 100644 index 00000000..11294224 --- /dev/null +++ b/src/components/agents/config/__tests__/PluginTabs.spec.ts @@ -0,0 +1,270 @@ +// The plugins of the Effective view as tabs: selection, keyboard navigation, the add action +// (which selects the new plugin's tab), removal markers and the empty state. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + flushPromises, + mount, + type VueWrapper, + enableAutoUnmount, +} from '@vue/test-utils'; +import type { AgentConfigApi } from '@/composables/agent-config/useAgentConfigApi'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; +import type { Agent } from '@/types/agents'; +import { + ADMIN, + READER, + fakeApi, + globalWith, + piniaWith, + workspaceHost, +} from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); +vi.mock('primevue/useconfirm', () => ({ + useConfirm: () => ({ + require: (o: { accept?: () => void }) => o.accept?.(), + }), +})); +const api = vi.hoisted(() => ({ current: null as unknown as AgentConfigApi })); +vi.mock('@/composables/agent-config/useAgentConfigApi', async () => { + const actual = await vi.importActual< + typeof import('@/composables/agent-config/useAgentConfigApi') + >('@/composables/agent-config/useAgentConfigApi'); + return { ...actual, useAgentConfigApi: () => api.current }; +}); + +import AgentConfigTab from '../AgentConfigTab.vue'; +import AgentConfigEffectiveView from '../AgentConfigEffectiveView.vue'; +import AddPluginDialog from '../editor/AddPluginDialog.vue'; + +// PrimeVue's TabList schedules a 150 ms ink-bar update on mount and never clears it; a wrapper +// left mounted lets it fire after this file's jsdom environment is torn down +// ("HTMLElement is not defined"). Unmounting nulls its refs, so the timer becomes a no-op. +enableAutoUnmount(afterEach); + +const agent: Agent = { + id: 'agent-1', + name: 'ssh agent', + isActive: true, + serviceAccountKeyCount: 1, + createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', +}; + +async function mountTab(perms: Record = ADMIN) { + const wrapper = mount(AgentConfigTab, { + props: { agent }, + global: globalWith(piniaWith(perms)), + attachTo: document.body, + }); + await flushPromises(); + const ws = (wrapper.vm as unknown as { ws: ConfigWorkspace }).ws; + return { wrapper, ws }; +} + +const tab = (w: VueWrapper, name: string) => + w.find(`[data-test="plugin-tab-${name}"]`); +const selectedTabs = (w: VueWrapper) => + w + .findAll('[role="tab"]') + .filter((t) => t.attributes('aria-selected') === 'true') + .map((t) => t.attributes('data-test')); + +describe('plugin tabs', () => { + beforeEach(() => { + resetAgentDrafts(); + api.current = fakeApi(); + }); + + it('renders one labelled tab per plugin, the first selected, with ARIA wiring', async () => { + const { wrapper } = await mountTab(); + const list = wrapper.find('[role="tablist"]'); + const heading = wrapper.find('[data-test="plugin-tabs"] h4'); + expect(list.attributes('aria-labelledby')).toBe(heading.attributes('id')); + const tabs = wrapper.findAll('[role="tab"]'); + expect(tabs.map((t) => t.text())).toEqual([ + expect.stringContaining('local-ssh'), + expect.stringContaining('ubuntu-packages'), + ]); + expect(selectedTabs(wrapper)).toEqual(['plugin-tab-local-ssh']); + const panel = wrapper.find('[data-test="plugin-panel-local-ssh"]'); + expect(panel.attributes('role')).toBe('tabpanel'); + expect(panel.attributes('aria-labelledby')).toBe(tabs[0].attributes('id')); + expect(tabs[0].attributes('aria-controls')).toBe(panel.attributes('id')); + // Inactive panels stay mounted (an open editor keeps its state) but hidden. + const other = wrapper.find('[data-test="plugin-panel-ubuntu-packages"]'); + expect(other.exists()).toBe(true); + expect(other.isVisible()).toBe(false); + }); + + it('selects on click, and moves the focus with the arrow keys (Enter selects)', async () => { + const { wrapper } = await mountTab(); + await tab(wrapper, 'ubuntu-packages').trigger('click'); + expect(selectedTabs(wrapper)).toEqual(['plugin-tab-ubuntu-packages']); + expect( + wrapper.find('[data-test="plugin-panel-ubuntu-packages"]').isVisible(), + ).toBe(true); + + await tab(wrapper, 'ubuntu-packages').trigger('keydown', { + code: 'ArrowRight', + }); + // Wraps around to the first tab. + expect(document.activeElement).toBe(tab(wrapper, 'local-ssh').element); + await tab(wrapper, 'local-ssh').trigger('keydown', { code: 'ArrowLeft' }); + expect(document.activeElement).toBe( + tab(wrapper, 'ubuntu-packages').element, + ); + await tab(wrapper, 'ubuntu-packages').trigger('keydown', { code: 'Home' }); + expect(document.activeElement).toBe(tab(wrapper, 'local-ssh').element); + await tab(wrapper, 'local-ssh').trigger('keydown', { code: 'Enter' }); + expect(selectedTabs(wrapper)).toEqual(['plugin-tab-local-ssh']); + }); + + it('the add action adds a pending plugin and selects its tab; discarding falls back', async () => { + const { wrapper, ws } = await mountTab(); + const add = wrapper.find('[data-test="add-plugin"]'); + expect(add.text()).toContain('Add plugin'); + // The action sits after the tablist, not inside it. + expect( + wrapper.find('[role="tablist"] [data-test="add-plugin"]').exists(), + ).toBe(false); + await add.trigger('click'); + const dialog = wrapper.findComponent(AddPluginDialog); + expect(dialog.props('visible')).toBe(true); + expect(dialog.props('existing')).toEqual( + expect.arrayContaining(['local-ssh', 'ubuntu-packages']), + ); + dialog.vm.$emit('add', { + name: 'extra', + source: 'ghcr.io/compliance-framework/plugin-extra:v1', + }); + await flushPromises(); + expect(ws.draft.overlay.value.plugins?.extra).toEqual({ + source: 'ghcr.io/compliance-framework/plugin-extra:v1', + policies: [], + }); + expect(selectedTabs(wrapper)).toEqual(['plugin-tab-extra']); + expect( + tab(wrapper, 'extra').find('[data-test="plugin-tab-hint-new"]').exists(), + ).toBe(true); + const panel = wrapper.find('[data-test="plugin-panel-extra"]'); + expect(panel.find('[data-test="pending-new"]').exists()).toBe(true); + + await panel.find('[data-test="plugin-undo-removal"]').trigger('click'); + expect(tab(wrapper, 'extra').exists()).toBe(false); + expect(selectedTabs(wrapper)).toEqual(['plugin-tab-local-ssh']); + }); + + it('marks pending changes, removals and disabled plugins on the tab', async () => { + const { wrapper, ws } = await mountTab(); + const panel = wrapper.find('[data-test="plugin-panel-ubuntu-packages"]'); + await panel.find('[data-test="remove-plugin"]').trigger('click'); + expect(ws.draft.overlay.value.plugins?.['ubuntu-packages']).toBeNull(); + expect( + tab(wrapper, 'ubuntu-packages') + .find('[data-test="plugin-tab-hint-removal"]') + .exists(), + ).toBe(true); + await panel.find('[data-test="plugin-undo-removal"]').trigger('click'); + expect( + tab(wrapper, 'ubuntu-packages') + .find('[data-test^="plugin-tab-hint-"]') + .exists(), + ).toBe(false); + + ws.draft.set('/plugins/local-ssh/schedule', '0 0 * * *'); + await flushPromises(); + expect( + tab(wrapper, 'local-ssh') + .find('[data-test="plugin-tab-hint-pending"]') + .exists(), + ).toBe(true); + }); + + it('readers get no add action', async () => { + const { wrapper } = await mountTab(READER); + expect(wrapper.find('[data-test="add-plugin"]').exists()).toBe(false); + expect(wrapper.findAll('[role="tab"]').length).toBe(2); + }); + + it('shows the disabled hint', () => { + const view = mount(AgentConfigEffectiveView, { + props: { + effectiveDoc: { + plugins: { + a: { source: 'ghcr.io/x/a:v1', enabled: false }, + b: { source: 'ghcr.io/x/b:v1' }, + }, + }, + base: null, + appliedOverlay: {}, + appliedRevisionNote: null, + filename: 'x.yaml', + }, + global: globalWith(piniaWith(READER)), + }); + expect( + view + .find( + '[data-test="plugin-tab-a"] [data-test="plugin-tab-hint-disabled"]', + ) + .exists(), + ).toBe(true); + expect( + view + .find('[data-test="plugin-tab-b"] [data-test^="plugin-tab-hint"]') + .exists(), + ).toBe(false); + }); + + it('handles many plugins in a scrollable strip', () => { + const plugins = Object.fromEntries( + Array.from({ length: 30 }, (_, n) => [ + `plugin-${String(n).padStart(2, '0')}`, + { source: `ghcr.io/x/p${n}:v1` }, + ]), + ); + const view = mount(AgentConfigEffectiveView, { + props: { + effectiveDoc: { plugins }, + base: null, + appliedOverlay: {}, + appliedRevisionNote: null, + filename: 'x.yaml', + }, + global: globalWith(piniaWith(READER)), + }); + expect(view.findAll('[role="tab"]').length).toBe(30); + expect( + view.find('[role="tablist"]').element.closest('[data-p~="scrollable"]'), + ).not.toBeNull(); + }); + + it('empty state: no tabs, the add action stays available', async () => { + const out: { ws?: ConfigWorkspace } = {}; + const host = workspaceHost( + fakeApi(), + AgentConfigEffectiveView, + () => ({ + effectiveDoc: { verbosity: 0, plugins: {} }, + base: { plugins: {} }, + appliedOverlay: {}, + appliedRevisionNote: null, + filename: 'x.yaml', + }), + out, + ); + const wrapper = mount(host, { global: globalWith(piniaWith(ADMIN)) }); + await flushPromises(); + expect(wrapper.text()).toContain('No plugins configured.'); + expect(wrapper.find('[role="tablist"]').exists()).toBe(false); + await wrapper.find('[data-test="add-plugin"]').trigger('click'); + wrapper.findComponent(AddPluginDialog).vm.$emit('add', { + name: 'first', + source: 'ghcr.io/x/first:v1', + }); + await flushPromises(); + expect(selectedTabs(wrapper)).toEqual(['plugin-tab-first']); + }); +}); From 8f368b1f886ea53b664132b7d2f4846e2a7f1e46 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:24:33 -0300 Subject: [PATCH 2/4] test(agents): the reader case asserts the tab renders before its absences "a reader gets no pencils and no plugin actions" only asserted absences, so it passed when the tab failed to load (CORE-TEST-002). It now first expects the schedule field and the local-ssh card. Co-Authored-By: Claude Opus 5.5 --- .../agents/config/__tests__/InlineEditing.spec.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/agents/config/__tests__/InlineEditing.spec.ts b/src/components/agents/config/__tests__/InlineEditing.spec.ts index 2310f815..5dfda969 100644 --- a/src/components/agents/config/__tests__/InlineEditing.spec.ts +++ b/src/components/agents/config/__tests__/InlineEditing.spec.ts @@ -322,6 +322,13 @@ describe('field states (R71)', () => { it('a reader gets no pencils and no plugin actions', async () => { const { wrapper } = await mountTab(READER); + // The tab renders the fields (the absences below are about their actions, not a broken tab). + expect( + wrapper.find('[data-test="field-/plugins/local-ssh/schedule"]').exists(), + ).toBe(true); + expect(wrapper.find('[data-test="plugin-card-local-ssh"]').exists()).toBe( + true, + ); expect( wrapper.find('[data-test="edit-/plugins/local-ssh/policies"]').exists(), ).toBe(false); From be87e94b08f7511c683d438b83acd83a1b07bfed Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:36:56 -0300 Subject: [PATCH 3/4] test(agents): the synthetic apply_all instance has a loadable file "keeps the shield on the new plugin's tab" lists an extra instance (ip-all) the fixture API had no detail for. Its load failed silently before; now Add plugin waits for every reporting instance's file, so the spec serves ip-all's detail too. Co-Authored-By: Claude Opus 5.5 --- .../config/__tests__/AddPluginGating.spec.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/components/agents/config/__tests__/AddPluginGating.spec.ts b/src/components/agents/config/__tests__/AddPluginGating.spec.ts index bba2d2cb..5ad87458 100644 --- a/src/components/agents/config/__tests__/AddPluginGating.spec.ts +++ b/src/components/agents/config/__tests__/AddPluginGating.spec.ts @@ -14,6 +14,8 @@ import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorksp import type { AgentInstanceSummary } from '@/types/agent-config'; import type { Agent } from '@/types/agents'; import { + configRev7, + detailFor, instancesMixed, remoteConfigSafe, } from '@/composables/agent-config/__tests__/fixtures'; @@ -212,16 +214,21 @@ describe('add-plugin gating (R71)', () => { }); it("keeps the shield on the new plugin's tab, card and fields", async () => { - withInstances([ - ...fresh.filter((i) => i.mode === 'apply_safe'), - { - ...fresh[0], - instanceId: '0f5e2c1a-0000-4000-8000-0000000000aa', - hostname: 'ip-all', - mode: 'apply_all', - remoteConfig: { ...remoteConfigSafe, mode: 'apply_all' }, - }, - ]); + const all: AgentInstanceSummary = { + ...fresh[0], + instanceId: '0f5e2c1a-0000-4000-8000-0000000000aa', + hostname: 'ip-all', + mode: 'apply_all', + remoteConfig: { ...remoteConfigSafe, mode: 'apply_all' }, + }; + withInstances([...fresh.filter((i) => i.mode === 'apply_safe'), all]); + // ip-all's file loads too: editing waits for every reporting instance's file. + const fallback = api.current.getInstance; + api.current.getInstance = vi.fn(async (agentId: string, id: string) => + id === all.instanceId + ? detailFor(all, configRev7.overlay ?? {}) + : fallback(agentId, id), + ); const { wrapper, ws } = await mountTab(); await openDialog(wrapper, 'docker.io/acme/extra:v1'); expect( From 224c22ae5d5698701d379d837a6592dd7425ba56 Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:07:12 -0300 Subject: [PATCH 4/4] test(agents): integration specs serve their instance lists page by page AddPluginGating and InlineEditing build their fleets with pagedListInstances, so meta (page fields, counts) matches the rows as the paginated API's would. Co-Authored-By: Claude Opus 5.5 --- .../config/__tests__/AddPluginGating.spec.ts | 14 +++++++------ .../config/__tests__/InlineEditing.spec.ts | 21 +++++++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/components/agents/config/__tests__/AddPluginGating.spec.ts b/src/components/agents/config/__tests__/AddPluginGating.spec.ts index 5ad87458..487d2885 100644 --- a/src/components/agents/config/__tests__/AddPluginGating.spec.ts +++ b/src/components/agents/config/__tests__/AddPluginGating.spec.ts @@ -19,7 +19,13 @@ import { instancesMixed, remoteConfigSafe, } from '@/composables/agent-config/__tests__/fixtures'; -import { ADMIN, fakeApi, globalWith, piniaWith } from './helpers'; +import { + ADMIN, + fakeApi, + globalWith, + pagedListInstances, + piniaWith, +} from './helpers'; vi.mock('@/components/code-editor', () => import('./codeEditorMock')); const api = vi.hoisted(() => ({ current: null as unknown as AgentConfigApi })); @@ -53,11 +59,7 @@ const fresh = instancesMixed.items.filter( ); function withInstances(items: AgentInstanceSummary[]) { - api.current = fakeApi({ - listInstances: vi - .fn() - .mockResolvedValue({ items, meta: instancesMixed.meta }), - }); + api.current = fakeApi({ listInstances: pagedListInstances(items) }); } async function mountTab() { diff --git a/src/components/agents/config/__tests__/InlineEditing.spec.ts b/src/components/agents/config/__tests__/InlineEditing.spec.ts index 5dfda969..8914f46a 100644 --- a/src/components/agents/config/__tests__/InlineEditing.spec.ts +++ b/src/components/agents/config/__tests__/InlineEditing.spec.ts @@ -12,7 +12,14 @@ import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; import type { Agent } from '@/types/agents'; import { instancesMixed } from '@/composables/agent-config/__tests__/fixtures'; -import { ADMIN, READER, fakeApi, globalWith, piniaWith } from './helpers'; +import { + ADMIN, + READER, + fakeApi, + globalWith, + pagedListInstances, + piniaWith, +} from './helpers'; vi.mock('@/components/code-editor', () => import('./codeEditorMock')); // Confirmations accept immediately. @@ -286,10 +293,9 @@ describe('field states (R71)', () => { it('all apply: pencil only, no shield', async () => { api.current = fakeApi({ - listInstances: vi.fn().mockResolvedValue({ - ...instancesMixed, - items: instancesMixed.items.filter((i) => i.mode !== 'report'), - }), + listInstances: pagedListInstances( + instancesMixed.items.filter((i) => i.mode !== 'report'), + ), }); const { wrapper } = await mountTab(); const port = wrapper.find(sel('/plugins/local-ssh/config/port')); @@ -302,10 +308,7 @@ describe('field states (R71)', () => { it('no reporting instance yet: editable without shields', async () => { api.current = fakeApi({ - listInstances: vi.fn().mockResolvedValue({ - items: [], - meta: instancesMixed.meta, - }), + listInstances: pagedListInstances([]), }); const tab = mount(AgentConfigTab, { props: { agent },