test: add ssr and og regression coverage

This commit is contained in:
Peter Steinberger
2026-03-15 20:49:18 -07:00
parent f8104fd759
commit 26e6a435f2
6 changed files with 720 additions and 5 deletions
+86
View File
@@ -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'),
]),
)
})
})
+155
View File
@@ -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',
}),
)
})
})
+148
View File
@@ -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',
}),
)
})
})
+73 -1
View File
@@ -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
+149 -4
View File
@@ -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' },
]),
})
})
})
+109
View File
@@ -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)
})
})