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..487d2885 --- /dev/null +++ b/src/components/agents/config/__tests__/AddPluginGating.spec.ts @@ -0,0 +1,260 @@ +// 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 { + configRev7, + detailFor, + instancesMixed, + remoteConfigSafe, +} from '@/composables/agent-config/__tests__/fixtures'; +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 })); +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: pagedListInstances(items) }); +} + +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 () => { + 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( + 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..8914f46a --- /dev/null +++ b/src/components/agents/config/__tests__/InlineEditing.spec.ts @@ -0,0 +1,348 @@ +// 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, + pagedListInstances, + 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: pagedListInstances( + 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: pagedListInstances([]), + }); + 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); + // 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); + 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']); + }); +});