mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
test: add ssr and og regression coverage
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const readFileMock = vi.fn()
|
||||
const initWasmMock = vi.fn()
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: (...args: unknown[]) => readFileMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@resvg/resvg-wasm', () => ({
|
||||
initWasm: (...args: unknown[]) => initWasmMock(...args),
|
||||
}))
|
||||
|
||||
describe('ogAssets', () => {
|
||||
beforeEach(() => {
|
||||
delete (globalThis as { __nitro_main__?: unknown }).__nitro_main__
|
||||
readFileMock.mockReset()
|
||||
initWasmMock.mockReset()
|
||||
})
|
||||
|
||||
it('falls back to the packaged public mark asset', async () => {
|
||||
readFileMock.mockImplementation(async (input: unknown) => {
|
||||
const path = String(input)
|
||||
if (path.includes('public/clawd-mark.png')) {
|
||||
return Buffer.from('png')
|
||||
}
|
||||
if (path.includes('clawd-mark.png')) {
|
||||
throw new Error('missing root mark')
|
||||
}
|
||||
throw new Error(`unexpected read: ${path}`)
|
||||
})
|
||||
|
||||
const { getMarkDataUrl } = await import('./ogAssets')
|
||||
|
||||
await expect(getMarkDataUrl()).resolves.toBe('data:image/png;base64,cG5n')
|
||||
expect(readFileMock).toHaveBeenCalledTimes(2)
|
||||
expect(String(readFileMock.mock.calls[0]?.[0])).toContain('clawd-mark.png')
|
||||
expect(String(readFileMock.mock.calls[1]?.[0])).toContain('public/clawd-mark.png')
|
||||
})
|
||||
|
||||
it('initializes resvg wasm only once per process', async () => {
|
||||
readFileMock.mockImplementation(async (input: unknown) => {
|
||||
const path = String(input)
|
||||
if (path.includes('index_bg.wasm')) {
|
||||
return Buffer.from([1, 2, 3])
|
||||
}
|
||||
throw new Error(`unexpected read: ${path}`)
|
||||
})
|
||||
initWasmMock.mockResolvedValue(undefined)
|
||||
|
||||
const { ensureResvgWasm } = await import('./ogAssets')
|
||||
|
||||
await ensureResvgWasm()
|
||||
await ensureResvgWasm()
|
||||
|
||||
expect(initWasmMock).toHaveBeenCalledTimes(1)
|
||||
expect(initWasmMock).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]))
|
||||
expect(readFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(String(readFileMock.mock.calls[0]?.[0])).toContain('index_bg.wasm')
|
||||
})
|
||||
|
||||
it('caches font buffers across calls', async () => {
|
||||
readFileMock.mockResolvedValue(Buffer.from([9, 8, 7]))
|
||||
|
||||
const { getFontBuffers } = await import('./ogAssets')
|
||||
|
||||
const first = await getFontBuffers()
|
||||
const second = await getFontBuffers()
|
||||
|
||||
expect(first).toHaveLength(3)
|
||||
expect(first[0]).toBeInstanceOf(Uint8Array)
|
||||
expect(second).toEqual(first)
|
||||
expect(readFileMock).toHaveBeenCalledTimes(3)
|
||||
expect(
|
||||
readFileMock.mock.calls.map((call) => String(call[0])),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('bricolage-grotesque-latin-800-normal.woff2'),
|
||||
expect.stringContaining('bricolage-grotesque-latin-500-normal.woff2'),
|
||||
expect.stringContaining('ibm-plex-mono-latin-500-normal.woff2'),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getQueryMock = vi.fn()
|
||||
const getRequestHostMock = vi.fn()
|
||||
const setHeaderMock = vi.fn()
|
||||
const fetchSkillOgMetaMock = vi.fn()
|
||||
const getMarkDataUrlMock = vi.fn()
|
||||
const ensureResvgWasmMock = vi.fn()
|
||||
const getFontBuffersMock = vi.fn()
|
||||
const buildSkillOgSvgMock = vi.fn()
|
||||
const renderAsPngMock = vi.fn()
|
||||
const freeMock = vi.fn()
|
||||
const resvgCtorMock = vi.fn()
|
||||
|
||||
class ResvgMockClass {
|
||||
constructor(...args: unknown[]) {
|
||||
resvgCtorMock(...args)
|
||||
}
|
||||
|
||||
render() {
|
||||
return { asPng: renderAsPngMock }
|
||||
}
|
||||
|
||||
free() {
|
||||
return freeMock()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('h3', () => ({
|
||||
defineEventHandler: (handler: unknown) => handler,
|
||||
getQuery: (...args: unknown[]) => getQueryMock(...args),
|
||||
getRequestHost: (...args: unknown[]) => getRequestHostMock(...args),
|
||||
setHeader: (...args: unknown[]) => setHeaderMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../og/fetchSkillOgMeta', () => ({
|
||||
fetchSkillOgMeta: (...args: unknown[]) => fetchSkillOgMetaMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../og/ogAssets', () => ({
|
||||
FONT_MONO: 'IBM Plex Mono',
|
||||
FONT_SANS: 'Bricolage Grotesque',
|
||||
getMarkDataUrl: (...args: unknown[]) => getMarkDataUrlMock(...args),
|
||||
ensureResvgWasm: (...args: unknown[]) => ensureResvgWasmMock(...args),
|
||||
getFontBuffers: (...args: unknown[]) => getFontBuffersMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../og/skillOgSvg', () => ({
|
||||
buildSkillOgSvg: (...args: unknown[]) => buildSkillOgSvgMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@resvg/resvg-wasm', () => ({
|
||||
Resvg: ResvgMockClass,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getQueryMock.mockReset()
|
||||
getRequestHostMock.mockReset()
|
||||
setHeaderMock.mockReset()
|
||||
fetchSkillOgMetaMock.mockReset()
|
||||
getMarkDataUrlMock.mockReset()
|
||||
ensureResvgWasmMock.mockReset()
|
||||
getFontBuffersMock.mockReset()
|
||||
buildSkillOgSvgMock.mockReset()
|
||||
renderAsPngMock.mockReset()
|
||||
freeMock.mockReset()
|
||||
resvgCtorMock.mockReset()
|
||||
|
||||
getMarkDataUrlMock.mockResolvedValue('data:image/png;base64,AAA=')
|
||||
ensureResvgWasmMock.mockResolvedValue(undefined)
|
||||
getFontBuffersMock.mockResolvedValue([new Uint8Array([1, 2, 3])])
|
||||
buildSkillOgSvgMock.mockReturnValue('<svg>skill</svg>')
|
||||
renderAsPngMock.mockReturnValue(new Uint8Array([7, 8, 9]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.VITE_CONVEX_SITE_URL
|
||||
delete process.env.SITE_URL
|
||||
delete process.env.VITE_SITE_URL
|
||||
})
|
||||
|
||||
describe('skill og route', () => {
|
||||
it('returns plain text when slug is missing', async () => {
|
||||
getQueryMock.mockReturnValue({})
|
||||
|
||||
const handler = (await import('./skill.png')).default
|
||||
await expect(handler({} as never)).resolves.toBe('Missing `slug` query param.')
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, 'Content-Type', 'text/plain; charset=utf-8')
|
||||
expect(fetchSkillOgMetaMock).not.toHaveBeenCalled()
|
||||
expect(resvgCtorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders from explicit query params without fetching metadata', async () => {
|
||||
getQueryMock.mockReturnValue({
|
||||
slug: 'gifgrep',
|
||||
owner: 'steipete',
|
||||
version: '1.0.1',
|
||||
title: 'Gifgrep',
|
||||
description: 'Search GIFs fast',
|
||||
})
|
||||
|
||||
const handler = (await import('./skill.png')).default
|
||||
await expect(handler({} as never)).resolves.toEqual(new Uint8Array([7, 8, 9]))
|
||||
|
||||
expect(fetchSkillOgMetaMock).not.toHaveBeenCalled()
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, 'Cache-Control', 'public, max-age=31536000, immutable')
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, 'Content-Type', 'image/png')
|
||||
expect(buildSkillOgSvgMock).toHaveBeenCalledWith({
|
||||
markDataUrl: 'data:image/png;base64,AAA=',
|
||||
title: 'Gifgrep',
|
||||
description: 'Search GIFs fast',
|
||||
ownerLabel: '@steipete',
|
||||
versionLabel: 'v1.0.1',
|
||||
footer: 'clawhub.ai/steipete/gifgrep',
|
||||
})
|
||||
expect(resvgCtorMock).toHaveBeenCalledWith('<svg>skill</svg>', {
|
||||
fitTo: { mode: 'width', value: 1200 },
|
||||
font: {
|
||||
fontBuffers: [new Uint8Array([1, 2, 3])],
|
||||
defaultFontFamily: 'Bricolage Grotesque',
|
||||
sansSerifFamily: 'Bricolage Grotesque',
|
||||
monospaceFamily: 'IBM Plex Mono',
|
||||
},
|
||||
})
|
||||
expect(freeMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('fetches metadata from the request host when query params are incomplete', async () => {
|
||||
getQueryMock.mockReturnValue({ slug: 'gifgrep' })
|
||||
getRequestHostMock.mockReturnValue('preview.clawhub.ai')
|
||||
fetchSkillOgMetaMock.mockResolvedValue({
|
||||
owner: 'steipete',
|
||||
version: null,
|
||||
displayName: 'Gifgrep',
|
||||
summary: 'Search GIFs fast',
|
||||
})
|
||||
|
||||
const handler = (await import('./skill.png')).default
|
||||
await handler({} as never)
|
||||
|
||||
expect(fetchSkillOgMetaMock).toHaveBeenCalledWith('gifgrep', 'https://preview.clawhub.ai')
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, 'Cache-Control', 'public, max-age=3600')
|
||||
expect(buildSkillOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Gifgrep',
|
||||
description: 'Search GIFs fast',
|
||||
ownerLabel: '@steipete',
|
||||
versionLabel: 'latest',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getQueryMock = vi.fn()
|
||||
const getRequestHostMock = vi.fn()
|
||||
const setHeaderMock = vi.fn()
|
||||
const fetchSoulOgMetaMock = vi.fn()
|
||||
const getMarkDataUrlMock = vi.fn()
|
||||
const ensureResvgWasmMock = vi.fn()
|
||||
const getFontBuffersMock = vi.fn()
|
||||
const buildSoulOgSvgMock = vi.fn()
|
||||
const renderAsPngMock = vi.fn()
|
||||
const freeMock = vi.fn()
|
||||
const resvgCtorMock = vi.fn()
|
||||
|
||||
class ResvgMockClass {
|
||||
constructor(...args: unknown[]) {
|
||||
resvgCtorMock(...args)
|
||||
}
|
||||
|
||||
render() {
|
||||
return { asPng: renderAsPngMock }
|
||||
}
|
||||
|
||||
free() {
|
||||
return freeMock()
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('h3', () => ({
|
||||
defineEventHandler: (handler: unknown) => handler,
|
||||
getQuery: (...args: unknown[]) => getQueryMock(...args),
|
||||
getRequestHost: (...args: unknown[]) => getRequestHostMock(...args),
|
||||
setHeader: (...args: unknown[]) => setHeaderMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../og/fetchSoulOgMeta', () => ({
|
||||
fetchSoulOgMeta: (...args: unknown[]) => fetchSoulOgMetaMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../og/ogAssets', () => ({
|
||||
FONT_MONO: 'IBM Plex Mono',
|
||||
FONT_SANS: 'Bricolage Grotesque',
|
||||
getMarkDataUrl: (...args: unknown[]) => getMarkDataUrlMock(...args),
|
||||
ensureResvgWasm: (...args: unknown[]) => ensureResvgWasmMock(...args),
|
||||
getFontBuffers: (...args: unknown[]) => getFontBuffersMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../../og/soulOgSvg', () => ({
|
||||
buildSoulOgSvg: (...args: unknown[]) => buildSoulOgSvgMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@resvg/resvg-wasm', () => ({
|
||||
Resvg: ResvgMockClass,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
getQueryMock.mockReset()
|
||||
getRequestHostMock.mockReset()
|
||||
setHeaderMock.mockReset()
|
||||
fetchSoulOgMetaMock.mockReset()
|
||||
getMarkDataUrlMock.mockReset()
|
||||
ensureResvgWasmMock.mockReset()
|
||||
getFontBuffersMock.mockReset()
|
||||
buildSoulOgSvgMock.mockReset()
|
||||
renderAsPngMock.mockReset()
|
||||
freeMock.mockReset()
|
||||
resvgCtorMock.mockReset()
|
||||
|
||||
getMarkDataUrlMock.mockResolvedValue('data:image/png;base64,AAA=')
|
||||
ensureResvgWasmMock.mockResolvedValue(undefined)
|
||||
getFontBuffersMock.mockResolvedValue([new Uint8Array([1, 2, 3])])
|
||||
buildSoulOgSvgMock.mockReturnValue('<svg>soul</svg>')
|
||||
renderAsPngMock.mockReturnValue(new Uint8Array([4, 5, 6]))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.VITE_CONVEX_SITE_URL
|
||||
delete process.env.SITE_URL
|
||||
delete process.env.VITE_SITE_URL
|
||||
})
|
||||
|
||||
describe('soul og route', () => {
|
||||
it('returns plain text when slug is missing', async () => {
|
||||
getQueryMock.mockReturnValue({})
|
||||
|
||||
const handler = (await import('./soul.png')).default
|
||||
await expect(handler({} as never)).resolves.toBe('Missing `slug` query param.')
|
||||
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, 'Content-Type', 'text/plain; charset=utf-8')
|
||||
expect(fetchSoulOgMetaMock).not.toHaveBeenCalled()
|
||||
expect(resvgCtorMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fetches metadata and renders SoulHub labels', async () => {
|
||||
getQueryMock.mockReturnValue({ slug: 'lorekeeper' })
|
||||
getRequestHostMock.mockReturnValue('souls-preview.example.com')
|
||||
fetchSoulOgMetaMock.mockResolvedValue({
|
||||
owner: null,
|
||||
version: null,
|
||||
displayName: 'Lorekeeper',
|
||||
summary: 'Portable memory for your agent.',
|
||||
})
|
||||
|
||||
const handler = (await import('./soul.png')).default
|
||||
await expect(handler({} as never)).resolves.toEqual(new Uint8Array([4, 5, 6]))
|
||||
|
||||
expect(fetchSoulOgMetaMock).toHaveBeenCalledWith(
|
||||
'lorekeeper',
|
||||
'https://souls-preview.example.com',
|
||||
)
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, 'Cache-Control', 'public, max-age=3600')
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, 'Content-Type', 'image/png')
|
||||
expect(buildSoulOgSvgMock).toHaveBeenCalledWith({
|
||||
markDataUrl: 'data:image/png;base64,AAA=',
|
||||
title: 'Lorekeeper',
|
||||
description: 'Portable memory for your agent.',
|
||||
ownerLabel: 'SoulHub',
|
||||
versionLabel: 'latest',
|
||||
footer: 'souls/lorekeeper',
|
||||
})
|
||||
expect(freeMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('prefers explicit owner and version query params', async () => {
|
||||
getQueryMock.mockReturnValue({
|
||||
slug: 'lorekeeper',
|
||||
owner: 'steipete',
|
||||
version: '2.0.0',
|
||||
title: 'Lorekeeper',
|
||||
description: 'Portable memory for your agent.',
|
||||
})
|
||||
|
||||
const handler = (await import('./soul.png')).default
|
||||
await handler({} as never)
|
||||
|
||||
expect(fetchSoulOgMetaMock).not.toHaveBeenCalled()
|
||||
expect(setHeaderMock).toHaveBeenCalledWith({}, 'Cache-Control', 'public, max-age=31536000, immutable')
|
||||
expect(buildSoulOgSvgMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ownerLabel: '@steipete',
|
||||
versionLabel: 'v2.0.0',
|
||||
footer: '@steipete/lorekeeper',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { Id } from '../../convex/_generated/dataModel'
|
||||
import { vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SkillDetailPage } from '../components/SkillDetailPage'
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
@@ -129,6 +129,78 @@ describe('SkillDetailPage', () => {
|
||||
expect(screen.getByRole('button', { name: 'Files' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not refetch readme when SSR data already matches the latest version', async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
if (args && typeof args === 'object' && 'skillId' in args) return []
|
||||
return undefined
|
||||
})
|
||||
|
||||
render(
|
||||
<SkillDetailPage
|
||||
slug="weather"
|
||||
initialData={{
|
||||
result: {
|
||||
skill: {
|
||||
_id: skillId,
|
||||
_creationTime: 0,
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
ownerUserId: ownerId,
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {
|
||||
stars: 12,
|
||||
downloads: 34,
|
||||
installsCurrent: 5,
|
||||
installsAllTime: 8,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
},
|
||||
owner: {
|
||||
_id: ownerId,
|
||||
_creationTime: 0,
|
||||
handle: 'steipete',
|
||||
name: 'Peter',
|
||||
},
|
||||
latestVersion: {
|
||||
_id: versionId,
|
||||
_creationTime: 0,
|
||||
skillId,
|
||||
version: '1.0.0',
|
||||
fingerprint: 'abc',
|
||||
changelog: 'Initial release',
|
||||
parsed: { license: 'MIT-0', frontmatter: {} },
|
||||
files: [
|
||||
{
|
||||
path: 'SKILL.md',
|
||||
size: 10,
|
||||
storageId,
|
||||
sha256: 'abc',
|
||||
contentType: 'text/markdown',
|
||||
},
|
||||
],
|
||||
createdBy: ownerId,
|
||||
createdAt: 0,
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: '# Weather',
|
||||
readmeError: null,
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Weather' })).toBeTruthy()
|
||||
expect(screen.getByText(/Get current weather\./i)).toBeTruthy()
|
||||
expect(getReadmeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows not found when skill query resolves to null', async () => {
|
||||
useQueryMock.mockImplementation((_fn: unknown, args: unknown) => {
|
||||
if (args === 'skip') return undefined
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
process.env.VITE_CONVEX_URL = process.env.VITE_CONVEX_URL || 'https://example.convex.cloud'
|
||||
|
||||
const fetchSkillPageDataMock = vi.fn()
|
||||
|
||||
vi.mock('../convex/client', () => ({
|
||||
@@ -7,6 +9,10 @@ vi.mock('../convex/client', () => ({
|
||||
convexHttp: {},
|
||||
}))
|
||||
|
||||
vi.mock('../components/SkillDetailPage', () => ({
|
||||
SkillDetailPage: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
createFileRoute:
|
||||
() =>
|
||||
@@ -22,14 +28,25 @@ vi.mock('../lib/skillPage', () => ({
|
||||
fetchSkillPageData: (...args: unknown[]) => fetchSkillPageDataMock(...args),
|
||||
}))
|
||||
|
||||
import { Route } from '../routes/$owner/$slug'
|
||||
|
||||
async function runLoader(params: { owner: string; slug: string }) {
|
||||
const route = Route as unknown as {
|
||||
async function loadRoute() {
|
||||
return (await import('../routes/$owner/$slug')).Route as unknown as {
|
||||
__config: {
|
||||
loader?: (args: { params: { owner: string; slug: string } }) => Promise<unknown>
|
||||
head?: (args: {
|
||||
params: { owner: string; slug: string }
|
||||
loaderData?: {
|
||||
owner?: string | null
|
||||
displayName?: string | null
|
||||
summary?: string | null
|
||||
version?: string | null
|
||||
}
|
||||
}) => unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runLoader(params: { owner: string; slug: string }) {
|
||||
const route = await loadRoute()
|
||||
const loader = route.__config.loader as (args: {
|
||||
params: { owner: string; slug: string }
|
||||
}) => Promise<unknown>
|
||||
@@ -41,6 +58,18 @@ async function runLoader(params: { owner: string; slug: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function runHead(
|
||||
params: { owner: string; slug: string },
|
||||
loaderData?: {
|
||||
owner?: string | null
|
||||
displayName?: string | null
|
||||
summary?: string | null
|
||||
version?: string | null
|
||||
},
|
||||
) {
|
||||
return loadRoute().then((route) => route.__config.head?.({ params, loaderData }))
|
||||
}
|
||||
|
||||
describe('skill route loader', () => {
|
||||
beforeEach(() => {
|
||||
fetchSkillPageDataMock.mockReset()
|
||||
@@ -139,4 +168,120 @@ describe('skill route loader', () => {
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('does not redirect when canonical owner data is missing', async () => {
|
||||
fetchSkillPageDataMock.mockResolvedValue({
|
||||
owner: null,
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
version: '1.0.0',
|
||||
initialData: {
|
||||
result: {
|
||||
resolvedSlug: 'weather-pro',
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather-pro',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
ownerUserId: 'users:1',
|
||||
tags: {},
|
||||
badges: {},
|
||||
stats: {},
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
_creationTime: 0,
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: null,
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
},
|
||||
readme: '# Weather',
|
||||
readmeError: null,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(runLoader({ owner: 'legacy-owner', slug: 'weather' })).resolves.toEqual({
|
||||
owner: 'legacy-owner',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
version: '1.0.0',
|
||||
initialData: expect.objectContaining({
|
||||
readme: '# Weather',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to params when loader data is empty', async () => {
|
||||
fetchSkillPageDataMock.mockResolvedValue({
|
||||
owner: null,
|
||||
displayName: null,
|
||||
summary: null,
|
||||
version: null,
|
||||
initialData: null,
|
||||
})
|
||||
|
||||
await expect(runLoader({ owner: 'steipete', slug: 'weather' })).resolves.toEqual({
|
||||
owner: 'steipete',
|
||||
displayName: null,
|
||||
summary: null,
|
||||
version: null,
|
||||
initialData: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds canonical and og metadata from loader data', async () => {
|
||||
const head = (await runHead(
|
||||
{ owner: 'legacy-owner', slug: 'weather' },
|
||||
{
|
||||
owner: 'steipete',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
version: '1.0.0',
|
||||
},
|
||||
)) as { links: Array<{ rel: string; href: string }>; meta?: unknown[] }
|
||||
|
||||
expect(head).toEqual(
|
||||
expect.objectContaining({
|
||||
links: [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: 'https://clawhub.ai/steipete/weather',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(head?.meta).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ title: 'Weather — ClawHub' },
|
||||
{ name: 'description', content: 'Get current weather.' },
|
||||
{ property: 'og:url', content: 'https://clawhub.ai/steipete/weather' },
|
||||
{
|
||||
property: 'og:image',
|
||||
content:
|
||||
'https://clawhub.ai/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0',
|
||||
},
|
||||
{
|
||||
name: 'twitter:image',
|
||||
content:
|
||||
'https://clawhub.ai/og/skill.png?v=5&slug=weather&owner=steipete&version=1.0.0',
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to route params when head loader data is absent', async () => {
|
||||
await expect(runHead({ owner: 'steipete', slug: 'weather' })).resolves.toEqual({
|
||||
links: [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: 'https://clawhub.ai/steipete/weather',
|
||||
},
|
||||
],
|
||||
meta: expect.arrayContaining([
|
||||
{ title: 'weather — ClawHub' },
|
||||
{ property: 'og:url', content: 'https://clawhub.ai/steipete/weather' },
|
||||
]),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -116,4 +116,113 @@ describe('fetchSkillPageData', () => {
|
||||
})
|
||||
expect((actionMock as Mock).mock.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to owner name when handle is missing', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
},
|
||||
latestVersion: {
|
||||
_id: 'skillVersions:1',
|
||||
version: '1.0.0',
|
||||
},
|
||||
owner: {
|
||||
_id: 'users:1',
|
||||
handle: null,
|
||||
name: 'Peter Steinberger',
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
})
|
||||
actionMock.mockResolvedValue({ text: '# Weather' })
|
||||
|
||||
await expect(fetchSkillPageData('weather')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
owner: 'Peter Steinberger',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('skips readme fetch when there is no latest version', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
},
|
||||
latestVersion: null,
|
||||
owner: {
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
name: 'Peter',
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
})
|
||||
|
||||
await expect(fetchSkillPageData('weather')).resolves.toEqual({
|
||||
owner: 'steipete',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
version: null,
|
||||
initialData: {
|
||||
result: expect.objectContaining({
|
||||
skill: expect.objectContaining({ slug: 'weather' }),
|
||||
latestVersion: null,
|
||||
}),
|
||||
readme: null,
|
||||
readmeError: null,
|
||||
},
|
||||
})
|
||||
expect((actionMock as Mock).mock.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses default readme error for non-Error failures', async () => {
|
||||
queryMock.mockResolvedValue({
|
||||
skill: {
|
||||
_id: 'skills:1',
|
||||
slug: 'weather',
|
||||
displayName: 'Weather',
|
||||
summary: 'Get current weather.',
|
||||
},
|
||||
latestVersion: {
|
||||
_id: 'skillVersions:1',
|
||||
version: '1.0.0',
|
||||
},
|
||||
owner: {
|
||||
_id: 'users:1',
|
||||
handle: 'steipete',
|
||||
name: 'Peter',
|
||||
},
|
||||
forkOf: null,
|
||||
canonical: null,
|
||||
})
|
||||
actionMock.mockRejectedValue('boom')
|
||||
|
||||
await expect(fetchSkillPageData('weather')).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
initialData: expect.objectContaining({
|
||||
readme: null,
|
||||
readmeError: 'Failed to load SKILL.md',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty snapshot when the skill query throws', async () => {
|
||||
queryMock.mockRejectedValue(new Error('network down'))
|
||||
|
||||
await expect(fetchSkillPageData('weather')).resolves.toEqual({
|
||||
owner: null,
|
||||
displayName: null,
|
||||
summary: null,
|
||||
version: null,
|
||||
initialData: null,
|
||||
})
|
||||
expect((actionMock as Mock).mock.calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user