Skip to content

Commit 4867d33

Browse files
committed
test(clawdhub): add publish smoke test
1 parent 5108072 commit 4867d33

File tree

1 file changed

+155
-0
lines changed

1 file changed

+155
-0
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/* @vitest-environment node */
2+
3+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
4+
import { tmpdir } from 'node:os'
5+
import { join } from 'node:path'
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
7+
import { sha256Hex } from '../../skills'
8+
import type { GlobalOpts } from '../types'
9+
10+
vi.mock('../../config.js', () => ({
11+
readGlobalConfig: vi.fn(async () => ({ registry: 'https://clawdhub.com', token: 'tkn' })),
12+
}))
13+
14+
const mockGetRegistry = vi.fn(async () => 'https://clawdhub.com')
15+
vi.mock('../registry.js', () => ({
16+
getRegistry: (...args: unknown[]) => mockGetRegistry(...args),
17+
}))
18+
19+
const mockApiRequest = vi.fn()
20+
vi.mock('../../http.js', () => ({
21+
apiRequest: (...args: unknown[]) => mockApiRequest(...args),
22+
}))
23+
24+
const mockFail = vi.fn((message: string) => {
25+
throw new Error(message)
26+
})
27+
const mockSpinner = { text: '', succeed: vi.fn(), fail: vi.fn() }
28+
vi.mock('../ui.js', () => ({
29+
createSpinner: vi.fn(() => mockSpinner),
30+
fail: (...args: unknown[]) => mockFail(...args),
31+
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
32+
}))
33+
34+
const { cmdPublish } = await import('./publish')
35+
36+
async function makeTmpWorkdir() {
37+
const root = await mkdtemp(join(tmpdir(), 'clawdhub-publish-'))
38+
return root
39+
}
40+
41+
function makeOpts(workdir: string): GlobalOpts {
42+
return {
43+
workdir,
44+
dir: join(workdir, 'skills'),
45+
site: 'https://clawdhub.com',
46+
registry: 'https://clawdhub.com',
47+
}
48+
}
49+
50+
afterEach(() => {
51+
vi.unstubAllGlobals()
52+
vi.clearAllMocks()
53+
})
54+
55+
describe('cmdPublish', () => {
56+
it('publishes SKILL.md from disk (mocked HTTP)', async () => {
57+
const workdir = await makeTmpWorkdir()
58+
try {
59+
const folder = join(workdir, 'my-skill')
60+
await mkdir(folder, { recursive: true })
61+
const skillContent = '# Skill\n\nHello\n'
62+
const notesContent = 'notes\n'
63+
await writeFile(join(folder, 'SKILL.md'), skillContent, 'utf8')
64+
await writeFile(join(folder, 'notes.md'), notesContent, 'utf8')
65+
66+
let uploadIndex = 0
67+
mockApiRequest.mockImplementation(
68+
async (_registry: string, args: { method: string; path: string }) => {
69+
if (args.method === 'GET' && args.path.startsWith('/api/skill?slug=')) {
70+
return { skill: null, latestVersion: { version: '9.9.9' } }
71+
}
72+
if (args.method === 'POST' && args.path === '/api/cli/upload-url') {
73+
uploadIndex += 1
74+
return { uploadUrl: `https://upload.example/${uploadIndex}` }
75+
}
76+
if (args.method === 'POST' && args.path === '/api/cli/publish') {
77+
return { ok: true, skillId: 'skill_1', versionId: 'ver_1' }
78+
}
79+
throw new Error(`Unexpected apiRequest: ${args.method} ${args.path}`)
80+
},
81+
)
82+
83+
vi.stubGlobal(
84+
'fetch',
85+
vi.fn(async (url: string, init?: RequestInit) => {
86+
expect(url).toMatch(/^https:\/\/upload\.example\/\d+$/)
87+
expect(init?.method).toBe('POST')
88+
expect((init?.headers as Record<string, string>)?.['Content-Type']).toMatch(
89+
/text\/(markdown|plain)/,
90+
)
91+
return new Response(JSON.stringify({ storageId: `st_${String(url).split('/').pop()}` }), {
92+
status: 200,
93+
headers: { 'Content-Type': 'application/json' },
94+
})
95+
}) as unknown as typeof fetch,
96+
)
97+
98+
await cmdPublish(makeOpts(workdir), 'my-skill', {
99+
slug: 'my-skill',
100+
name: 'My Skill',
101+
version: '1.0.0',
102+
changelog: '',
103+
tags: 'latest',
104+
})
105+
106+
const publishCall = mockApiRequest.mock.calls.find((call) => {
107+
const req = call[1] as { path?: string } | undefined
108+
return req?.path === '/api/cli/publish'
109+
})
110+
if (!publishCall) throw new Error('Missing publish call')
111+
const publishBody = (publishCall[1] as { body?: unknown }).body as {
112+
slug: string
113+
displayName: string
114+
version: string
115+
changelog: string
116+
tags: string[]
117+
files: Array<{ path: string; sha256: string; storageId: string }>
118+
}
119+
120+
expect(publishBody.slug).toBe('my-skill')
121+
expect(publishBody.displayName).toBe('My Skill')
122+
expect(publishBody.version).toBe('1.0.0')
123+
expect(publishBody.changelog).toBe('')
124+
expect(publishBody.tags).toEqual(['latest'])
125+
126+
const byPath = Object.fromEntries(publishBody.files.map((f) => [f.path, f]))
127+
expect(Object.keys(byPath).sort()).toEqual(['SKILL.md', 'notes.md'])
128+
expect(byPath['SKILL.md']?.sha256).toBe(sha256Hex(new TextEncoder().encode(skillContent)))
129+
expect(byPath['notes.md']?.sha256).toBe(sha256Hex(new TextEncoder().encode(notesContent)))
130+
} finally {
131+
await rm(workdir, { recursive: true, force: true })
132+
}
133+
})
134+
135+
it('requires --changelog when updating an existing skill', async () => {
136+
const workdir = await makeTmpWorkdir()
137+
try {
138+
const folder = join(workdir, 'existing-skill')
139+
await mkdir(folder, { recursive: true })
140+
await writeFile(join(folder, 'SKILL.md'), '# Skill\n', 'utf8')
141+
142+
mockApiRequest.mockImplementation(async () => ({ skill: { slug: 'existing-skill' } }))
143+
vi.stubGlobal(
144+
'fetch',
145+
vi.fn(async () => new Response('{}', { status: 200 })) as unknown as typeof fetch,
146+
)
147+
148+
await expect(
149+
cmdPublish(makeOpts(workdir), 'existing-skill', { version: '1.0.0', changelog: '' }),
150+
).rejects.toThrow(/changelog/i)
151+
} finally {
152+
await rm(workdir, { recursive: true, force: true })
153+
}
154+
})
155+
})

0 commit comments

Comments
 (0)