Skip to content

Commit e77771d

Browse files
fix: quizMe exercise selection for missing input (#600)
* Fixes EPICSHOP-EP: guard quizMe exercise input Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fixes EPICSHOP-EP: tighten quizMe test typing Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 66e9b84 commit e77771d

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

packages/workshop-mcp/src/prompts.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { invariant } from '@epic-web/invariant'
12
import { getExercises } from '@epic-web/workshop-utils/apps.server'
23
import { getWorkshopConfig } from '@epic-web/workshop-utils/config.server'
34
import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
@@ -16,6 +17,7 @@ export const quizMeInputSchema: PromptInputSchema = {
1617
workshopDirectory: workshopDirectoryInputSchema,
1718
exerciseNumber: z
1819
.string()
20+
.trim()
1921
.optional()
2022
.describe(
2123
'Exercise number to quiz on (e.g., "4"). Omit for a random exercise.',
@@ -31,8 +33,15 @@ export async function quizMe({
3133
}): Promise<GetPromptResult> {
3234
const workshopRoot = await handleWorkshopDirectory(workshopDirectory)
3335
const config = getWorkshopConfig()
34-
let exerciseNumber = Number(providedExerciseNumber)
35-
if (!providedExerciseNumber) {
36+
let exerciseNumber: number | undefined
37+
if (providedExerciseNumber) {
38+
exerciseNumber = Number(providedExerciseNumber)
39+
invariant(
40+
Number.isFinite(exerciseNumber),
41+
`Exercise number must be a number, received "${providedExerciseNumber}".`,
42+
)
43+
}
44+
if (exerciseNumber === undefined) {
3645
const exercises = await getExercises()
3746
const randomExercise =
3847
exercises[Math.floor(Math.random() * exercises.length)]
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { expect, test, vi } from 'vitest'
2+
import { quizMe } from './prompts.ts'
3+
import { exerciseContextResource } from './resources.ts'
4+
5+
vi.mock('@epic-web/workshop-utils/apps.server', () => ({
6+
getExercises: vi.fn(async () => [{ exerciseNumber: 3 }]),
7+
}))
8+
9+
vi.mock('@epic-web/workshop-utils/config.server', () => ({
10+
getWorkshopConfig: vi.fn(() => ({
11+
title: 'Test Workshop',
12+
subtitle: 'Testing Sub',
13+
})),
14+
}))
15+
16+
vi.mock('./resources.ts', () => ({
17+
exerciseContextResource: {
18+
getResource: vi.fn(async ({ exerciseNumber }: { exerciseNumber: number }) => ({
19+
uri: `epicshop://exercise/${exerciseNumber}`,
20+
mimeType: 'text/plain',
21+
text: `exercise ${exerciseNumber}`,
22+
})),
23+
},
24+
}))
25+
26+
vi.mock('./utils.ts', async () => {
27+
const actual = await vi.importActual<typeof import('./utils.ts')>('./utils.ts')
28+
return {
29+
...actual,
30+
handleWorkshopDirectory: vi.fn(async (workshopDirectory: string) => {
31+
return workshopDirectory
32+
}),
33+
}
34+
})
35+
36+
test('quizMe chooses a random exercise when none provided (aha)', async () => {
37+
const resultPromise = quizMe({ workshopDirectory: '/workshop' })
38+
39+
await expect(resultPromise).resolves.toMatchObject({
40+
messages: [
41+
{ role: 'user', content: { type: 'text' } },
42+
{ role: 'user', content: { type: 'resource' } },
43+
],
44+
})
45+
46+
const result = await resultPromise
47+
const getResource = vi.mocked(exerciseContextResource.getResource)
48+
49+
expect(getResource).toHaveBeenCalledWith({
50+
workshopDirectory: '/workshop',
51+
exerciseNumber: 3,
52+
})
53+
const firstMessage = result.messages[0]
54+
expect(firstMessage?.content.type).toBe('text')
55+
if (!firstMessage || firstMessage.content.type !== 'text') {
56+
throw new Error('Expected first message to be text')
57+
}
58+
expect(firstMessage.content.text).toContain('exercise 3')
59+
})
60+
61+
test('quizMe rejects non-numeric exercise numbers (aha)', async () => {
62+
const resultPromise = quizMe({
63+
workshopDirectory: '/workshop',
64+
exerciseNumber: 'not-a-number',
65+
})
66+
67+
await expect(resultPromise).rejects.toThrow(
68+
'Exercise number must be a number',
69+
)
70+
})

0 commit comments

Comments
 (0)