Minimal Electron shell over the DSH JSON-RPC runtime — a first-look at what a ChatGPT.app-style host on top of the DeepSeek Harness looks like, with the harness's normally-invisible internals (trace timeline, context surface, subagent tree, compaction, plugin registry, rubrics) brought forward as first-class UI surfaces so plugin authors and researchers can see what the agent is actually doing. Runs against three keyless-to-live profiles (stdio-echo works on master out of the box; daemon-echo / daemon-vibe-echo activate once the daemon-demo lands; stdio-deepseek and daemon-vibe hit the real DeepSeek API when you supply a key). HARNESS_DEV auto-resolves to the in-repo runtime when this shell ships under examples/desktop/, so a fresh clone launches without config; env DSH_DEV_ROOT overrides for custom layouts, and a sibling deepseek-harness-dev/ checkout is the original dev workflow. Cold-clone gate (P0 fixes for first-time-clone usability): - HARNESS_DEV: 3-candidate resolver (env → walk-up in-repo marker → sibling), unit-tested via mock fs so ordering is locked without needing either real layout on disk. - config yml leaves rewritten at assemble time so the sibling-clone paths (../../deepseek-harness-dev/examples/echo-agent/…) become the in-repo paths (../../echo-agent/…) in the released tree — source yml stays usable for local dev, released tree ships a working shape. - pnpm-workspace.yaml allowBuilds.electron = true (was placeholder). - missing-key card in stdio-deepseek offers a one-click switch to stdio-echo (the keyless profile that works on master) rather than daemon-echo (blocked on the not-yet-shipped daemon-demo). - assemble-oss-release.sh rewrites the source-side breadcrumb name 'dsh-desktop-demo' → 'dsh-desktop' for the released package.json. FOUC guard on the onboarding gate (41fc5df carried) keeps the first-launch splash from flashing before the runtime probe finishes. Test suite (1634 tests in source, 3990 in the runtime repo) covers resolver ordering, renderer classifiers, trace timeline shape, compaction diff rendering, rubric parity, and the missing-key onboarding paths.
465 lines
17 KiB
JavaScript
465 lines
17 KiB
JavaScript
// Unit tests for src/main/plugins.js — the pure overlay parser + role
|
|
// template resolver. Runs under `node --test`; no Electron or fs mocking
|
|
// beyond writing into a scoped temp dir for the shell-home helpers.
|
|
|
|
'use strict'
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const os = require('node:os')
|
|
const path = require('node:path')
|
|
|
|
const P = require('../src/main/plugins.js')
|
|
|
|
// Strip the `line` annotation parseOverlay tags each patch with so shape
|
|
// assertions can compare against the plain UI-facing patch shape. The tag is
|
|
// consumed by plugin-validation, not by consumers who write overlays back.
|
|
function stripLine(patch) {
|
|
const { line: _line, ...rest } = patch
|
|
return rest
|
|
}
|
|
|
|
const SAMPLE_BASE = `# example leaf
|
|
- id: mock-llm
|
|
name: '../../deepseek-harness-dev/examples/echo-agent/src/mock-llm.ts'
|
|
|
|
- id: echo-tool
|
|
name: '../../deepseek-harness-dev/examples/echo-agent/src/echo-tool.ts'
|
|
|
|
- id: bash
|
|
name: '@deepseek-ai/dsh-bash-local'
|
|
|
|
- id: session-query
|
|
name: '@deepseek-ai/dsh-session-query'
|
|
|
|
- id: daemon-agent
|
|
name: '@deepseek-ai/dsh-daemon-demo'
|
|
config:
|
|
socketPath: !!js process.env.DSH_DAEMON_SOCKET_PATH
|
|
persona: 'You are a mock daemon agent.'
|
|
`
|
|
|
|
test('parseBaseEntries pulls id/name pairs, ignores config bodies', () => {
|
|
const entries = P.parseBaseEntries(SAMPLE_BASE)
|
|
assert.deepEqual(entries.map((e) => e.id), [
|
|
'mock-llm', 'echo-tool', 'bash', 'session-query', 'daemon-agent',
|
|
])
|
|
assert.equal(entries.find((e) => e.id === 'bash').name, '@deepseek-ai/dsh-bash-local')
|
|
assert.equal(entries.find((e) => e.id === 'daemon-agent').name, '@deepseek-ai/dsh-daemon-demo')
|
|
})
|
|
|
|
test('parseOverlay pulls base path and patches', () => {
|
|
const text = `- id: base
|
|
name: '@cordisjs/plugin-include'
|
|
config:
|
|
path: ../config/daemon-echo.yml
|
|
patches:
|
|
- id: bash
|
|
disabled: true
|
|
- id: my-plugin
|
|
name: '@example/some-plugin'
|
|
insert: append
|
|
`
|
|
const parsed = P.parseOverlay(text)
|
|
assert.equal(parsed.base, '../config/daemon-echo.yml')
|
|
assert.equal(parsed.patches.length, 2)
|
|
// parseOverlay tags patches with `line` for validator anchoring; strip it
|
|
// for shape-equality against the shape the tab writes.
|
|
const [p0, p1] = parsed.patches.map(stripLine)
|
|
assert.deepEqual(p0, { id: 'bash', disabled: true })
|
|
assert.deepEqual(p1, { id: 'my-plugin', name: '@example/some-plugin', insert: 'append' })
|
|
})
|
|
|
|
test('renderOverlay round-trips through parseOverlay', () => {
|
|
const overlay = {
|
|
base: '../config/daemon-echo.yml',
|
|
patches: [
|
|
{ id: 'bash', disabled: true },
|
|
{ id: 'custom-plugin', name: '@x/plugin', insert: 'append' },
|
|
],
|
|
}
|
|
const text = P.renderOverlay(overlay)
|
|
const reparsed = P.parseOverlay(text)
|
|
assert.equal(reparsed.base, overlay.base)
|
|
assert.equal(reparsed.patches.length, 2)
|
|
assert.deepEqual(stripLine(reparsed.patches[0]), overlay.patches[0])
|
|
assert.deepEqual(stripLine(reparsed.patches[1]), overlay.patches[1])
|
|
})
|
|
|
|
test('computeEffective folds base + patches into a UI-ready list', () => {
|
|
const base = P.parseBaseEntries(SAMPLE_BASE)
|
|
const patches = [
|
|
{ id: 'bash', disabled: true },
|
|
{ id: 'custom', name: '@x/custom', insert: 'append' },
|
|
]
|
|
const eff = P.computeEffective(base, patches)
|
|
assert.equal(eff.length, base.length + 1)
|
|
const bash = eff.find((e) => e.id === 'bash')
|
|
assert.equal(bash.disabled, true)
|
|
assert.equal(bash.source, 'base')
|
|
const custom = eff.find((e) => e.id === 'custom')
|
|
assert.equal(custom.name, '@x/custom')
|
|
assert.equal(custom.disabled, false)
|
|
assert.equal(custom.source, 'user')
|
|
})
|
|
|
|
test('togglePatch: adds a new disabling patch when none exists', () => {
|
|
const overlay = { base: 'x.yml', patches: [] }
|
|
const next = P.togglePatch(overlay, 'bash', true)
|
|
assert.equal(next.patches.length, 1)
|
|
assert.deepEqual(next.patches[0], { id: 'bash', disabled: true })
|
|
})
|
|
|
|
test('togglePatch: updates existing patch in place', () => {
|
|
const overlay = { base: 'x.yml', patches: [{ id: 'bash', disabled: true }] }
|
|
const next = P.togglePatch(overlay, 'bash', true)
|
|
assert.deepEqual(next.patches, [{ id: 'bash', disabled: true }])
|
|
})
|
|
|
|
test('togglePatch: drops an empty patch when re-enabling with no other fields', () => {
|
|
const overlay = { base: 'x.yml', patches: [{ id: 'bash', disabled: true }] }
|
|
const next = P.togglePatch(overlay, 'bash', false)
|
|
assert.equal(next.patches.length, 0)
|
|
})
|
|
|
|
test('togglePatch: preserves other fields when re-enabling a user-added plugin', () => {
|
|
const overlay = { base: 'x.yml', patches: [{ id: 'custom', name: '@x/y', insert: 'append', disabled: true }] }
|
|
const next = P.togglePatch(overlay, 'custom', false)
|
|
assert.equal(next.patches.length, 1)
|
|
assert.deepEqual(next.patches[0], { id: 'custom', name: '@x/y', insert: 'append', disabled: false })
|
|
})
|
|
|
|
test('addPatch: rejects duplicates', () => {
|
|
const overlay = { base: 'x.yml', patches: [{ id: 'a', name: '@x/a', insert: 'append' }] }
|
|
assert.throws(() => P.addPatch(overlay, { id: 'a', name: '@x/other' }), /duplicate patch id/)
|
|
})
|
|
|
|
test('addPatch: appends a new user entry with insert default', () => {
|
|
const overlay = { base: 'x.yml', patches: [] }
|
|
const next = P.addPatch(overlay, { id: 'a', name: '@x/a' })
|
|
assert.deepEqual(next.patches[0], { id: 'a', name: '@x/a', insert: 'append' })
|
|
})
|
|
|
|
test('applyRoleTemplate: coding gets no patches, research disables bash', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-plugins-'))
|
|
try {
|
|
const basePath = path.join(home, 'base.yml')
|
|
const overlayP = path.join(home, 'overlay.yml')
|
|
fs.writeFileSync(basePath, SAMPLE_BASE)
|
|
const coding = P.applyRoleTemplate('coding', 'ask', basePath, overlayP)
|
|
assert.equal(coding.overlay.patches.length, 0)
|
|
assert.match(coding.overlay.base, /base\.yml$/)
|
|
|
|
const research = P.applyRoleTemplate('research', 'auto', basePath, overlayP)
|
|
assert.ok(research.overlay.patches.find((p) => p.id === 'bash' && p.disabled === true))
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('applyRoleTemplate: rejects unknown role or mode', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-plugins-'))
|
|
try {
|
|
const basePath = path.join(home, 'base.yml')
|
|
fs.writeFileSync(basePath, SAMPLE_BASE)
|
|
assert.throws(() => P.applyRoleTemplate('bogus', 'ask', basePath, path.join(home, 'o.yml')), /unknown role/)
|
|
assert.throws(() => P.applyRoleTemplate('coding', 'bogus', basePath, path.join(home, 'o.yml')), /unknown approval mode/)
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('readOverlayFile: missing file → empty overlay', () => {
|
|
const overlay = P.readOverlayFile('/definitely/does/not/exist.yml')
|
|
assert.deepEqual(overlay, { base: '', patches: [] })
|
|
})
|
|
|
|
test('writeOverlayFile + readOverlayFile round-trip on disk', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-plugins-'))
|
|
try {
|
|
const overlayP = path.join(home, 'sub', 'user-overlay.cordis.yml')
|
|
const overlay = { base: '../config/daemon-echo.yml', patches: [{ id: 'bash', disabled: true }] }
|
|
P.writeOverlayFile(overlayP, overlay)
|
|
const reread = P.readOverlayFile(overlayP)
|
|
assert.equal(reread.base, overlay.base)
|
|
assert.deepEqual(reread.patches.map(stripLine), overlay.patches)
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
test('shell-home helpers respect DSH_DESKTOP_HOME', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-plugins-'))
|
|
const prev = process.env.DSH_DESKTOP_HOME
|
|
process.env.DSH_DESKTOP_HOME = home
|
|
try {
|
|
assert.equal(P.shellHome(), home)
|
|
assert.equal(P.shellHomeExists(), true) // mkdtempSync created it
|
|
P.writeShellConfig({ role: 'coding', approvalMode: 'ask', createdAt: 123 })
|
|
assert.deepEqual(P.readShellConfig(), { role: 'coding', approvalMode: 'ask', createdAt: 123 })
|
|
} finally {
|
|
if (prev === undefined) delete process.env.DSH_DESKTOP_HOME
|
|
else process.env.DSH_DESKTOP_HOME = prev
|
|
fs.rmSync(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
// A-P0-1 fix (2026-07-16): firstRun used to key off "config.json exists",
|
|
// which auto-materialized before the wizard could show, skipping onboarding
|
|
// 100% of the time. We now key off an explicit sentinel that only the
|
|
// wizard's completion path writes.
|
|
test('onboarded sentinel: fresh home has no sentinel, mark/clear flip it', () => {
|
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-onboarded-'))
|
|
const prev = process.env.DSH_DESKTOP_HOME
|
|
process.env.DSH_DESKTOP_HOME = home
|
|
try {
|
|
// A fresh dir with no sentinel — the wizard should fire.
|
|
assert.equal(P.onboardedSentinelExists(), false)
|
|
// Writing config.json alone must NOT flip firstRun off.
|
|
P.writeShellConfig({ role: 'coding', approvalMode: 'ask', createdAt: 1 })
|
|
assert.equal(P.onboardedSentinelExists(), false,
|
|
'writing config.json should not create the sentinel')
|
|
// markOnboarded should create the sentinel; clearOnboarded should remove
|
|
// it so Reset onboarding reliably re-triggers the wizard next boot.
|
|
P.markOnboarded()
|
|
assert.equal(P.onboardedSentinelExists(), true)
|
|
P.clearOnboarded()
|
|
assert.equal(P.onboardedSentinelExists(), false)
|
|
// clearOnboarded on an already-clean home is a no-op.
|
|
P.clearOnboarded()
|
|
assert.equal(P.onboardedSentinelExists(), false)
|
|
} finally {
|
|
if (prev === undefined) delete process.env.DSH_DESKTOP_HOME
|
|
else process.env.DSH_DESKTOP_HOME = prev
|
|
fs.rmSync(home, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Task #49 (MCP frontend delivery batch, 2026-07-17): the audit at
|
|
// docs/plugin-mcp-audit.md §4 said addPatch already accepts `config`, but the
|
|
// source only carried the shape in JSDoc — parseOverlay / renderOverlay /
|
|
// addPatch all silently dropped it. These tests pin the round-trip now that
|
|
// the parser stack understands the shape.
|
|
|
|
test('parseOverlay: captures stdio mcp-client config as a shallow object', () => {
|
|
const text = `- id: base
|
|
name: '@cordisjs/plugin-include'
|
|
config:
|
|
path: ../config/daemon-echo.yml
|
|
patches:
|
|
- id: gh-mcp
|
|
name: '@deepseek-ai/dsh-mcp-client'
|
|
insert: append
|
|
config:
|
|
transport: "stdio"
|
|
serverName: "github"
|
|
command: "npx"
|
|
args:
|
|
- "@modelcontextprotocol/server-github"
|
|
env:
|
|
GITHUB_TOKEN: "ghp_fixture"
|
|
`
|
|
const parsed = P.parseOverlay(text)
|
|
assert.equal(parsed.patches.length, 1)
|
|
const p = parsed.patches[0]
|
|
assert.equal(p.id, 'gh-mcp')
|
|
assert.equal(p.name, '@deepseek-ai/dsh-mcp-client')
|
|
assert.equal(p.insert, 'append')
|
|
assert.ok(p.config, 'config should be parsed')
|
|
assert.equal(p.config.transport, 'stdio')
|
|
assert.equal(p.config.serverName, 'github')
|
|
assert.equal(p.config.command, 'npx')
|
|
assert.deepEqual(p.config.args, ['@modelcontextprotocol/server-github'])
|
|
assert.deepEqual(p.config.env, { GITHUB_TOKEN: 'ghp_fixture' })
|
|
})
|
|
|
|
test('parseOverlay: captures streamable-http mcp-client config with headers map', () => {
|
|
const text = `- id: base
|
|
name: '@cordisjs/plugin-include'
|
|
config:
|
|
path: base.yml
|
|
patches:
|
|
- id: http-mcp
|
|
name: '@deepseek-ai/dsh-mcp-client'
|
|
config:
|
|
transport: "streamable-http"
|
|
serverName: "grafana"
|
|
url: "https://mcp.example.com/rpc"
|
|
headers:
|
|
Authorization: "Bearer secret"
|
|
X-Trace-Id: "abc123"
|
|
`
|
|
const parsed = P.parseOverlay(text)
|
|
const p = parsed.patches[0]
|
|
assert.equal(p.config.transport, 'streamable-http')
|
|
assert.equal(p.config.serverName, 'grafana')
|
|
assert.equal(p.config.url, 'https://mcp.example.com/rpc')
|
|
assert.deepEqual(p.config.headers, {
|
|
Authorization: 'Bearer secret',
|
|
'X-Trace-Id': 'abc123',
|
|
})
|
|
})
|
|
|
|
test('parseOverlay: config-body flush does not swallow following patch', () => {
|
|
const text = `- id: base
|
|
name: '@cordisjs/plugin-include'
|
|
config:
|
|
path: base.yml
|
|
patches:
|
|
- id: first
|
|
name: '@x/first'
|
|
config:
|
|
key: "value"
|
|
- id: second
|
|
name: '@x/second'
|
|
disabled: true
|
|
`
|
|
const parsed = P.parseOverlay(text)
|
|
assert.equal(parsed.patches.length, 2)
|
|
assert.equal(parsed.patches[0].id, 'first')
|
|
assert.deepEqual(parsed.patches[0].config, { key: 'value' })
|
|
assert.equal(parsed.patches[1].id, 'second')
|
|
assert.equal(parsed.patches[1].disabled, true)
|
|
})
|
|
|
|
test('renderOverlay: emits nested config with env/args nested blocks', () => {
|
|
const overlay = {
|
|
base: 'base.yml',
|
|
patches: [{
|
|
id: 'gh-mcp',
|
|
name: '@deepseek-ai/dsh-mcp-client',
|
|
insert: 'append',
|
|
config: {
|
|
transport: 'stdio',
|
|
serverName: 'github',
|
|
command: 'npx',
|
|
args: ['@modelcontextprotocol/server-github'],
|
|
env: { GITHUB_TOKEN: 'ghp_fixture' },
|
|
},
|
|
}],
|
|
}
|
|
const text = P.renderOverlay(overlay)
|
|
assert.match(text, /transport: "stdio"/)
|
|
assert.match(text, /serverName: "github"/)
|
|
assert.match(text, /args:\n {12}- "@modelcontextprotocol\/server-github"/)
|
|
assert.match(text, /env:\n {12}GITHUB_TOKEN: "ghp_fixture"/)
|
|
})
|
|
|
|
test('renderOverlay + parseOverlay round-trip: mcp-client config survives', () => {
|
|
const overlay = {
|
|
base: 'base.yml',
|
|
patches: [
|
|
{ id: 'bash', disabled: true },
|
|
{
|
|
id: 'gh-mcp',
|
|
name: '@deepseek-ai/dsh-mcp-client',
|
|
insert: 'append',
|
|
config: {
|
|
transport: 'stdio',
|
|
serverName: 'github',
|
|
command: 'npx',
|
|
args: ['-y', '@modelcontextprotocol/server-github'],
|
|
env: { GITHUB_TOKEN: 'ghp_x' },
|
|
},
|
|
},
|
|
{
|
|
id: 'http-mcp',
|
|
name: '@deepseek-ai/dsh-mcp-client',
|
|
config: {
|
|
transport: 'streamable-http',
|
|
serverName: 'grafana',
|
|
url: 'https://mcp.example.com',
|
|
headers: { Authorization: 'Bearer x' },
|
|
},
|
|
},
|
|
],
|
|
}
|
|
const text = P.renderOverlay(overlay)
|
|
const reparsed = P.parseOverlay(text)
|
|
assert.equal(reparsed.patches.length, 3)
|
|
assert.equal(reparsed.patches[0].disabled, true)
|
|
const gh = reparsed.patches.find((p) => p.id === 'gh-mcp')
|
|
assert.deepEqual(gh.config.args, ['-y', '@modelcontextprotocol/server-github'])
|
|
assert.deepEqual(gh.config.env, { GITHUB_TOKEN: 'ghp_x' })
|
|
const http = reparsed.patches.find((p) => p.id === 'http-mcp')
|
|
assert.deepEqual(http.config.headers, { Authorization: 'Bearer x' })
|
|
})
|
|
|
|
test('addPatch: accepts a config sub-object and preserves it', () => {
|
|
const overlay = { base: 'x.yml', patches: [] }
|
|
const next = P.addPatch(overlay, {
|
|
id: 'gh-mcp',
|
|
name: '@deepseek-ai/dsh-mcp-client',
|
|
config: { transport: 'stdio', serverName: 'github' },
|
|
})
|
|
assert.deepEqual(next.patches[0].config, { transport: 'stdio', serverName: 'github' })
|
|
})
|
|
|
|
test('setPatchConfig: seeds a fresh patch when the row has no patch yet', () => {
|
|
const overlay = { base: 'x.yml', patches: [] }
|
|
const next = P.setPatchConfig(overlay, 'mcp-client', {
|
|
transport: 'stdio', serverName: 'github',
|
|
})
|
|
assert.equal(next.patches.length, 1)
|
|
assert.equal(next.patches[0].id, 'mcp-client')
|
|
assert.deepEqual(next.patches[0].config, { transport: 'stdio', serverName: 'github' })
|
|
})
|
|
|
|
test('setPatchConfig: overwrites existing config in place', () => {
|
|
const overlay = { base: 'x.yml', patches: [{
|
|
id: 'mcp-client', name: '@deepseek-ai/dsh-mcp-client',
|
|
config: { transport: 'stdio', serverName: 'old' },
|
|
}] }
|
|
const next = P.setPatchConfig(overlay, 'mcp-client', {
|
|
transport: 'streamable-http', serverName: 'new', url: 'https://x/y',
|
|
})
|
|
assert.equal(next.patches.length, 1)
|
|
assert.deepEqual(next.patches[0].config, {
|
|
transport: 'streamable-http', serverName: 'new', url: 'https://x/y',
|
|
})
|
|
assert.equal(next.patches[0].name, '@deepseek-ai/dsh-mcp-client')
|
|
})
|
|
|
|
test('setPatchConfig: clearing config drops a patch that carries nothing else', () => {
|
|
const overlay = { base: 'x.yml', patches: [{
|
|
id: 'seeded', config: { serverName: 'x' },
|
|
}] }
|
|
const next = P.setPatchConfig(overlay, 'seeded', null)
|
|
assert.equal(next.patches.length, 0)
|
|
})
|
|
|
|
test('setPatchConfig: clearing keeps a patch that has other fields', () => {
|
|
const overlay = { base: 'x.yml', patches: [{
|
|
id: 'x', disabled: true, config: { k: 'v' },
|
|
}] }
|
|
const next = P.setPatchConfig(overlay, 'x', null)
|
|
assert.equal(next.patches.length, 1)
|
|
assert.equal(next.patches[0].disabled, true)
|
|
assert.ok(!next.patches[0].config)
|
|
})
|
|
|
|
test('togglePatch: re-enabling a config-only patch keeps the patch (config not lost)', () => {
|
|
const overlay = { base: 'x.yml', patches: [{
|
|
id: 'mcp-client', disabled: true, config: { serverName: 'gh' },
|
|
}] }
|
|
const next = P.togglePatch(overlay, 'mcp-client', false)
|
|
assert.equal(next.patches.length, 1)
|
|
assert.equal(next.patches[0].disabled, false)
|
|
assert.deepEqual(next.patches[0].config, { serverName: 'gh' })
|
|
})
|
|
|
|
test('computeEffective: surfaces config on the user row for the plugin UI', () => {
|
|
const base = P.parseBaseEntries(SAMPLE_BASE)
|
|
const patches = [
|
|
{ id: 'gh-mcp', name: '@deepseek-ai/dsh-mcp-client',
|
|
config: { transport: 'stdio', serverName: 'github' } },
|
|
]
|
|
const eff = P.computeEffective(base, patches)
|
|
const row = eff.find((e) => e.id === 'gh-mcp')
|
|
assert.ok(row)
|
|
assert.equal(row.source, 'user')
|
|
assert.deepEqual(row.config, { transport: 'stdio', serverName: 'github' })
|
|
})
|