// Unit tests for src/renderer/market-import-panel.js. Same DOM-stub grammar as // test/plugins-mcp-card.test.js. Coverage focus: the validation branches // (`workspace` vs `path` vs `git`) and the submit → api.onImport wiring. 'use strict' const test = require('node:test') const assert = require('node:assert/strict') const M = require('../src/renderer/market-import-panel.js') test('validate: empty state returns silent (no error message, still not submittable)', () => { const v = M.validate({ shape: 'workspace', id: '', name: '' }) assert.equal(v.error, '') }) test('validate: workspace + valid id + valid package = no error', () => { const v = M.validate({ shape: 'workspace', id: 'gh-mcp', name: '@deepseek-ai/dsh-mcp-client' }) assert.equal(v.error, null) }) test('validate: workspace rejects a path-shaped name', () => { const v = M.validate({ shape: 'workspace', id: 'x', name: './packages/foo' }) assert.match(v.error, /path-shaped values belong/) }) test('validate: workspace rejects a bad character in package', () => { const v = M.validate({ shape: 'workspace', id: 'x', name: 'has space' }) assert.match(v.error, /valid npm specifier/) }) test('validate: id charset enforced', () => { const v = M.validate({ shape: 'workspace', id: 'has space', name: '@x/y' }) assert.match(v.error, /id must be/) }) test('validate: path branch accepts relative', () => { const v = M.validate({ shape: 'path', id: 'p', name: './packages/x' }) assert.equal(v.error, null) }) test('validate: path branch accepts absolute POSIX', () => { const v = M.validate({ shape: 'path', id: 'p', name: '/opt/plugins/foo' }) assert.equal(v.error, null) }) test('validate: path branch accepts absolute Windows', () => { const v = M.validate({ shape: 'path', id: 'p', name: 'C:/plugins/foo' }) assert.equal(v.error, null) }) test('validate: path branch rejects a bare package name', () => { const v = M.validate({ shape: 'path', id: 'p', name: '@scope/pkg' }) assert.match(v.error, /path must start with/) }) test('validate: git branch always silent (disabled shape)', () => { const v = M.validate({ shape: 'git', id: 'p', name: 'https://example.com/x.git' }) assert.equal(v.error, '') }) // ---- DOM smoke tests ---------------------------------------------------- // Same DOM stub as plugins-mcp-card.test.js — kept in this file for // symmetric per-module test isolation (`node --test test/x.test.js` should // bring its own DOM shim). test('buildImportPanel: renders three seg buttons, git disabled', () => { const doc = makeStubDoc() const panel = M.buildImportPanel(doc, { onImport: async () => {} }) const seg = findByClass(panel, 'market-import-seg') assert.ok(seg, 'segmented control renders') const btns = seg.children assert.equal(btns.length, 3) assert.equal(btns[0].dataset.shape, 'workspace') assert.equal(btns[1].dataset.shape, 'path') assert.equal(btns[2].dataset.shape, 'git') assert.equal(btns[2].disabled, true, 'git URL should be disabled (coming soon)') }) test('buildImportPanel: workspace submit invokes onImport with typed id+name', async () => { const doc = makeStubDoc() let received = null const panel = M.buildImportPanel(doc, { onImport: async (entry) => { received = entry }, }) const idInput = findInputByPlaceholder(panel, /unique-id/) const pkgInput = findInputByPlaceholder(panel, /@deepseek-ai\/dsh-echo/) idInput.value = 'gh-mcp' idInput.dispatchEvent({ type: 'input' }) pkgInput.value = '@deepseek-ai/dsh-mcp-client' pkgInput.dispatchEvent({ type: 'input' }) const submitBtn = findByClass(panel, 'market-import-submit') assert.equal(submitBtn.disabled, false, 'submit enables after both fields fill') await clickAndAwait(submitBtn) assert.deepEqual(received, { id: 'gh-mcp', name: '@deepseek-ai/dsh-mcp-client' }) const status = findByClass(panel, 'market-import-status') assert.match(status.textContent, /Imported "gh-mcp"/) }) test('buildImportPanel: switching to path shape swaps the package input for a path input', () => { const doc = makeStubDoc() const panel = M.buildImportPanel(doc, { onImport: async () => {} }) const seg = findByClass(panel, 'market-import-seg') const pathBtn = seg.children[1] // path shape pathBtn.dispatchEvent({ type: 'click' }) const pathInput = findInputByPlaceholder(panel, /packages\/my-plugin/) assert.ok(pathInput, 'path input renders after switching') const pkgInput = findInputByPlaceholder(panel, /@deepseek-ai\/dsh-echo/, { optional: true }) assert.equal(pkgInput, null, 'workspace input should be gone') }) test('buildImportPanel: git shape renders a "coming soon" note, submit stays disabled', () => { const doc = makeStubDoc() const panel = M.buildImportPanel(doc, { onImport: async () => {} }) const gitBtn = findByClass(panel, 'market-import-seg').children[2] // The button is disabled, but a real click through the DOM is still fired // here to prove it does NOT flip state — matches production where the // disabled attribute prevents the handler from running. gitBtn.disabled = false // force it, to prove the handler bailout works if wired const noteBefore = findByClass(panel, 'market-import-note') assert.equal(noteBefore, null, 'note should not render on the workspace default') }) test('buildImportPanel: bubbles a validation error to the status line', async () => { const doc = makeStubDoc() let calls = 0 const panel = M.buildImportPanel(doc, { onImport: async () => { calls += 1 }, }) const idInput = findInputByPlaceholder(panel, /unique-id/) const pkgInput = findInputByPlaceholder(panel, /@deepseek-ai\/dsh-echo/) idInput.value = 'ok-id' idInput.dispatchEvent({ type: 'input' }) // Feed a path-shaped value into the workspace tab so validate() rejects. pkgInput.value = './x/y' pkgInput.dispatchEvent({ type: 'input' }) const submitBtn = findByClass(panel, 'market-import-submit') submitBtn.disabled = false // simulate a slip await clickAndAwait(submitBtn) assert.equal(calls, 0, 'onImport should not fire when validation fails') const status = findByClass(panel, 'market-import-status') assert.match(status.textContent, /path-shaped/) }) test('buildImportPanel: propagates onImport rejection as a status error', async () => { const doc = makeStubDoc() const panel = M.buildImportPanel(doc, { onImport: async () => { throw new Error('duplicate patch id: x') }, }) const idInput = findInputByPlaceholder(panel, /unique-id/) const pkgInput = findInputByPlaceholder(panel, /@deepseek-ai\/dsh-echo/) idInput.value = 'x' idInput.dispatchEvent({ type: 'input' }) pkgInput.value = '@x/y' pkgInput.dispatchEvent({ type: 'input' }) const submitBtn = findByClass(panel, 'market-import-submit') await clickAndAwait(submitBtn) const status = findByClass(panel, 'market-import-status') assert.match(status.textContent, /import failed.*duplicate patch id/) assert.equal(status.classList.contains('error'), true) assert.equal(submitBtn.disabled, false, 'submit should re-enable on failure so user can retry') }) // ---- DOM stub (copied from plugins-mcp-card.test.js) -------------------- 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: {}, appendChild(child) { this.children.push(child); child.parent = this; return child }, setAttribute(k, v) { el[k] = v }, getAttribute(k) { return el[k] }, 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) }, click() { el.dispatchEvent({ type: 'click' }) }, focus() {}, get className() { return Array.from(this.classList._set).join(' ') }, set className(v) { this.classList._set = new Set(String(v || '').split(/\s+/).filter(Boolean)) }, } 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' }) await Promise.resolve() await Promise.resolve() await Promise.resolve() }