From fbc6954ce46d86063259e73cac747cb7412f7eec Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 5 Oct 2026 07:40:54 -0300 Subject: [PATCH 1/3] feat(agent-config): plugin summary card Layer 17 of 21 in the stacked split of compliance-framework/ui#318. Co-Authored-By: Claude Opus 5.5 --- .../agents/config/PluginSummaryCard.vue | 364 +++++++++++++ .../__tests__/PolicyDataSection.spec.ts | 482 ++++++++++++++++++ 2 files changed, 846 insertions(+) create mode 100644 src/components/agents/config/PluginSummaryCard.vue create mode 100644 src/components/agents/config/__tests__/PolicyDataSection.spec.ts diff --git a/src/components/agents/config/PluginSummaryCard.vue b/src/components/agents/config/PluginSummaryCard.vue new file mode 100644 index 00000000..81b4a009 --- /dev/null +++ b/src/components/agents/config/PluginSummaryCard.vue @@ -0,0 +1,364 @@ + + + diff --git a/src/components/agents/config/__tests__/PolicyDataSection.spec.ts b/src/components/agents/config/__tests__/PolicyDataSection.spec.ts new file mode 100644 index 00000000..9ea44eb0 --- /dev/null +++ b/src/components/agents/config/__tests__/PolicyDataSection.spec.ts @@ -0,0 +1,482 @@ +// policy_data on the Effective view: a structured tree without type labels, edited one key / +// list item at a time (minimal merge patch; a list item edit is one element change), masked +// and ${env:} values shown distinctly, and a raw JSON view that also records only real changes. +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; +import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { ConfigDoc } from '@/types/agent-config'; +import { + baseConfig, + configRev7, + instanceDetailA, +} from '@/composables/agent-config/__tests__/fixtures'; +import { clone } from '@/utils/agent-config/merge-patch'; +import { + ADMIN, + READER, + fakeApi, + globalWith, + piniaWith, + workspaceHost, +} from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); + +import PolicyDataSection from '../effective/PolicyDataSection.vue'; +import PluginSummaryCard from '../PluginSummaryCard.vue'; +import SelectButton from '@/volt/SelectButton.vue'; + +const PD = '/plugins/local-ssh/policy_data'; +const POLICY_DATA = { + MaxAuthTries: 4, + 'allowed-users': ['root', 'admin'], + nested: { Deep_Key: { level: 2, enabled: true, more: { a: 1 } } }, + rules: [ + { id: 'r1', on: true }, + { id: 'r2', on: false }, + ], + api_token: '••••', + // Masked by value (e.g. a URL with a password), under keys that do not look secret. + upstream: '••••', + mirrors: ['https://a.example', '••••', { url: '••••' }], + banner: 'Hello ${env:BANNER}!', + nothing: null, + big: Array.from({ length: 60 }, (_, i) => i), +}; + +async function mountSection( + opts: { perms?: Record; overlay?: object } = {}, +) { + const base = clone(baseConfig) as ConfigDoc; + base.plugins!['local-ssh']!.policy_data = clone(POLICY_DATA); + const saved = { ...configRev7, overlay: opts.overlay ?? {} }; + const api = fakeApi({ + getConfig: vi.fn().mockResolvedValue(saved), + getRevision: vi.fn().mockResolvedValue(saved), + getInstance: vi + .fn() + .mockResolvedValue({ ...instanceDetailA, base, effective: base }), + }); + const out: { ws?: ConfigWorkspace } = {}; + const host = workspaceHost( + api, + PolicyDataSection, + () => ({ + plugin: 'local-ssh', + reported: POLICY_DATA, + provenance: 'file', + }), + out, + ); + const wrapper = mount(host, { + global: globalWith(piniaWith(opts.perms ?? ADMIN)), + attachTo: document.body, + }); + await flushPromises(); + return { wrapper, ws: out.ws! }; +} + +const pd = (ws: ConfigWorkspace) => + ws.draft.overlay.value.plugins?.['local-ssh']?.policy_data as + | Record + | undefined; +const node = (w: VueWrapper, rel: string) => + w.find(`[data-test="pd-node-${PD}/${rel}"]`); +const btn = (w: VueWrapper, what: string, rel: string) => + w.find(`[data-test="pd-${what}-${PD}/${rel}"]`); + +async function editValue(w: VueWrapper, rel: string, value: string | boolean) { + await btn(w, 'edit', rel).trigger('click'); + const form = btn(w, 'edit-form', rel); + const input = form.find('[data-test="pd-edit-value"]'); + if (typeof value === 'boolean') await input.setValue(value); + else await input.setValue(value); + await form.trigger('submit'); +} + +describe('policy_data section: display', () => { + beforeEach(() => resetAgentDrafts()); + + it('shows no type labels or type selectors; masked and ${env:} values stand out', async () => { + const { wrapper } = await mountSection(); + expect(wrapper.find('[data-test^="pd-type-"]').exists()).toBe(false); + for (const t of ['number', 'string', 'boolean', 'object', 'array']) { + expect(wrapper.text()).not.toMatch(new RegExp(`\\b${t}\\b`)); + } + expect(node(wrapper, 'MaxAuthTries').text()).toContain('4'); + expect(node(wrapper, 'allowed-users').text()).toContain('2 items'); + expect( + node(wrapper, 'api_token').find('[data-test="pd-masked"]').exists(), + ).toBe(true); + const banner = node(wrapper, 'banner'); + expect(banner.find('[data-test="pd-env"]').text()).toBe('${env:BANNER}'); + expect( + banner.find('[data-test="pd-env-pill"]').attributes('aria-label'), + ).toContain('resolved only in plugin config values'); + // A null has no editor (its type changes in the raw view). + expect(btn(wrapper, 'edit', 'nothing').exists()).toBe(false); + await btn(wrapper, 'edit', 'MaxAuthTries').trigger('click'); + expect(wrapper.find('select').exists()).toBe(false); + wrapper.unmount(); + }); + + it('puts the edit / remove actions right after the key, before the value', async () => { + const { wrapper } = await mountSection(); + const row = node(wrapper, 'MaxAuthTries').element.firstElementChild!; + const kids = Array.from(row.children); + const label = kids.findIndex((k) => k.textContent === 'MaxAuthTries'); + const actions = row.querySelector('[data-test="pd-actions"]')!; + expect(kids[label + 1]).toBe(actions); + expect(actions.className).not.toContain('ml-auto'); + expect( + Array.from(actions.querySelectorAll('button')).map((b) => + b.getAttribute('aria-label'), + ), + ).toEqual(['Edit MaxAuthTries', 'Remove MaxAuthTries']); + wrapper.unmount(); + }); + + it('collapses deep and large containers, and pages long lists', async () => { + const { wrapper } = await mountSection(); + expect(node(wrapper, 'nested/Deep_Key/level').exists()).toBe(true); + const more = btn(wrapper, 'toggle', 'nested/Deep_Key/more'); + expect(more.attributes('aria-expanded')).toBe('false'); + await more.trigger('click'); + expect(node(wrapper, 'nested/Deep_Key/more/a').exists()).toBe(true); + const big = btn(wrapper, 'toggle', 'big'); + expect(big.attributes('aria-expanded')).toBe('false'); + await big.trigger('click'); + expect(node(wrapper, 'big/49').exists()).toBe(true); + expect(node(wrapper, 'big/50').exists()).toBe(false); + await btn(wrapper, 'show-all', 'big').trigger('click'); + expect(node(wrapper, 'big/59').exists()).toBe(true); + wrapper.unmount(); + }); + + it('is read-only for readers (on the card too)', async () => { + const { wrapper } = await mountSection({ perms: READER }); + expect(wrapper.find('[data-test^="pd-edit-"]').exists()).toBe(false); + expect(wrapper.find('[data-test^="pd-remove-"]').exists()).toBe(false); + expect(wrapper.find('[data-test^="pd-add-"]').exists()).toBe(false); + expect(wrapper.findComponent(SelectButton).exists()).toBe(false); + wrapper.unmount(); + + const card = mount(PluginSummaryCard, { + props: { + name: 'a', + plugin: { source: 'ghcr.io/x/a:v1', policy_data: POLICY_DATA }, + base: null, + overlay: null, + }, + global: globalWith(piniaWith(READER)), + }); + expect(card.find('[data-test="policy-data-view-a"]').text()).toContain( + 'MaxAuthTries', + ); + expect(card.text()).toContain('10 keys'); + }); +}); + +describe('policy_data section: per-element edits (minimal patch)', () => { + beforeEach(() => resetAgentDrafts()); + + it('a scalar edit writes only that key and keeps its type', async () => { + const { wrapper, ws } = await mountSection(); + await btn(wrapper, 'edit', 'MaxAuthTries').trigger('click'); + const form = btn(wrapper, 'edit-form', 'MaxAuthTries'); + await form.find('[data-test="pd-edit-value"]').setValue('lots'); + expect(form.find('[data-test="pd-edit-error"]').text()).toBe( + 'Enter a number', + ); + await form.find('[data-test="pd-edit-value"]').setValue('6'); + await form.trigger('submit'); + expect(pd(ws)).toEqual({ MaxAuthTries: 6 }); + expect(ws.draft.changedPaths.value).toEqual([`${PD}/MaxAuthTries`]); + expect(btn(wrapper, 'pending', 'MaxAuthTries').exists()).toBe(true); + // Setting it back to the file value drops the entry. + await editValue(wrapper, 'MaxAuthTries', '4'); + expect(ws.draft.isDirty.value).toBe(false); + wrapper.unmount(); + }); + + it('a nested key and a boolean (checkbox) are written at their own pointer', async () => { + const { wrapper, ws } = await mountSection(); + await editValue(wrapper, 'nested/Deep_Key/enabled', false); + expect(pd(ws)).toEqual({ nested: { Deep_Key: { enabled: false } } }); + await editValue(wrapper, 'banner', 'Bye'); + expect(pd(ws)).toEqual({ + nested: { Deep_Key: { enabled: false } }, + banner: 'Bye', + }); + expect(ws.draft.changedPaths.value).toHaveLength(2); + wrapper.unmount(); + }); + + it('removing a key writes null; adding keys keeps their case and JSON type', async () => { + const { wrapper, ws } = await mountSection(); + await btn(wrapper, 'remove', 'banner').trigger('click'); + expect(pd(ws)).toEqual({ banner: null }); + expect(node(wrapper, 'banner').exists()).toBe(false); + + await wrapper.find('[data-test="pd-add-root"]').trigger('click'); + const form = wrapper.find('[data-test="pd-add-form-root"]'); + await form.find('[data-test="pd-new-key"]').setValue('MaxAuthTries'); + expect(form.find('[data-test="pd-add-error"]').text()).toBe( + 'This key exists', + ); + await form.find('[data-test="pd-new-key"]').setValue('New-Key_camelCase'); + await form.find('[data-test="pd-new-value"]').setValue('7'); + await form.trigger('submit'); + expect(pd(ws)).toEqual({ banner: null, 'New-Key_camelCase': 7 }); + + // A key inside a nested object. + await btn(wrapper, 'add', `nested/Deep_Key`).trigger('click'); + const nf = btn(wrapper, 'add-form', 'nested/Deep_Key'); + await nf.find('[data-test="pd-new-key"]').setValue('note'); + await nf.find('[data-test="pd-new-value"]').setValue('plain text'); + await nf.trigger('submit'); + expect(pd(ws)!.nested).toEqual({ Deep_Key: { note: 'plain text' } }); + wrapper.unmount(); + }); + + it('a list item edit writes the whole list but is one element change, undoable on the item', async () => { + const { wrapper, ws } = await mountSection(); + await editValue(wrapper, 'allowed-users/1', 'ops'); + expect(pd(ws)).toEqual({ 'allowed-users': ['root', 'ops'] }); + expect(ws.draft.changedPaths.value).toEqual([`${PD}/allowed-users/1`]); + expect(btn(wrapper, 'pending', 'allowed-users/1').exists()).toBe(true); + expect(btn(wrapper, 'pending', 'allowed-users/0').exists()).toBe(false); + await btn(wrapper, 'undo', 'allowed-users/1').trigger('click'); + expect(ws.draft.isDirty.value).toBe(false); + + // Add an item: the list's item type (string) is kept, even for "5". + await btn(wrapper, 'add', 'allowed-users').trigger('click'); + const form = btn(wrapper, 'add-form', 'allowed-users'); + expect(form.find('[data-test="pd-new-key"]').exists()).toBe(false); + await form.find('[data-test="pd-new-value"]').setValue('5'); + await form.trigger('submit'); + expect(pd(ws)!['allowed-users']).toEqual(['root', 'admin', '5']); + expect(ws.draft.changedPaths.value).toEqual([`${PD}/allowed-users/2`]); + + // Remove an item. + await btn(wrapper, 'remove', 'allowed-users/0').trigger('click'); + expect(pd(ws)!['allowed-users']).toEqual(['admin', '5']); + expect(ws.draft.changedPaths.value).toHaveLength(2); + wrapper.unmount(); + }); + + it('closes an open item editor when a removal shifts the list under it', async () => { + const { wrapper, ws } = await mountSection(); + await btn(wrapper, 'add', 'allowed-users').trigger('click'); + const form = btn(wrapper, 'add-form', 'allowed-users'); + await form.find('[data-test="pd-new-value"]').setValue('z'); + await form.trigger('submit'); + expect(pd(ws)!['allowed-users']).toEqual(['root', 'admin', 'z']); + + // Editing [1] ("admin"), then removing [0]: the node at [1] now shows "z". + await btn(wrapper, 'edit', 'allowed-users/1').trigger('click'); + await btn(wrapper, 'edit-form', 'allowed-users/1') + .find('[data-test="pd-edit-value"]') + .setValue('edited'); + await btn(wrapper, 'remove', 'allowed-users/0').trigger('click'); + await flushPromises(); + expect(btn(wrapper, 'edit-form', 'allowed-users/1').exists()).toBe(false); + expect(pd(ws)!['allowed-users']).toEqual(['admin', 'z']); + expect(node(wrapper, 'allowed-users/1').text()).toContain('"z"'); + wrapper.unmount(); + }); + + it('refuses null for a key (an overlay delete), but not for a list item', async () => { + const { wrapper, ws } = await mountSection(); + await wrapper.find('[data-test="pd-add-root"]').trigger('click'); + const form = wrapper.find('[data-test="pd-add-form-root"]'); + await form.find('[data-test="pd-new-key"]').setValue('foo'); + await form.find('[data-test="pd-new-value"]').setValue('null'); + expect(form.find('[data-test="pd-add-error"]').text()).toContain( + "null can't be stored for a key", + ); + expect( + form.find('[data-test="pd-add-submit"]').attributes('disabled'), + ).toBeDefined(); + await form.trigger('submit'); + expect(pd(ws)).toBeUndefined(); + + await btn(wrapper, 'add', 'rules').trigger('click'); + const lf = btn(wrapper, 'add-form', 'rules'); + await lf.find('[data-test="pd-new-value"]').setValue('null'); + expect(lf.find('[data-test="pd-add-error"]').exists()).toBe(false); + await lf.trigger('submit'); + expect(pd(ws)!.rules).toEqual([...POLICY_DATA.rules, null]); + wrapper.unmount(); + }); + + it('a key inside a list item rewrites the list (never a pointer into the array)', async () => { + const { wrapper, ws } = await mountSection(); + await editValue(wrapper, 'rules/1/on', true); + expect(pd(ws)).toEqual({ + rules: [ + { id: 'r1', on: true }, + { id: 'r2', on: true }, + ], + }); + expect(ws.draft.changedPaths.value).toEqual([`${PD}/rules/1`]); + wrapper.unmount(); + }); + + it('a masked value needs a new value; an untouched one never reaches the overlay', async () => { + const { wrapper, ws } = await mountSection(); + await btn(wrapper, 'edit', 'api_token').trigger('click'); + const form = btn(wrapper, 'edit-form', 'api_token'); + const input = form.find('[data-test="pd-edit-value"]'); + expect((input.element as HTMLInputElement).value).toBe(''); + expect(input.attributes('placeholder')).toBe('masked; type a new value'); + expect(form.find('[data-test="pd-edit-error"]').exists()).toBe(true); + await input.setValue('${env:TOKEN}'); + await form.trigger('submit'); + expect(pd(ws)).toEqual({ api_token: '${env:TOKEN}' }); + wrapper.unmount(); + }); + + it('composes with entries the saved revision already has', async () => { + const { wrapper, ws } = await mountSection({ + overlay: { + verbosity: 1, + plugins: { 'local-ssh': { policy_data: { MaxAuthTries: 9 } } }, + }, + }); + expect(node(wrapper, 'MaxAuthTries').text()).toContain('9'); + await editValue(wrapper, 'banner', 'x'); + expect(pd(ws)).toEqual({ MaxAuthTries: 9, banner: 'x' }); + expect(ws.draft.overlay.value.verbosity).toBe(1); + expect(ws.draft.changedPaths.value).toEqual([`${PD}/banner`]); + wrapper.unmount(); + }); +}); + +describe('policy_data section: masked values anywhere', () => { + beforeEach(() => resetAgentDrafts()); + + it('a masked value under any key shows as masked and needs a new value', async () => { + const { wrapper, ws } = await mountSection(); + expect( + node(wrapper, 'upstream').find('[data-test="pd-masked"]').exists(), + ).toBe(true); + await btn(wrapper, 'edit', 'upstream').trigger('click'); + const form = btn(wrapper, 'edit-form', 'upstream'); + expect(form.find('[data-test="pd-edit-error"]').exists()).toBe(true); + // Editing a sibling never copies the mask. + await btn(wrapper, 'edit-form', 'upstream') + .find('button[type="button"]') + .trigger('click'); + await editValue(wrapper, 'banner', 'x'); + expect(pd(ws)).toEqual({ banner: 'x' }); + wrapper.unmount(); + }); + + it('a list holding a masked value cannot be edited item by item (it would copy the mask)', async () => { + const { wrapper, ws } = await mountSection(); + const list = node(wrapper, 'mirrors'); + expect(btn(wrapper, 'masked-list', 'mirrors').exists()).toBe(true); + expect(list.find(`[data-test="pd-edit-${PD}/mirrors/0"]`).exists()).toBe( + false, + ); + expect(list.find(`[data-test="pd-remove-${PD}/mirrors/0"]`).exists()).toBe( + false, + ); + expect( + list.find(`[data-test="pd-edit-${PD}/mirrors/2/url"]`).exists(), + ).toBe(false); + expect(btn(wrapper, 'add', 'mirrors').exists()).toBe(false); + // The whole list can still be removed (nothing is copied). + await btn(wrapper, 'remove', 'mirrors').trigger('click'); + expect(pd(ws)).toEqual({ mirrors: null }); + expect(ws.draft.clientIssues.value).toEqual([]); + wrapper.unmount(); + }); +}); + +describe('policy_data section: raw JSON view', () => { + beforeEach(() => resetAgentDrafts()); + + it('records only real changes, and stays in sync with the tree', async () => { + const { wrapper, ws } = await mountSection(); + await editValue(wrapper, 'MaxAuthTries', '9'); + wrapper.findComponent(SelectButton).vm.$emit('update:modelValue', 'raw'); + await flushPromises(); + const textarea = wrapper.find('textarea'); + const raw = JSON.parse((textarea.element as HTMLTextAreaElement).value); + expect(raw.MaxAuthTries).toBe(9); + expect(raw.api_token).toBe('••••'); + + // One key changed (and a type change, only possible here): one entry each. + raw.banner = 42; + raw['allowed-users'][0] = 'ROOT'; + await textarea.setValue(JSON.stringify(raw)); + expect(pd(ws)).toEqual({ + MaxAuthTries: 9, + banner: 42, + 'allowed-users': ['ROOT', 'admin'], + }); + expect(ws.draft.changedPaths.value).toEqual([ + `${PD}/MaxAuthTries`, + `${PD}/allowed-users/0`, + `${PD}/banner`, + ]); + + // Deleting a key in the raw view nulls it. + delete raw.nothing; + await textarea.setValue(JSON.stringify(raw)); + expect(pd(ws)!.nothing).toBeNull(); + + // Invalid JSON: the structured view is unavailable until it is fixed. + await textarea.setValue('{nope'); + expect(wrapper.find('[data-test="policy-data-error"]').exists()).toBe(true); + wrapper + .findComponent(SelectButton) + .vm.$emit('update:modelValue', 'structured'); + await flushPromises(); + expect( + wrapper.find('[data-test="policy-data-view-local-ssh"]').exists(), + ).toBe(false); + await wrapper.find('textarea').setValue(JSON.stringify(raw)); + wrapper + .findComponent(SelectButton) + .vm.$emit('update:modelValue', 'structured'); + await flushPromises(); + expect(node(wrapper, 'banner').text()).toContain('42'); + + // "Use the file value" drops every override. + wrapper.findComponent(SelectButton).vm.$emit('update:modelValue', 'raw'); + await flushPromises(); + await wrapper.find('[data-test="policy-data-reset"]').trigger('click'); + expect(pd(ws)).toBeUndefined(); + wrapper.unmount(); + }); + it('refuses a new null for a key, but keeps nulls inside lists and existing ones', async () => { + const { wrapper, ws } = await mountSection(); + wrapper.findComponent(SelectButton).vm.$emit('update:modelValue', 'raw'); + await flushPromises(); + const textarea = wrapper.find('textarea'); + const raw = JSON.parse((textarea.element as HTMLTextAreaElement).value); + // The file's own `nothing: null` is not a change. + expect(raw.nothing).toBeNull(); + + await textarea.setValue( + JSON.stringify({ ...raw, nested: { ...raw.nested, gone: null } }), + ); + expect(wrapper.find('[data-test="policy-data-error"]').text()).toContain( + "null can't be stored for a key", + ); + expect(pd(ws)).toBeUndefined(); + + await textarea.setValue( + JSON.stringify({ ...raw, 'allowed-users': ['root', null] }), + ); + expect(wrapper.find('[data-test="policy-data-error"]').exists()).toBe( + false, + ); + expect(pd(ws)).toEqual({ 'allowed-users': ['root', null] }); + wrapper.unmount(); + }); +}); From b894e798321fcb924df19aee5e928dc9b708bd6c Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:31:13 -0300 Subject: [PATCH 2/3] test(agent-config): raw policy_data view refuses 1e999 and 20-digit integers Co-Authored-By: Claude Opus 5.5 --- .../__tests__/PolicyDataSection.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/agents/config/__tests__/PolicyDataSection.spec.ts b/src/components/agents/config/__tests__/PolicyDataSection.spec.ts index 9ea44eb0..b380a547 100644 --- a/src/components/agents/config/__tests__/PolicyDataSection.spec.ts +++ b/src/components/agents/config/__tests__/PolicyDataSection.spec.ts @@ -453,6 +453,25 @@ describe('policy_data section: raw JSON view', () => { expect(pd(ws)).toBeUndefined(); wrapper.unmount(); }); + it.each([ + ['1e999', 'an out-of-range number (Infinity, saved as null)'], + ['12345678901234567890', 'an integer past 2^53'], + ])('refuses %s (%s) instead of saving something else', async (literal) => { + const { wrapper, ws } = await mountSection(); + wrapper.findComponent(SelectButton).vm.$emit('update:modelValue', 'raw'); + await flushPromises(); + const textarea = wrapper.find('textarea'); + const text = (textarea.element as HTMLTextAreaElement).value; + await textarea.setValue( + text.replace(/"MaxAuthTries": \d+/, `"MaxAuthTries": ${literal}`), + ); + expect(wrapper.find('[data-test="policy-data-error"]').text()).toContain( + 'cannot be saved as typed', + ); + expect(pd(ws)).toBeUndefined(); + wrapper.unmount(); + }); + it('refuses a new null for a key, but keeps nulls inside lists and existing ones', async () => { const { wrapper, ws } = await mountSection(); wrapper.findComponent(SelectButton).vm.$emit('update:modelValue', 'raw'); From feadb4fdcbf00c7fbf74b48da9806c9ef3b05aab 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:21:18 -0300 Subject: [PATCH 3/3] fix(agent-config): plugin card removal for any name, once every file is loaded - pendingRemoval reads the draft's plugins as own properties (getOwn): a plugin named "constructor" (a valid name) read Object#constructor, so its removal showed no pending state and no Undo - Remove is disabled, with the workspace's reason, until every reporting instance's file is loaded: removal nulls the plugin where any file has it Co-Authored-By: Claude Opus 5.5 --- .../agents/config/PluginSummaryCard.vue | 23 ++-- .../__tests__/PluginSummaryCard.spec.ts | 116 ++++++++++++++++++ 2 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 src/components/agents/config/__tests__/PluginSummaryCard.spec.ts diff --git a/src/components/agents/config/PluginSummaryCard.vue b/src/components/agents/config/PluginSummaryCard.vue index 81b4a009..ff9409d3 100644 --- a/src/components/agents/config/PluginSummaryCard.vue +++ b/src/components/agents/config/PluginSummaryCard.vue @@ -24,14 +24,19 @@ pendingNew ? 'Discard plugin' : removed ? 'Restore' : 'Undo removal' }} - - Remove - + + Remove + + @@ -263,6 +268,7 @@ import type { PluginReport, } from '@/types/agent-config'; import { pointer } from '@/utils/agent-config/json-pointer'; +import { getOwn } from '@/utils/agent-config/merge-patch'; import { pluginProvenance, provenanceOf, @@ -337,9 +343,12 @@ const installText = computed(() => /** The draft removes a plugin the instance still runs. */ const pendingRemoval = computed(() => { if (!ws || props.removed || props.pendingNew) return false; + // Own properties only: a plugin may be named like an Object.prototype member ("constructor"). const eff = ws.draft.effectiveDraft.value.plugins ?? {}; - return ws.draft.pendingAt(p()) && !eff[props.name]; + return ws.draft.pendingAt(p()) && !getOwn(eff, props.name); }); +/** Removal nulls the plugin where any host's file has it: it needs every file (else why not). */ +const removeBlocked = computed(() => ws?.basesBlockedReason.value ?? ''); function confirmRemove() { confirm.require({ diff --git a/src/components/agents/config/__tests__/PluginSummaryCard.spec.ts b/src/components/agents/config/__tests__/PluginSummaryCard.spec.ts new file mode 100644 index 00000000..866f8ba8 --- /dev/null +++ b/src/components/agents/config/__tests__/PluginSummaryCard.spec.ts @@ -0,0 +1,116 @@ +// The plugin card's removal actions: a pending removal (with Undo) for any plugin name, and +// Remove only once every instance's file is loaded (removal nulls the plugin where a file has it). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'; +import type { AgentConfigApi } from '@/composables/agent-config/api-types'; +import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace'; +import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry'; +import type { AgentInstanceDetail, PluginDoc } from '@/types/agent-config'; +import { + baseConfig, + instanceIds, +} from '@/composables/agent-config/__tests__/fixtures'; +import { clone } from '@/utils/agent-config/merge-patch'; +import { getAt, hasAt } from '@/utils/agent-config/json-pointer'; +import { + ADMIN, + fakeApi, + globalWith, + piniaWith, + workspaceHost, +} from './helpers'; + +vi.mock('@/components/code-editor', () => import('./codeEditorMock')); +vi.mock('primevue/useconfirm', () => ({ + useConfirm: () => ({ + require: (o: { accept?: () => void }) => o.accept?.(), + }), +})); + +import PluginSummaryCard from '../PluginSummaryCard.vue'; + +enableAutoUnmount(afterEach); + +// Typed as a plain string, so `plugins[CTOR_NAME]` is the index signature, not Object#constructor. +const CTOR_NAME: string = 'constructor'; +const CTOR: PluginDoc = { + source: 'ghcr.io/compliance-framework/plugin-ctor:v1', +}; + +/** Every instance's file (and report) also runs a plugin named "constructor". */ +function apiWithCtor(over: Partial = {}) { + const fallback = fakeApi().getInstance; + return fakeApi({ + getInstance: vi.fn(async (agentId: string, id: string) => { + const d = clone(await fallback(agentId, id)); + if (d.base) d.base.plugins![CTOR_NAME] = clone(CTOR); + if (d.effective) d.effective.plugins![CTOR_NAME] = clone(CTOR); + return d; + }), + ...over, + }); +} + +async function mountCard(api: AgentConfigApi, name = 'constructor') { + const base = clone(baseConfig); + base.plugins![CTOR_NAME] = clone(CTOR); + const out: { ws?: ConfigWorkspace } = {}; + const host = workspaceHost( + api, + PluginSummaryCard, + () => ({ + name, + plugin: base.plugins![name], + base, + overlay: {}, + }), + out, + ); + const wrapper = mount(host, { global: globalWith(piniaWith(ADMIN)) }); + await flushPromises(); + return { wrapper, ws: out.ws! }; +} + +describe('PluginSummaryCard: removal', () => { + beforeEach(() => resetAgentDrafts()); + + it('shows the pending removal of a plugin named "constructor", with Undo', async () => { + const { wrapper, ws } = await mountCard(apiWithCtor()); + const card = wrapper.find('[data-test="plugin-card-constructor"]'); + expect(card.exists()).toBe(true); + expect(card.find('[data-test="pending-removal"]').exists()).toBe(false); + + await card.find('[data-test="remove-plugin"]').trigger('click'); + // The removal is in the draft... + expect(hasAt(ws.draft.overlay.value, '/plugins/constructor')).toBe(true); + expect(getAt(ws.draft.overlay.value, '/plugins/constructor')).toBeNull(); + // ...so the card says so and offers Undo. + 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); + }); + + it('offers Remove only once every instance file is loaded', async () => { + let releaseB!: () => void; + const fallback = apiWithCtor().getInstance; + const { wrapper } = await mountCard( + apiWithCtor({ + getInstance: vi.fn(async (agentId: string, id: string) => { + const d = await fallback(agentId, id); + if (id !== instanceIds.b) return d; + return new Promise( + (r) => (releaseB = () => r(d)), + ); + }), + }), + 'local-ssh', + ); + const remove = () => wrapper.find('[data-test="remove-plugin"]'); + expect(remove().exists()).toBe(true); + expect(remove().attributes('disabled')).toBeDefined(); + + releaseB(); + await flushPromises(); + expect(remove().attributes('disabled')).toBeUndefined(); + }); +});