Skip to content

Commit 56230aa

Browse files
committed
test: expand cli coverage
- add e2e coverage for install/update + --cli-version - add unit coverage for sync optional changelog - add handler tests for delete/undelete
1 parent b93bb58 commit 56230aa

File tree

4 files changed

+160
-4
lines changed

4 files changed

+160
-4
lines changed

convex/httpApi.handlers.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,4 +271,42 @@ describe('httpApi handlers', () => {
271271
})
272272
expect(await response.json()).toEqual({ ok: true })
273273
})
274+
275+
it('cliSkillDeleteHandler supports undelete', async () => {
276+
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
277+
const runMutation = vi.fn().mockResolvedValue({ ok: true })
278+
const request = new Request('https://x/api/cli/skill/undelete', {
279+
method: 'POST',
280+
headers: { 'Content-Type': 'application/json' },
281+
body: JSON.stringify({ slug: 'demo' }),
282+
})
283+
const response = await __handlers.cliSkillDeleteHandler(
284+
{ runMutation } as never,
285+
request,
286+
false,
287+
)
288+
expect(response.status).toBe(200)
289+
expect(runMutation).toHaveBeenCalledWith(expect.anything(), {
290+
userId: 'user1',
291+
slug: 'demo',
292+
deleted: false,
293+
})
294+
})
295+
296+
it('cliSkillDeleteHandler returns 400 on invalid json', async () => {
297+
const request = new Request('https://x/api/cli/skill/delete', { method: 'POST', body: '{' })
298+
const response = await __handlers.cliSkillDeleteHandler({} as never, request, true)
299+
expect(response.status).toBe(400)
300+
})
301+
302+
it('cliSkillDeleteHandler returns 400 on invalid payload', async () => {
303+
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
304+
const request = new Request('https://x/api/cli/skill/delete', {
305+
method: 'POST',
306+
headers: { 'Content-Type': 'application/json' },
307+
body: JSON.stringify({}),
308+
})
309+
const response = await __handlers.cliSkillDeleteHandler({} as never, request, true)
310+
expect(response.status).toBe(400)
311+
})
274312
})

e2e/clawdhub.e2e.test.ts

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
ApiSearchResponseSchema,
1111
parseArk,
1212
} from 'clawdhub-schema'
13+
import { unzipSync } from 'fflate'
1314
import { describe, expect, it } from 'vitest'
1415
import { readGlobalConfig } from '../packages/clawdhub/src/config'
1516

@@ -31,6 +32,15 @@ async function makeTempConfig(registry: string, token: string | null) {
3132
}
3233

