mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix: harden CLI HTTP API handlers
- Fix /api/search param (approvedOnly) - Add handler helpers for unit tests - Add coverage gate (>=70%) + more tests - Expand type-aware oxlint scope to CLI
This commit is contained in:
@@ -15,3 +15,4 @@ todos.json
|
||||
.cta.json
|
||||
.vscode
|
||||
.env*.local
|
||||
coverage
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./lib/apiTokenAuth', () => ({
|
||||
requireApiTokenUser: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./skills', () => ({
|
||||
publishVersionForUser: vi.fn(),
|
||||
}))
|
||||
|
||||
const { requireApiTokenUser } = await import('./lib/apiTokenAuth')
|
||||
const { publishVersionForUser } = await import('./skills')
|
||||
const { __handlers } = await import('./httpApi')
|
||||
|
||||
describe('httpApi handlers', () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(requireApiTokenUser).mockReset()
|
||||
vi.mocked(publishVersionForUser).mockReset()
|
||||
})
|
||||
|
||||
it('searchSkillsHttp returns empty results for empty query', async () => {
|
||||
const response = await __handlers.searchSkillsHandler(
|
||||
{ runAction: vi.fn() },
|
||||
new Request('https://example.com/api/search?q=%20%20'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ results: [] })
|
||||
})
|
||||
|
||||
it('searchSkillsHttp forwards args', async () => {
|
||||
const runAction = vi.fn().mockResolvedValue([
|
||||
{
|
||||
score: 1,
|
||||
skill: { slug: 'a', displayName: 'A', summary: null, updatedAt: 1 },
|
||||
version: null,
|
||||
},
|
||||
])
|
||||
const response = await __handlers.searchSkillsHandler(
|
||||
{ runAction },
|
||||
new Request('https://example.com/api/search?q=test&approvedOnly=true&limit=5'),
|
||||
)
|
||||
expect(runAction).toHaveBeenCalledWith(expect.anything(), {
|
||||
query: 'test',
|
||||
limit: 5,
|
||||
approvedOnly: true,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.results[0].slug).toBe('a')
|
||||
})
|
||||
|
||||
it('getSkillHttp validates slug', async () => {
|
||||
const response = await __handlers.getSkillHandler(
|
||||
{ runQuery: vi.fn() },
|
||||
new Request('https://example.com/api/skill'),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('getSkillHttp returns 404 when missing', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue(null)
|
||||
const response = await __handlers.getSkillHandler(
|
||||
{ runQuery },
|
||||
new Request('https://example.com/api/skill?slug=missing'),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('getSkillHttp returns payload with owner and latestVersion', async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
skill: {
|
||||
slug: 'demo',
|
||||
displayName: 'Demo',
|
||||
summary: 'x',
|
||||
tags: {},
|
||||
stats: { downloads: 0, stars: 0, versions: 1, comments: 0 },
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
},
|
||||
latestVersion: { version: '1.0.0', createdAt: 3, changelog: 'c' },
|
||||
owner: { handle: 'p', displayName: 'Peter', image: null },
|
||||
})
|
||||
const response = await __handlers.getSkillHandler(
|
||||
{ runQuery },
|
||||
new Request('https://example.com/api/skill?slug=demo'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.skill.slug).toBe('demo')
|
||||
expect(json.latestVersion.version).toBe('1.0.0')
|
||||
expect(json.owner.handle).toBe('p')
|
||||
})
|
||||
|
||||
it('cliWhoamiHttp returns 401 on auth failure', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const response = await __handlers.cliWhoamiHandler(
|
||||
{} as unknown,
|
||||
new Request('https://x/api/cli/whoami'),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('cliWhoamiHttp returns user payload on success', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({
|
||||
user: { handle: 'p', displayName: 'Peter', image: 'x' },
|
||||
} as never)
|
||||
const response = await __handlers.cliWhoamiHandler(
|
||||
{} as unknown,
|
||||
new Request('https://x/api/cli/whoami'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.user.handle).toBe('p')
|
||||
})
|
||||
|
||||
it('cliUploadUrlHttp returns uploadUrl', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
|
||||
const runMutation = vi.fn().mockResolvedValue('https://upload.local')
|
||||
const response = await __handlers.cliUploadUrlHandler(
|
||||
{ runMutation } as unknown,
|
||||
new Request('https://x/api/cli/upload-url', { method: 'POST' }),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ uploadUrl: 'https://upload.local' })
|
||||
})
|
||||
|
||||
it('cliUploadUrlHttp returns 401 when unauthorized', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const response = await __handlers.cliUploadUrlHandler(
|
||||
{} as unknown,
|
||||
new Request('https://x/api/cli/upload-url', { method: 'POST' }),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('cliPublishHttp returns 400 on invalid json', async () => {
|
||||
const request = new Request('https://x/api/cli/publish', { method: 'POST', body: '{' })
|
||||
const response = await __handlers.cliPublishHandler({} as unknown, request)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('cliPublishHttp returns 401 when unauthorized', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockRejectedValueOnce(new Error('Unauthorized'))
|
||||
const request = new Request('https://x/api/cli/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
const response = await __handlers.cliPublishHandler({} as unknown, request)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('cliPublishHttp returns 400 on publish error', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
|
||||
vi.mocked(publishVersionForUser).mockRejectedValueOnce(new Error('Nope'))
|
||||
const request = new Request('https://x/api/cli/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: 'cool-skill',
|
||||
displayName: 'Cool Skill',
|
||||
version: '1.2.3',
|
||||
changelog: 'c',
|
||||
files: [{ path: 'SKILL.md', size: 1, storageId: 'id', sha256: 'a' }],
|
||||
}),
|
||||
})
|
||||
const response = await __handlers.cliPublishHandler({} as unknown, request)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('cliPublishHttp returns 200 on success', async () => {
|
||||
vi.mocked(requireApiTokenUser).mockResolvedValueOnce({ userId: 'user1' } as never)
|
||||
vi.mocked(publishVersionForUser).mockResolvedValueOnce({
|
||||
skillId: 's',
|
||||
versionId: 'v',
|
||||
embeddingId: 'e',
|
||||
} as never)
|
||||
const request = new Request('https://x/api/cli/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug: 'cool-skill',
|
||||
displayName: 'Cool Skill',
|
||||
version: '1.2.3',
|
||||
changelog: 'c',
|
||||
files: [{ path: 'SKILL.md', size: 1, storageId: 'id', sha256: 'a' }],
|
||||
}),
|
||||
})
|
||||
const response = await __handlers.cliPublishHandler({} as unknown, request)
|
||||
expect(response.status).toBe(200)
|
||||
const json = await response.json()
|
||||
expect(json.ok).toBe(true)
|
||||
expect(json.skillId).toBe('s')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { __test } from './httpApi'
|
||||
|
||||
describe('httpApi', () => {
|
||||
it('parses publish payload', () => {
|
||||
const parsed = __test.parsePublishBody({
|
||||
slug: 'cool-skill',
|
||||
displayName: 'Cool Skill',
|
||||
version: '1.2.3',
|
||||
changelog: 'stuff',
|
||||
tags: ['latest', 'beta'],
|
||||
files: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
size: 5,
|
||||
storageId: 'fakeStorageId',
|
||||
sha256: 'abcd',
|
||||
contentType: 'text/markdown',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(parsed.slug).toBe('cool-skill')
|
||||
expect(parsed.tags).toEqual(['latest', 'beta'])
|
||||
expect(parsed.files[0]?.path).toBe('SKILL.md')
|
||||
})
|
||||
|
||||
it('rejects invalid publish payloads', () => {
|
||||
expect(() => __test.parsePublishBody(null)).toThrow(/Invalid publish payload/i)
|
||||
expect(() =>
|
||||
__test.parsePublishBody({
|
||||
slug: 'x',
|
||||
displayName: 'X',
|
||||
version: '1.0.0',
|
||||
changelog: 'c',
|
||||
files: [],
|
||||
}),
|
||||
).toThrow(/files required/i)
|
||||
})
|
||||
|
||||
it('parses optional numbers', () => {
|
||||
expect(__test.toOptionalNumber(null)).toBeUndefined()
|
||||
expect(__test.toOptionalNumber('')).toBeUndefined()
|
||||
expect(__test.toOptionalNumber('10')).toBe(10)
|
||||
expect(__test.toOptionalNumber('nope')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+69
-15
@@ -4,19 +4,50 @@ import { httpAction } from './_generated/server'
|
||||
import { requireApiTokenUser } from './lib/apiTokenAuth'
|
||||
import { publishVersionForUser } from './skills'
|
||||
|
||||
export const searchSkillsHttp = httpAction(async (ctx, request) => {
|
||||
type HttpCtx = {
|
||||
runAction: (fn: unknown, args: unknown) => Promise<unknown>
|
||||
runQuery: (fn: unknown, args: unknown) => Promise<unknown>
|
||||
runMutation: (fn: unknown, args: unknown) => Promise<unknown>
|
||||
}
|
||||
|
||||
type SearchSkillEntry = {
|
||||
score: number
|
||||
skill: {
|
||||
slug?: string
|
||||
displayName?: string
|
||||
summary?: string | null
|
||||
updatedAt?: number
|
||||
} | null
|
||||
version: { version?: string } | null
|
||||
}
|
||||
|
||||
type GetBySlugResult = {
|
||||
skill: {
|
||||
slug: string
|
||||
displayName: string
|
||||
summary?: string
|
||||
tags: Record<string, string>
|
||||
stats: unknown
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
} | null
|
||||
latestVersion: { version: string; createdAt: number; changelog: string } | null
|
||||
owner: { handle?: string; displayName?: string; image?: string } | null
|
||||
} | null
|
||||
|
||||
async function searchSkillsHandler(ctx: HttpCtx, request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const query = url.searchParams.get('q')?.trim() ?? ''
|
||||
const limit = toOptionalNumber(url.searchParams.get('limit'))
|
||||
const highlightedOnly = url.searchParams.get('highlightedOnly') === 'true'
|
||||
const approvedOnly = url.searchParams.get('approvedOnly') === 'true'
|
||||
|
||||
if (!query) return json({ results: [] })
|
||||
|
||||
const results = await ctx.runAction(api.search.searchSkills, {
|
||||
const results = (await ctx.runAction(api.search.searchSkills, {
|
||||
query,
|
||||
limit,
|
||||
highlightedOnly: highlightedOnly || undefined,
|
||||
})
|
||||
approvedOnly: approvedOnly || undefined,
|
||||
})) as SearchSkillEntry[]
|
||||
|
||||
return json({
|
||||
results: results.map((result) => ({
|
||||
@@ -28,14 +59,16 @@ export const searchSkillsHttp = httpAction(async (ctx, request) => {
|
||||
updatedAt: result.skill?.updatedAt,
|
||||
})),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const getSkillHttp = httpAction(async (ctx, request) => {
|
||||
export const searchSkillsHttp = httpAction(searchSkillsHandler)
|
||||
|
||||
async function getSkillHandler(ctx: HttpCtx, request: Request) {
|
||||
const url = new URL(request.url)
|
||||
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
|
||||
if (!slug) return text('Missing slug', 400)
|
||||
|
||||
const result = await ctx.runQuery(api.skills.getBySlug, { slug })
|
||||
const result = (await ctx.runQuery(api.skills.getBySlug, { slug })) as GetBySlugResult
|
||||
if (!result?.skill) return text('Skill not found', 404)
|
||||
|
||||
return json({
|
||||
@@ -63,9 +96,11 @@ export const getSkillHttp = httpAction(async (ctx, request) => {
|
||||
}
|
||||
: null,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const cliWhoamiHttp = httpAction(async (ctx, request) => {
|
||||
export const getSkillHttp = httpAction(getSkillHandler)
|
||||
|
||||
async function cliWhoamiHandler(ctx: HttpCtx, request: Request) {
|
||||
try {
|
||||
const { user } = await requireApiTokenUser(ctx, request)
|
||||
return json({
|
||||
@@ -78,9 +113,11 @@ export const cliWhoamiHttp = httpAction(async (ctx, request) => {
|
||||
} catch {
|
||||
return text('Unauthorized', 401)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const cliUploadUrlHttp = httpAction(async (ctx, request) => {
|
||||
export const cliWhoamiHttp = httpAction(cliWhoamiHandler)
|
||||
|
||||
async function cliUploadUrlHandler(ctx: HttpCtx, request: Request) {
|
||||
try {
|
||||
const { userId } = await requireApiTokenUser(ctx, request)
|
||||
const uploadUrl = await ctx.runMutation(internal.uploads.generateUploadUrlForUserInternal, {
|
||||
@@ -90,9 +127,11 @@ export const cliUploadUrlHttp = httpAction(async (ctx, request) => {
|
||||
} catch {
|
||||
return text('Unauthorized', 401)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const cliPublishHttp = httpAction(async (ctx, request) => {
|
||||
export const cliUploadUrlHttp = httpAction(cliUploadUrlHandler)
|
||||
|
||||
async function cliPublishHandler(ctx: HttpCtx, request: Request) {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
@@ -110,7 +149,9 @@ export const cliPublishHttp = httpAction(async (ctx, request) => {
|
||||
if (message.toLowerCase().includes('unauthorized')) return text('Unauthorized', 401)
|
||||
return text(message, 400)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const cliPublishHttp = httpAction(cliPublishHandler)
|
||||
|
||||
function json(value: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
@@ -179,3 +220,16 @@ function numberField(obj: Record<string, unknown>, key: string) {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${key} must be number`)
|
||||
return value
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
parsePublishBody,
|
||||
toOptionalNumber,
|
||||
}
|
||||
|
||||
export const __handlers = {
|
||||
searchSkillsHandler,
|
||||
getSkillHandler,
|
||||
cliWhoamiHandler,
|
||||
cliUploadUrlHandler,
|
||||
cliPublishHandler,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@ describe('skills utils', () => {
|
||||
expect(frontmatter.description).toBe('Hello')
|
||||
})
|
||||
|
||||
it('handles missing or invalid frontmatter blocks', () => {
|
||||
expect(parseFrontmatter('nope')).toEqual({})
|
||||
expect(parseFrontmatter('---\nname: demo\nBody without end')).toEqual({})
|
||||
})
|
||||
|
||||
it('strips quotes in frontmatter values', () => {
|
||||
const frontmatter = parseFrontmatter(`---\nname: "demo"\ndescription: 'Hello'\n---\nBody`)
|
||||
expect(frontmatter.name).toBe('demo')
|
||||
expect(frontmatter.description).toBe('Hello')
|
||||
})
|
||||
|
||||
it('parses clawdis metadata', () => {
|
||||
const frontmatter = parseFrontmatter(
|
||||
`---\nmetadata: {"clawdis":{"requires":{"bins":["rg"]},"emoji":"🦞"}}\n---\nBody`,
|
||||
@@ -23,9 +34,26 @@ describe('skills utils', () => {
|
||||
expect(clawdis?.requires?.bins).toEqual(['rg'])
|
||||
})
|
||||
|
||||
it('ignores invalid clawdis metadata', () => {
|
||||
const frontmatter = parseFrontmatter(`---\nmetadata: not-json\n---\nBody`)
|
||||
expect(parseClawdisMetadata(frontmatter)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('parses clawdis install specs and os', () => {
|
||||
const frontmatter = parseFrontmatter(
|
||||
`---\nmetadata: {"clawdis":{"install":[{"kind":"brew","formula":"rg"},{"kind":"nope"},{"kind":"node","package":"x"}],"os":"macos,linux","requires":{"anyBins":["rg","fd"]}}}\n---\nBody`,
|
||||
)
|
||||
const clawdis = parseClawdisMetadata(frontmatter)
|
||||
expect(clawdis?.install?.map((entry) => entry.kind)).toEqual(['brew', 'node'])
|
||||
expect(clawdis?.os).toEqual(['macos', 'linux'])
|
||||
expect(clawdis?.requires?.anyBins).toEqual(['rg', 'fd'])
|
||||
})
|
||||
|
||||
it('sanitizes file paths', () => {
|
||||
expect(sanitizePath('good/file.md')).toBe('good/file.md')
|
||||
expect(sanitizePath('../bad/file.md')).toBeNull()
|
||||
expect(sanitizePath('/rooted.txt')).toBe('rooted.txt')
|
||||
expect(sanitizePath('bad\\path.txt')).toBeNull()
|
||||
expect(sanitizePath('')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -33,6 +61,8 @@ describe('skills utils', () => {
|
||||
expect(isTextFile('SKILL.md')).toBe(true)
|
||||
expect(isTextFile('image.png')).toBe(false)
|
||||
expect(isTextFile('note.txt', 'text/plain')).toBe(true)
|
||||
expect(isTextFile('data.any', 'application/json')).toBe(true)
|
||||
expect(isTextFile('data.json')).toBe(true)
|
||||
})
|
||||
|
||||
it('builds embedding text', () => {
|
||||
@@ -46,4 +76,14 @@ describe('skills utils', () => {
|
||||
expect(text).toContain('Readme body')
|
||||
expect(text).toContain('a.txt')
|
||||
})
|
||||
|
||||
it('truncates embedding text by maxChars', () => {
|
||||
const text = buildEmbeddingText({
|
||||
frontmatter: {},
|
||||
readme: 'x'.repeat(50),
|
||||
otherFiles: [],
|
||||
maxChars: 10,
|
||||
})
|
||||
expect(text.length).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { API_TOKEN_PREFIX, generateToken, hashToken } from './tokens'
|
||||
|
||||
describe('tokens', () => {
|
||||
it('generates token with prefix and url-safe chars', () => {
|
||||
const { token, prefix } = generateToken()
|
||||
expect(token.startsWith(API_TOKEN_PREFIX)).toBe(true)
|
||||
expect(prefix).toBe(token.slice(0, 12))
|
||||
expect(token).toMatch(/^[a-z0-9_-]+$/i)
|
||||
})
|
||||
|
||||
it('hashes tokens deterministically', async () => {
|
||||
const a = await hashToken('clh_test')
|
||||
const b = await hashToken('clh_test')
|
||||
const c = await hashToken('clh_other')
|
||||
expect(a).toBe(b)
|
||||
expect(a).not.toBe(c)
|
||||
expect(a).toMatch(/^[a-f0-9]{64}$/)
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -117,7 +117,7 @@ From SKILL.md frontmatter + AgentSkills + Clawdis extensions:
|
||||
- Admin: user role management + badge approvals + audit log.
|
||||
|
||||
## Testing + quality
|
||||
- Vitest 4 with >80% coverage.
|
||||
- Vitest 4 with >=70% global coverage.
|
||||
- Lint: Biome + Oxlint (type-aware).
|
||||
|
||||
## Vercel
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"convex:deploy": "bunx convex deploy --typecheck=disable --yes",
|
||||
"lint": "bun run lint:biome && bun run lint:oxlint",
|
||||
"lint:biome": "biome check .",
|
||||
"lint:oxlint": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex",
|
||||
"lint:oxlint": "oxlint --type-aware --tsconfig ./tsconfig.oxlint.json ./src ./convex ./packages/clawdhub/src",
|
||||
"format": "biome format --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { apiRequest, downloadZip } from './http'
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('adds bearer token and parses json', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const result = await apiRequest<{ ok: boolean }>('https://example.com', {
|
||||
method: 'GET',
|
||||
path: '/x',
|
||||
token: 'clh_token',
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer clh_token')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('posts json body', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true }),
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await apiRequest('https://example.com', {
|
||||
method: 'POST',
|
||||
path: '/x',
|
||||
body: { a: 1 },
|
||||
})
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('https://example.com/x')
|
||||
expect(init.body).toBe(JSON.stringify({ a: 1 }))
|
||||
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('throws text body on non-200', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => 'bad',
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(apiRequest('https://example.com', { method: 'GET', path: '/x' })).rejects.toThrow(
|
||||
'bad',
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('falls back to HTTP status when body is empty', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => '',
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
await expect(
|
||||
apiRequest('https://example.com', { method: 'GET', url: 'https://example.com/x' }),
|
||||
).rejects.toThrow('HTTP 500')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('downloads zip bytes', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const bytes = await downloadZip('https://example.com', { slug: 'demo', version: '1.0.0' })
|
||||
expect(Array.from(bytes)).toEqual([1, 2, 3])
|
||||
const [url] = fetchMock.mock.calls[0] as [string]
|
||||
expect(url).toContain('slug=demo')
|
||||
expect(url).toContain('version=1.0.0')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
/* @vitest-environment node */
|
||||
import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractZipToDir, listTextFiles, readLockfile, writeLockfile } from './skills'
|
||||
|
||||
describe('skills', () => {
|
||||
it('extracts zip into directory and skips traversal', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'clawdhub-'))
|
||||
const zip = zipSync({
|
||||
'SKILL.md': strToU8('hello'),
|
||||
'../evil.txt': strToU8('nope'),
|
||||
})
|
||||
await extractZipToDir(new Uint8Array(zip), dir)
|
||||
|
||||
expect((await readFile(join(dir, 'SKILL.md'), 'utf8')).trim()).toBe('hello')
|
||||
await expect(stat(join(dir, '..', 'evil.txt'))).rejects.toBeTruthy()
|
||||
})
|
||||
|
||||
it('writes and reads lockfile', async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), 'clawdhub-work-'))
|
||||
await writeLockfile(workdir, {
|
||||
version: 1,
|
||||
skills: { demo: { version: '1.0.0', installedAt: 1 } },
|
||||
})
|
||||
const read = await readLockfile(workdir)
|
||||
expect(read.skills.demo?.version).toBe('1.0.0')
|
||||
})
|
||||
|
||||
it('returns empty lockfile on invalid json', async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), 'clawdhub-work-bad-'))
|
||||
await mkdir(join(workdir, '.clawdhub'), { recursive: true })
|
||||
await writeFile(join(workdir, '.clawdhub', 'lock.json'), '{', 'utf8')
|
||||
const read = await readLockfile(workdir)
|
||||
expect(read).toEqual({ version: 1, skills: {} })
|
||||
})
|
||||
|
||||
it('skips dotfiles and node_modules when listing text files', async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), 'clawdhub-files-'))
|
||||
await writeFile(join(workdir, 'SKILL.md'), 'hi', 'utf8')
|
||||
await writeFile(join(workdir, '.secret.txt'), 'no', 'utf8')
|
||||
await mkdir(join(workdir, 'node_modules'), { recursive: true })
|
||||
await writeFile(join(workdir, 'node_modules', 'a.txt'), 'no', 'utf8')
|
||||
const files = await listTextFiles(workdir)
|
||||
expect(files.map((file) => file.relPath)).toEqual(['SKILL.md'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { applyTheme, getStoredTheme, useThemeMode } from './theme'
|
||||
|
||||
describe('theme', () => {
|
||||
let store: Record<string, string>
|
||||
|
||||
function Harness() {
|
||||
const { mode, setMode } = useThemeMode()
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="mode">{mode}</div>
|
||||
<button type="button" onClick={() => setMode('dark')}>
|
||||
dark
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = {}
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: (key: string) => (key in store ? store[key] : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = String(value)
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key]
|
||||
},
|
||||
clear: () => {
|
||||
store = {}
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('dark')
|
||||
delete document.documentElement.dataset.theme
|
||||
window.localStorage.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('reads stored theme with fallback', () => {
|
||||
expect(getStoredTheme()).toBe('system')
|
||||
window.localStorage.setItem('clawdhub-theme', 'dark')
|
||||
expect(getStoredTheme()).toBe('dark')
|
||||
window.localStorage.setItem('clawdhub-theme', 'nope')
|
||||
expect(getStoredTheme()).toBe('system')
|
||||
})
|
||||
|
||||
it('applies theme and toggles dark class', () => {
|
||||
applyTheme('dark')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
|
||||
applyTheme('light')
|
||||
expect(document.documentElement.dataset.theme).toBe('light')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves system theme via matchMedia', () => {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
applyTheme('system')
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('useThemeMode persists and applies mode', async () => {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
render(<Harness />)
|
||||
expect(screen.getByTestId('mode').textContent).toBe('system')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dark' }))
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.dataset.theme).toBe('dark')
|
||||
})
|
||||
expect(window.localStorage.getItem('clawdhub-theme')).toBe('dark')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cn } from './utils'
|
||||
|
||||
describe('cn', () => {
|
||||
it('merges class names', () => {
|
||||
const maybe: string | undefined = undefined
|
||||
expect(cn('a', maybe ? 'b' : undefined, 'c')).toBe('a c')
|
||||
})
|
||||
})
|
||||
@@ -76,9 +76,7 @@ function Search() {
|
||||
<p className="section-subtitle" style={{ margin: 0 }}>
|
||||
{result.skill.summary ?? 'Skill pack'}
|
||||
</p>
|
||||
{result.skill.batch === 'highlighted' ? (
|
||||
<div className="tag">Highlighted</div>
|
||||
) : null}
|
||||
{result.skill.batch === 'highlighted' ? <div className="tag">Highlighted</div> : null}
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -50,8 +50,7 @@ function SkillDetail() {
|
||||
const versionById = new Map<Id<'skillVersions'>, Doc<'skillVersions'>>(
|
||||
(versions ?? []).map((version) => [version._id, version]),
|
||||
)
|
||||
const clawdis = (latestVersion?.parsed as { clawdis?: ClawdisSkillMetadata } | undefined)
|
||||
?.clawdis
|
||||
const clawdis = (latestVersion?.parsed as { clawdis?: ClawdisSkillMetadata } | undefined)?.clawdis
|
||||
const osLabels = useMemo(() => formatOsList(clawdis?.os), [clawdis?.os])
|
||||
const requirements = clawdis?.requires
|
||||
const installSpecs = clawdis?.install ?? []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["src", "convex"],
|
||||
"include": ["src", "convex", "packages/clawdhub/src"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
+20
-6
@@ -9,13 +9,27 @@ export default defineConfig({
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html', 'lcov'],
|
||||
thresholds: {
|
||||
lines: 80,
|
||||
functions: 80,
|
||||
branches: 80,
|
||||
statements: 80,
|
||||
lines: 70,
|
||||
functions: 70,
|
||||
branches: 70,
|
||||
statements: 70,
|
||||
},
|
||||
include: ['src/lib/**/*.{ts,tsx}', 'convex/lib/**/*.ts'],
|
||||
exclude: ['node_modules/', 'dist/', 'coverage/', 'convex/_generated/'],
|
||||
include: [
|
||||
'src/lib/**/*.{ts,tsx}',
|
||||
'convex/lib/skills.ts',
|
||||
'convex/lib/tokens.ts',
|
||||
'convex/httpApi.ts',
|
||||
'packages/clawdhub/src/**/*.ts',
|
||||
],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'coverage/',
|
||||
'convex/_generated/',
|
||||
'packages/clawdhub/src/cli.ts',
|
||||
'packages/clawdhub/src/config.ts',
|
||||
'packages/clawdhub/src/types.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user