// Unit tests for src/renderer/plugins-mcp-card.js. The DOM-heavy parts are
// exercised by wiring a minimal DOM stub; the pure helpers (summarize / pack
// / validate / isMcpClientRow) get direct-call coverage.
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const M = require('../src/renderer/plugins-mcp-card.js')
test('summarize: empty config renders (unconfigured)', () => {
assert.equal(M.summarize(null), '(unconfigured)')
assert.equal(M.summarize({}), '(unconfigured)')
})
test('summarize: stdio shows serverName + transport + command', () => {
assert.equal(
M.summarize({ transport: 'stdio', serverName: 'github', command: 'npx' }),
'github · stdio · npx',
)
})
test('summarize: missing serverName rendered explicitly', () => {
assert.equal(
M.summarize({ transport: 'stdio', command: 'npx' }),
'(no serverName) · stdio · npx',
)
})
test('summarize: streamable-http shows serverName + transport + url', () => {
assert.equal(
M.summarize({ transport: 'streamable-http', serverName: 'grafana', url: 'https://x.example/rpc' }),
'grafana · http · https://x.example/rpc',
)
})
test('pack: stdio state omits empty env/args, keeps transport', () => {
const cfg = M.pack({
transport: 'stdio',
serverName: 'github',
command: 'npx',
args: [],
env: {},
cwd: '',
url: '',
headers: {},
})
assert.deepEqual(cfg, {
transport: 'stdio',
serverName: 'github',
command: 'npx',
})
})
test('pack: stdio state preserves args + env when non-empty', () => {
const cfg = M.pack({
transport: 'stdio',
serverName: 'github',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: { GITHUB_TOKEN: 'ghp_x' },
cwd: '/tmp/work',
url: '',
headers: {},
})
assert.deepEqual(cfg, {
transport: 'stdio',
serverName: 'github',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-github'],
env: { GITHUB_TOKEN: 'ghp_x' },
cwd: '/tmp/work',
})
})
test('pack: streamable-http state omits stdio-only fields', () => {
const cfg = M.pack({
transport: 'streamable-http',
serverName: 'grafana',
command: 'npx', // stray from a previous transport — should be dropped
args: ['-y'], // ditto
env: { X: '1' }, // ditto
cwd: '/tmp', // ditto
url: 'https://x.example',
headers: { Authorization: 'Bearer x' },
})
assert.deepEqual(cfg, {
transport: 'streamable-http',
serverName: 'grafana',
url: 'https://x.example',
headers: { Authorization: 'Bearer x' },
})
})
test('pack: empty-string entries in args list are filtered', () => {
const cfg = M.pack({
transport: 'stdio', serverName: 'x', command: 'npx',
args: ['-y', '', 'pkg', ''],
env: {}, cwd: '', url: '', headers: {},
})
assert.deepEqual(cfg.args, ['-y', 'pkg'])
})
test('validate: serverName required', () => {
const v = M.validate({ transport: 'stdio', serverName: '', command: 'npx' })
assert.match(v.error, /serverName is required/)
})
test('validate: serverName charset enforced', () => {
const v = M.validate({ transport: 'stdio', serverName: 'has spaces', command: 'npx' })
assert.match(v.error, /serverName must be/)
})
test('validate: stdio requires command', () => {
const v = M.validate({ transport: 'stdio', serverName: 'gh', command: '' })
assert.match(v.error, /command is required/)
})
test('validate: streamable-http requires url', () => {
const v = M.validate({ transport: 'streamable-http', serverName: 'gh', url: '' })
assert.match(v.error, /url is required/)
})
test('validate: streamable-http url must be http(s)://', () => {
const v = M.validate({ transport: 'streamable-http', serverName: 'gh', url: 'ftp://x' })
assert.match(v.error, /http:\/\/ or https:\/\//)
})
test('validate: happy path (stdio + serverName + command) returns no error', () => {
const v = M.validate({ transport: 'stdio', serverName: 'github', command: 'npx' })
assert.equal(v.error, null)
})
test('validate: happy path (http + serverName + url) returns no error', () => {
const v = M.validate({ transport: 'streamable-http', serverName: 'grafana',
url: 'https://mcp.example.com' })
assert.equal(v.error, null)
})
test('isMcpClientRow: matches @deepseek-ai/dsh-mcp-client', () => {
assert.equal(M.isMcpClientRow({ id: 'mcp-client', name: '@deepseek-ai/dsh-mcp-client' }), true)
})
test('isMcpClientRow: matches local path variants', () => {
assert.equal(M.isMcpClientRow({ id: 'x', name: '../deepseek-harness-dev/packages/mcp/mcp-client/src' }), true)
})
test('isMcpClientRow: does not match unrelated plugins', () => {
assert.equal(M.isMcpClientRow({ id: 'bash', name: '@deepseek-ai/dsh-bash-local' }), false)
assert.equal(M.isMcpClientRow({ id: 'x' }), false)
assert.equal(M.isMcpClientRow(null), false)
})
// DOM smoke test: buildMcpConfigCard should return a
that (a) has
// colSpan=5 on its single cell so the row lines up with the plugins table,
// (b) opens with the details panel expanded, (c) exposes a Save button
// wired to the api.onCommit callback, and (d) surfaces the serverName
// preview in the summary bar once committed.
test('buildMcpConfigCard: renders a wide-row card and wires save/clear', async () => {
const doc = makeStubDoc()
let committed = null
let cleared = false
const api = {
onCommit: async (cfg) => { committed = cfg },
onClear: async () => { cleared = true },
}
const tr = M.buildMcpConfigCard(doc, {
id: 'gh-mcp',
name: '@deepseek-ai/dsh-mcp-client',
disabled: false,
source: 'user',
config: { transport: 'stdio', serverName: 'github', command: 'npx' },
}, api)
assert.equal(tr.tagName, 'tr')
const td = tr.children[0]
assert.equal(td.tagName, 'td')
assert.equal(td.colSpan, 5)
const details = td.children[0]
assert.equal(details.tagName, 'details')
assert.equal(details.open, true)
// Restart-required badge must be present so the user knows Apply-restart
// is required for the change to take effect.
const badge = findByClass(details, 'mcp-config-restart-badge')
assert.ok(badge, 'restart-required badge should render')
// The Save button starts disabled (nothing dirty yet); flipping the
// command input to a new value should enable it, and clicking it should
// invoke api.onCommit with the packed config.
const saveBtn = findByClass(details, 'mcp-config-save')
assert.ok(saveBtn)
assert.equal(saveBtn.disabled, true)
// Simulate the user typing a new command; input handlers wire dirty().
const cmdInput = findInputByPlaceholder(details, /npx, node, python/)
cmdInput.value = 'python -m server'
cmdInput.dispatchEvent({ type: 'input' })
assert.equal(saveBtn.disabled, false, 'save should enable after edit')
await clickAndAwait(saveBtn)
assert.ok(committed, 'onCommit should have fired')
assert.equal(committed.command, 'python -m server')
assert.equal(committed.serverName, 'github')
assert.equal(committed.transport, 'stdio')
// Clear invokes onClear.
const clearBtn = findByClass(details, 'mcp-config-clear')
await clickAndAwait(clearBtn)
assert.equal(cleared, true)
})
test('buildMcpConfigCard: streamable-http transport hides stdio fields', () => {
const doc = makeStubDoc()
const tr = M.buildMcpConfigCard(doc, {
id: 'grafana', name: '@deepseek-ai/dsh-mcp-client', disabled: false, source: 'user',
config: { transport: 'streamable-http', serverName: 'grafana',
url: 'https://mcp.example.com' },
}, { onCommit: async () => {}, onClear: async () => {} })
const details = tr.children[0].children[0]
// Command placeholder must NOT be present when we're in streamable-http.
const cmdInput = findInputByPlaceholder(details, /npx, node, python/, { optional: true })
assert.equal(cmdInput, null, 'stdio-only command field should be absent')
// URL placeholder must be present.
const urlInput = findInputByPlaceholder(details, /mcp.example.com/)
assert.ok(urlInput, 'streamable-http url field should be present')
})
test('buildMcpConfigCard: refuses to save when validation fails', async () => {
const doc = makeStubDoc()
let committed = null
const api = { onCommit: async (cfg) => { committed = cfg }, onClear: async () => {} }
const tr = M.buildMcpConfigCard(doc, {
id: 'x', name: '@deepseek-ai/dsh-mcp-client', disabled: false, source: 'user',
config: { transport: 'stdio' }, // deliberately empty serverName + command
}, api)
const details = tr.children[0].children[0]
const saveBtn = findByClass(details, 'mcp-config-save')
// Bump one input to dirty the state.
const nameInput = findInputByPlaceholder(details, /github, everything/)
nameInput.value = ''
nameInput.dispatchEvent({ type: 'input' })
// Also dirty the command so save is unblocked from the pure-dirty check,
// but leave serverName blank so validation trips.
const cmdInput = findInputByPlaceholder(details, /npx, node, python/)
cmdInput.value = 'npx'
cmdInput.dispatchEvent({ type: 'input' })
saveBtn.disabled = false
await clickAndAwait(saveBtn)
assert.equal(committed, null, 'commit should NOT fire when serverName is blank')
const status = findByClass(details, 'mcp-config-status')
assert.match(status.textContent, /serverName is required/)
})
// ---- tiny DOM stub -------------------------------------------------------
// Just enough to run buildMcpConfigCard end-to-end under node --test. We
// stay conservative: only the DOM surface the card touches is implemented.
function makeStubDoc() {
return { createElement: (tag) => makeStubEl(tag) }
}
function makeStubEl(tag) {
const listeners = new Map()
const el = {
tagName: tag,
children: [],
classList: {
_set: new Set(),
add: (c) => el.classList._set.add(c),
remove: (c) => el.classList._set.delete(c),
contains: (c) => el.classList._set.has(c),
toggle: (c, on) => on ? el.classList.add(c) : el.classList.remove(c),
},
dataset: {},
style: {},
_events: listeners,
appendChild(child) { this.children.push(child); child.parent = this; return child },
insertBefore(child, ref) {
const idx = this.children.indexOf(ref)
if (idx < 0) this.children.push(child)
else this.children.splice(idx, 0, child)
child.parent = this
return child
},
addEventListener(type, cb) {
if (!listeners.has(type)) listeners.set(type, [])
listeners.get(type).push(cb)
},
dispatchEvent(ev) {
const cbs = listeners.get(ev.type) || []
for (const cb of cbs) cb(ev)
},
setAttribute(k, v) { el[k] = v },
getAttribute(k) { return el[k] },
querySelector: (_sel) => null,
querySelectorAll: (_sel) => [],
focus() {},
click() { el.dispatchEvent({ type: 'click' }) },
get className() { return Array.from(this.classList._set).join(' ') },
set className(v) {
this.classList._set = new Set(String(v || '').split(/\s+/).filter(Boolean))
},
}
// A shadow field so `el.textContent = 'x'` and `el.innerHTML = ''` both
// work minimally; setting innerHTML to '' also clears children so the
// list/map field repaint loops can reset the body.
Object.defineProperty(el, 'innerHTML', {
get() { return el._innerHTML || '' },
set(v) { el._innerHTML = v; if (v === '') el.children.length = 0 },
})
Object.defineProperty(el, 'textContent', {
get() { return el._textContent || '' },
set(v) { el._textContent = String(v) },
})
return el
}
function findByClass(root, cls) {
if (root.classList && root.classList.contains(cls)) return root
for (const c of root.children || []) {
const hit = findByClass(c, cls)
if (hit) return hit
}
return null
}
function findInputByPlaceholder(root, re, opts = {}) {
const stack = [root]
while (stack.length) {
const n = stack.shift()
if (n.tagName === 'input' && re.test(n.placeholder || '')) return n
for (const c of n.children || []) stack.push(c)
}
if (opts.optional) return null
return null
}
async function clickAndAwait(btn) {
btn.dispatchEvent({ type: 'click' })
// Two microtasks to drain both the button handler and the awaited api call.
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
}