3334
describe('clawdhub e2e', () => {
35+
it('prints CLI version via --cli-version', async () => {
36+
const result = spawnSync('bun', ['clawdhub', '--cli-version'], {
37+
cwd: process.cwd(),
38+
encoding: 'utf8',
39+
})
40+
expect(result.status).toBe(0)
41+
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/)
42+
})
43+
3444
it('search endpoint returns a results array (schema parse)', async () => {
3545
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || 'https://clawdhub.com'
3646
const url = new URL(ApiRoutes.search, registry)
@@ -174,6 +184,7 @@ describe('clawdhub e2e', () => {
174184

175185
const cfg = await makeTempConfig(registry, token)
176186
const workdir = await mkdtemp(join(tmpdir(), 'clawdhub-e2e-publish-'))
187+
const installWorkdir = await mkdtemp(join(tmpdir(), 'clawdhub-e2e-install-'))
177188
const slug = `e2e-${Date.now()}`
178189
const skillDir = join(workdir, slug)
179190

@@ -241,6 +252,73 @@ describe('clawdhub e2e', () => {
241252
expect(publish2.status).toBe(0)
242253
expect(publish2.stderr).not.toMatch(/changelog required/i)
243254

255+
const downloadUrl = new URL(ApiRoutes.download, registry)
256+
downloadUrl.searchParams.set('slug', slug)
257+
downloadUrl.searchParams.set('version', '1.0.1')
258+
const zipRes = await fetch(downloadUrl.toString())
259+
expect(zipRes.ok).toBe(true)
260+
const zipBytes = new Uint8Array(await zipRes.arrayBuffer())
261+
const unzipped = unzipSync(zipBytes)
262+
expect(Object.keys(unzipped)).toContain('SKILL.md')
263+
264+
const install = spawnSync(
265+
'bun',
266+
[
267+
'clawdhub',
268+
'install',
269+
slug,
270+
'--version',
271+
'1.0.0',
272+
'--force',
273+
'--site',
274+
site,
275+
'--registry',
276+
registry,
277+
'--workdir',
278+
installWorkdir,
279+
],
280+
{
281+
cwd: process.cwd(),
282+
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
283+
encoding: 'utf8',
284+
},
285+
)
286+
expect(install.status).toBe(0)
287+
288+
const list = spawnSync(
289+
'bun',
290+
['clawdhub', 'list', '--site', site, '--registry', registry, '--workdir', installWorkdir],
291+
{
292+
cwd: process.cwd(),
293+
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
294+
encoding: 'utf8',
295+
},
296+
)
297+
expect(list.status).toBe(0)
298+
expect(list.stdout).toMatch(new RegExp(`${slug}\\s+1\\.0\\.0`))
299+
300+
const update = spawnSync(
301+
'bun',
302+
[
303+
'clawdhub',
304+
'update',
305+
slug,
306+
'--force',
307+
'--site',
308+
site,
309+
'--registry',
310+
registry,
311+
'--workdir',
312+
installWorkdir,
313+
],
314+
{
315+
cwd: process.cwd(),
316+
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path },
317+
encoding: 'utf8',
318+
},
319+
)
320+
expect(update.status).toBe(0)
321+
244322
const metaUrl = new URL(ApiRoutes.skill, registry)
245323
metaUrl.searchParams.set('slug', slug)
246324
const metaRes = await fetch(metaUrl.toString(), { headers: { Accept: 'application/json' } })
@@ -273,9 +351,6 @@ describe('clawdhub e2e', () => {
273351
})
274352
expect(metaAfterDelete.status).toBe(404)
275353

276-
const downloadUrl = new URL(ApiRoutes.download, registry)
277-
downloadUrl.searchParams.set('slug', slug)
278-
downloadUrl.searchParams.set('version', '1.0.1')
279354
const downloadAfterDelete = await fetch(downloadUrl.toString())
280355
expect(downloadAfterDelete.status).toBe(404)
281356

@@ -330,6 +405,7 @@ describe('clawdhub e2e', () => {
330405
// best-effort cleanup
331406
}
332407
await rm(workdir, { recursive: true, force: true })
408+
await rm(installWorkdir, { recursive: true, force: true })
333409
await rm(cfg.dir, { recursive: true, force: true })
334410
}
335411
}, 180_000)

packages/clawdhub/src/cli/commands/sync.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { GlobalOpts } from '../types'
66
const mockIntro = vi.fn()
77
const mockOutro = vi.fn()
88
const mockNote = vi.fn()
9+
let interactive = false
910

1011
vi.mock('@clack/prompts', () => ({
1112
intro: (value: string) => mockIntro(value),
@@ -39,7 +40,7 @@ vi.mock('../ui.js', () => ({
3940
createSpinner: vi.fn(() => mockSpinner),
4041
fail: (message: string) => mockFail(message),
4142
formatError: (error: unknown) => (error instanceof Error ? error.message : String(error)),
42-
isInteractive: () => false,
43+
isInteractive: () => interactive,
4344
}))
4445

4546
vi.mock('../scanSkills.js', () => ({
@@ -86,6 +87,7 @@ afterEach(() => {
8687

8788
describe('cmdSync', () => {
8889
it('classifies skills as new/update/synced (dry-run, mocked HTTP)', async () => {
90+
interactive = false
8991
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
9092
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
9193
if (args.path.startsWith('/api/skill?slug=')) {
@@ -117,4 +119,37 @@ describe('cmdSync', () => {
117119
const dryRunOutro = mockOutro.mock.calls.at(-1)?.[0]
118120
expect(String(dryRunOutro)).toMatch(/Dry run: would upload 2 skill/)
119121
})
122+
123+
it('allows empty changelog for updates (interactive)', async () => {
124+
interactive = true
125+
mockApiRequest.mockImplementation(async (_registry: string, args: { path: string }) => {
126+
if (args.path === '/api/cli/whoami') return { user: { handle: 'steipete' } }
127+
if (args.path.startsWith('/api/skill?slug=')) {
128+
const slug = new URL(`https://x.test${args.path}`).searchParams.get('slug')
129+
if (slug === 'new-skill') return { latestVersion: undefined, skill: null }
130+
if (slug === 'synced-skill') return { latestVersion: { version: '1.2.3' }, skill: {} }
131+
if (slug === 'update-skill') return { latestVersion: { version: '1.0.0' }, skill: {} }
132+
}
133+
if (args.path.startsWith('/api/skill/resolve?')) {
134+
const u = new URL(`https://x.test${args.path}`)
135+
const slug = u.searchParams.get('slug')
136+
if (slug === 'synced-skill') {
137+
return { match: { version: '1.2.3' }, latestVersion: { version: '1.2.3' } }
138+
}
139+
if (slug === 'update-skill') {
140+
return { match: null, latestVersion: { version: '1.0.0' } }
141+
}
142+
}
143+
throw new Error(`Unexpected apiRequest: ${args.path}`)
144+
})
145+
146+
await cmdSync(makeOpts(), { root: ['/scan'], all: true, dryRun: false, bump: 'patch' }, true)
147+
148+
const calls = mockCmdPublish.mock.calls.map(
149+
(call) => call[2] as { slug: string; changelog: string },
150+
)
151+
const update = calls.find((c) => c.slug === 'update-skill')
152+
if (!update) throw new Error('Missing update-skill publish')
153+
expect(update.changelog).toBe('')
154+
})
120155
})

packages/schema/src/schemas.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { parseArk } from './ark'
55
import {
66
ApiSearchResponseSchema,
77
CliPublishRequestSchema,
8+
CliSkillDeleteRequestSchema,
89
LockfileSchema,
910
WellKnownConfigSchema,
1011
} from './schemas'
@@ -89,4 +90,10 @@ describe('clawdhub-schema', () => {
8990
expect(parsed.results).toHaveLength(2)
9091
expect(parsed.results[0]?.slug).toBe('a')
9192
})
93+
94+
it('parses delete request payload', () => {
95+
expect(parseArk(CliSkillDeleteRequestSchema, { slug: 'demo' }, 'Delete')).toEqual({
96+
slug: 'demo',
97+
})
98+
})
9299
})

0 commit comments

Comments
 (0)