import { afterEach, describe, expect, test } from 'bun:test' import { startFakeRegistry } from '../helpers/fake-registry' import { runCli } from '../helpers/run-cli' let registry: Awaited> | undefined afterEach(() => { registry?.stop() registry = undefined }) describe('search command', () => { test('--token sends bearer auth and takes priority over SKILLHUB_TOKEN', async () => { let capturedAuth = '' const server = Bun.serve({ port: 0, fetch(req) { const url = new URL(req.url) if (url.pathname === '/api/cli/v1/skills/search') { capturedAuth = req.headers.get('authorization') ?? '' return Response.json({ code: 0, data: { items: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }], total: 1, limit: 20 } }) } return Response.json({ code: 404 }, { status: 404 }) } }) try { const result = await runCli( ['search', 'pdf', '--registry', `http://localhost:${server.port}`, '--token', 'sk_ok'], { SKILLHUB_TOKEN: 'sk_bad' } ) expect(result.exitCode).toBe(0) expect(capturedAuth).toBe('Bearer sk_ok') expect(result.stdout).toContain('global/pdf-parser') } finally { server.stop() } }) test('bad --token fails with auth output and does not retry anonymously', async () => { const authHeaders: Array = [] const server = Bun.serve({ port: 0, fetch(req) { const url = new URL(req.url) if (url.pathname === '/api/cli/v1/skills/search') { const auth = req.headers.get('authorization') authHeaders.push(auth) if (auth === 'Bearer sk_bad') { return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) } return Response.json({ code: 0, data: { items: [{ namespace: 'global', slug: 'anonymous-only', latestVersion: '1.0.0', summary: 'anonymous fallback' }], total: 1, limit: 20 } }) } return Response.json({ code: 404 }, { status: 404 }) } }) try { const registryUrl = `http://localhost:${server.port}` const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad']) expect(result.exitCode).toBe(2) expect(result.stderr).toContain('Error: authentication failed') expect(result.stderr).toContain(`Context: registry ${registryUrl}`) expect(result.stderr).toContain('Next:') expect(authHeaders).toEqual(['Bearer sk_bad']) } finally { server.stop() } }) test('bad --token returns structured json auth error', async () => { const server = Bun.serve({ port: 0, fetch(req) { const url = new URL(req.url) if (url.pathname === '/api/cli/v1/skills/search') { return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 }) } return Response.json({ code: 404 }, { status: 404 }) } }) try { const registryUrl = `http://localhost:${server.port}` const result = await runCli(['search', 'pdf', '--registry', registryUrl, '--token', 'sk_bad', '--json']) expect(result.exitCode).toBe(2) const parsed = JSON.parse(result.stderr) expect(parsed.ok).toBe(false) expect(parsed.message).toBe('authentication failed') expect(parsed.exitCode).toBe(2) expect(parsed.details.registry).toBe(registryUrl) expect(typeof parsed.details.next).toBe('string') expect(parsed.details.next).toContain('skillhub login') } finally { server.stop() } }) test('prints compact search table', async () => { registry = await startFakeRegistry({ searchItems: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }] }) const result = await runCli(['search', 'pdf', '--registry', registry.url]) expect(result.exitCode).toBe(0) expect(result.stdout).toContain('global/pdf-parser') expect(result.stdout).toContain('1.2.0') }) test('search json output', async () => { registry = await startFakeRegistry({ searchItems: [{ namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }] }) const result = await runCli(['search', 'pdf', '--registry', registry.url, '--json']) expect(result.exitCode).toBe(0) const json = JSON.parse(result.stdout) expect(json.ok).toBe(true) expect(json.items).toHaveLength(1) expect(json.items[0].slug).toBe('pdf-parser') }) test('search without query returns full result set', async () => { registry = await startFakeRegistry({ searchItems: [ { namespace: 'global', slug: 'pdf-parser', latestVersion: '1.2.0', summary: 'Parse PDFs' }, { namespace: 'global', slug: 'doc-parser', latestVersion: '2.0.0', summary: 'Parse docs' } ] }) const result = await runCli(['search', '--registry', registry.url]) expect(result.exitCode).toBe(0) expect(result.stdout).toContain('global/pdf-parser') expect(result.stdout).toContain('global/doc-parser') }) // P1: empty result set test('prints "No skills found." when registry returns empty list', async () => { registry = await startFakeRegistry({ searchItems: [] }) const result = await runCli(['search', 'nonexistent', '--registry', registry.url]) expect(result.exitCode).toBe(0) expect(result.stdout).toBe('No skills found.') }) // P1: --limit forwarding — unit-level assertion that limit=5 appears in URL test('--limit 5 sends limit=5 in the search URL', async () => { // Capture the raw request URL inside the fake server by extending it // minimally: we start a real Bun server that records the last search URL. let capturedUrl = '' const server = Bun.serve({ port: 0, fetch(req) { const url = new URL(req.url) if (url.pathname === '/api/cli/v1/skills/search') { capturedUrl = req.url return Response.json({ code: 0, data: { items: [], total: 0, limit: 5 } }) } return Response.json({ code: 404, message: 'not found' }, { status: 404 }) } }) const registryUrl = `http://localhost:${server.port}` try { const result = await runCli(['search', 'pdf', '--limit', '5', '--registry', registryUrl]) expect(result.exitCode).toBe(0) expect(capturedUrl).toContain('limit=5') } finally { server.stop() } }) // P1: network failure → EXIT.network. The exit code is the contract; the // exact message can be "registry unreachable" (when fetch throws) or // "registry returned 5xx" (when Bun returns a 5xx Response on connection // refusal). Both indicate the same network-class failure. test('exits with network error code when registry is unreachable', async () => { registry = await startFakeRegistry({ failures: { search: 'network' } }) const result = await runCli(['search', 'pdf', '--registry', registry.url]) expect(result.exitCode).toBe(3) // EXIT.network expect(result.stderr).toMatch(/registry unreachable|registry returned 5\d\d/) }) // ------------------------------------------------------------------------- // P2: query containing non-ASCII characters must be URL-encoded in the // outgoing request. We capture the raw URL via a custom Bun.serve and // assert the q parameter is the percent-encoded UTF-8 form of "中文测试". // ------------------------------------------------------------------------- test('non-ASCII query is URL-encoded as UTF-8 percent escapes', async () => { let capturedUrl = '' const server = Bun.serve({ port: 0, fetch(req) { const url = new URL(req.url) if (url.pathname === '/api/cli/v1/skills/search') { capturedUrl = req.url return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } }) } return Response.json({ code: 404, message: 'not found' }, { status: 404 }) } }) const registryUrl = `http://localhost:${server.port}` try { const result = await runCli(['search', '中文测试', '--registry', registryUrl]) expect(result.exitCode).toBe(0) // UTF-8 of 中文测试 = E4 B8 AD E6 96 87 E6 B5 8B E8 AF 95 expect(capturedUrl).toContain('q=%E4%B8%AD%E6%96%87%E6%B5%8B%E8%AF%95') } finally { server.stop() } }) // ------------------------------------------------------------------------- // P2: queries containing special characters (script tags, ampersands, // equals signs) are percent-encoded so they don't break the query string. // ------------------------------------------------------------------------- test('special-character query is encoded so the URL stays parseable', async () => { let capturedUrl = '' const server = Bun.serve({ port: 0, fetch(req) { const url = new URL(req.url) if (url.pathname === '/api/cli/v1/skills/search') { capturedUrl = req.url return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } }) } return Response.json({ code: 404, message: 'not found' }, { status: 404 }) } }) const registryUrl = `http://localhost:${server.port}` try { const result = await runCli(['search', '