tests: add regression test for skills list (#47819)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Adds regression tests for something we noticed today. AI skills weren't
being loaded on https://supabase.com/docs/guides/ai-tools/ai-skills, so
while we fixed it, we wanted to make sure we could identify this faster.

## What is the current behavior?

Less tests. AI skills loading and not 

## What is the new behavior?

Two tests, no new workflows — both ride existing CI:

- **Unit test** (`AiSkills.utils.test.ts`) mocks GitHub, checks the
parsing/shaping logic (dir filtering, frontmatter, install command,
sorting, empty→fallback). Runs on every PR.
- **Smoke test** (`AiSkillsIndex.smoke.test.ts`) hits the live page and
asserts the skills table actually rendered. Runs in the daily docs smoke
job, and can be pointed at any environment via `DOCS_SMOKE_URL`.

Small supporting change: `getAiSkillsImpl` is now exported so the unit
test can call it directly.

## Additional Context

- Fixes
https://linear.app/supabase/issue/AI-915/skills-docs-page-fails-to-load-available-skills

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Tests**
* Added coverage for AI Skills loading, including directory filtering,
metadata parsing, install command generation, fallback descriptions, and
error handling.
* Added a smoke test confirming the AI Skills documentation page loads
successfully and displays install commands.
* **Refactor**
* Made AI Skills loading functionality accessible for direct testing
while preserving existing behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Raúl Barroso authored and GitHub committed 2026-07-29 17:19:50 +00:00
1 parent 66658ab339
commit 01958e520f
4 files changed
+107 -2

No files matched your search

@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getAiSkillsImpl } from './AiSkills.utils'
const { readFileMock } = vi.hoisted(() => ({
readFileMock: vi.fn(),
}))
vi.mock('node:fs/promises', () => ({
readFile: readFileMock,
}))
describe('getAiSkillsImpl', () => {
beforeEach(() => {
readFileMock.mockReset()
})
it('parses the generated skills JSON', async () => {
const skills = [
{
name: 'supabase',
description: 'Work with Supabase',
installCommand: 'npx skills add supabase/agent-skills --skill supabase',
},
{
name: 'supabase-postgres-best-practices',
description: 'Postgres best practices',
installCommand:
'npx skills add supabase/agent-skills --skill supabase-postgres-best-practices',
},
]
readFileMock.mockResolvedValue(JSON.stringify(skills))
await expect(getAiSkillsImpl()).resolves.toEqual(skills)
})
it('propagates errors reading the generated file', async () => {
readFileMock.mockRejectedValue(new Error('ENOENT'))
await expect(getAiSkillsImpl()).rejects.toThrow('ENOENT')
})
it('throws when the generated JSON is not an array', async () => {
readFileMock.mockResolvedValue(JSON.stringify({ name: 'supabase' }))
await expect(getAiSkillsImpl()).rejects.toThrow('Malformed ai-skills.json')
})
it('throws when an entry is missing required string fields', async () => {
readFileMock.mockResolvedValue(JSON.stringify([{ name: 'supabase', description: 'x' }]))
await expect(getAiSkillsImpl()).rejects.toThrow('Malformed ai-skills.json')
})
})
@@ -9,9 +9,25 @@ interface SkillSummary {
installCommand: string
}
async function getAiSkillsImpl(): Promise<SkillSummary[]> {
function isSkillSummary(value: unknown): value is SkillSummary {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as SkillSummary).name === 'string' &&
typeof (value as SkillSummary).description === 'string' &&
typeof (value as SkillSummary).installCommand === 'string'
)
}
export async function getAiSkillsImpl(): Promise<SkillSummary[]> {
const raw = await readFile(join(GENERATED_DIRECTORY, 'ai-skills.json'), 'utf-8')
return JSON.parse(raw)
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed) || !parsed.every(isSkillSummary)) {
throw new Error('Malformed ai-skills.json: expected an array of SkillSummary objects')
}
return parsed
}
export const getAiSkills = cache(getAiSkillsImpl)
@@ -0,0 +1,31 @@
// Guards against the docs GitHub App losing access to supabase/agent-skills,
// which 404s silently and renders an empty table.
import { load } from 'cheerio'
import { describe, expect, it } from 'vitest'
// Override to target a preview deploy or localhost; defaults to production.
const DOCS_BASE_URL = process.env.DOCS_SMOKE_URL ?? 'https://supabase.com'
const AI_SKILLS_URL = `${DOCS_BASE_URL.replace(/\/$/, '')}/docs/guides/ai-tools/ai-skills`
describe('prod smoke test: agent skills load on the AI Skills page', () => {
it('renders the skills table with at least one skill and no fallback', async () => {
const result = await fetch(AI_SKILLS_URL, { signal: AbortSignal.timeout(30_000) })
expect(result.status).toBe(200)
const html = await result.text()
expect(html).not.toContain('Unable to load AI skills at the moment.')
// The install command only appears on real skill rows.
const $ = load(html)
const installCommands = $('code')
.map(function () {
return $(this).text()
})
.get()
.filter((text) => text.startsWith('npx skills add supabase/agent-skills --skill '))
expect(installCommands.length).toBeGreaterThan(0)
// Test timeout must outlive the fetch abort so the network error surfaces
// instead of a generic vitest timeout.
}, 45_000)
})
+4
View File
@@ -16,6 +16,10 @@
"build:markdown": {
"outputs": ["public/markdown/**", "public/markdown/manifest.json"],
},
"test": {
// Lets the smoke tests target a preview deploy or localhost instead of production.
"env": ["DOCS_SMOKE_URL"],
},
"build": {
"dependsOn": ["^build", "codegen:examples", "codegen:references", "build:markdown"],
"env": [