|
| 1 | +import { readFile } from 'node:fs/promises'; |
| 2 | + |
| 3 | +import matter from 'gray-matter'; |
| 4 | + |
1 | 5 | import { parsePrompt } from './parser/index.js'; |
2 | 6 | import type { PromptAsset, ResolvedPromptAsset } from './schema/index.js'; |
3 | 7 |
|
| 8 | +export interface PromptTestCase<TResponse = unknown> { |
| 9 | + name: string; |
| 10 | + variables?: Record<string, string>; |
| 11 | + response?: TResponse; |
| 12 | + expected_response?: TResponse; |
| 13 | +} |
| 14 | + |
| 15 | +export interface PromptTestSidecar<TResponse = unknown> { |
| 16 | + cases: Array<PromptTestCase<TResponse>>; |
| 17 | +} |
| 18 | + |
| 19 | +export interface PromptTestRenderer { |
| 20 | + renderPrompt(options: { |
| 21 | + path?: string; |
| 22 | + source?: string; |
| 23 | + provider: string; |
| 24 | + environment?: string; |
| 25 | + tier?: string; |
| 26 | + variables?: Record<string, string>; |
| 27 | + strict?: boolean; |
| 28 | + }): Promise<unknown>; |
| 29 | +} |
| 30 | + |
4 | 31 | /** |
5 | 32 | * Create a mock PromptAsset for testing. |
6 | 33 | */ |
@@ -47,3 +74,119 @@ export function parseTestPrompt(source: string): PromptAsset { |
47 | 74 | const { asset } = parsePrompt(source); |
48 | 75 | return asset; |
49 | 76 | } |
| 77 | + |
| 78 | +/** |
| 79 | + * Parse a .test.yaml sidecar file. |
| 80 | + */ |
| 81 | +export function parsePromptTestSidecar<TResponse = unknown>(source: string): PromptTestSidecar<TResponse> { |
| 82 | + const parsed = matter(`---\n${source.trim()}\n---\n`); |
| 83 | + const data = parsed.data as Partial<PromptTestSidecar<TResponse>>; |
| 84 | + |
| 85 | + if (!Array.isArray(data.cases)) { |
| 86 | + throw new Error('Prompt test sidecar must include a "cases" array.'); |
| 87 | + } |
| 88 | + |
| 89 | + return { |
| 90 | + cases: data.cases.map((testCase, index) => { |
| 91 | + if (!testCase || typeof testCase !== 'object') { |
| 92 | + throw new Error(`Prompt test case at index ${index} must be an object.`); |
| 93 | + } |
| 94 | + |
| 95 | + if (typeof testCase.name !== 'string' || testCase.name.length === 0) { |
| 96 | + throw new Error(`Prompt test case at index ${index} must include a non-empty "name".`); |
| 97 | + } |
| 98 | + |
| 99 | + return testCase; |
| 100 | + }), |
| 101 | + }; |
| 102 | +} |
| 103 | + |
| 104 | +/** |
| 105 | + * Load a .test.yaml sidecar file from disk. |
| 106 | + */ |
| 107 | +export async function loadPromptTestSidecar<TResponse = unknown>( |
| 108 | + filePath: string | URL, |
| 109 | +): Promise<PromptTestSidecar<TResponse>> { |
| 110 | + return parsePromptTestSidecar<TResponse>(await readFile(filePath, 'utf-8')); |
| 111 | +} |
| 112 | + |
| 113 | +/** |
| 114 | + * Find a named test case in a sidecar. |
| 115 | + */ |
| 116 | +export function getPromptTestCase<TResponse = unknown>( |
| 117 | + sidecar: PromptTestSidecar<TResponse> | Array<PromptTestCase<TResponse>>, |
| 118 | + name: string, |
| 119 | +): PromptTestCase<TResponse> { |
| 120 | + const cases = Array.isArray(sidecar) ? sidecar : sidecar.cases; |
| 121 | + const testCase = cases.find((candidate) => candidate.name === name); |
| 122 | + |
| 123 | + if (!testCase) { |
| 124 | + throw new Error(`Prompt test case "${name}" was not found.`); |
| 125 | + } |
| 126 | + |
| 127 | + return testCase; |
| 128 | +} |
| 129 | + |
| 130 | +/** |
| 131 | + * Read the canned response for a named case. |
| 132 | + */ |
| 133 | +export function getHardcodedPromptResponse<TResponse = unknown>( |
| 134 | + sidecar: PromptTestSidecar<TResponse> | Array<PromptTestCase<TResponse>>, |
| 135 | + name: string, |
| 136 | +): TResponse { |
| 137 | + const testCase = getPromptTestCase(sidecar, name); |
| 138 | + const response = testCase.response ?? testCase.expected_response; |
| 139 | + |
| 140 | + if (response === undefined) { |
| 141 | + throw new Error(`Prompt test case "${name}" does not define a "response".`); |
| 142 | + } |
| 143 | + |
| 144 | + return response; |
| 145 | +} |
| 146 | + |
| 147 | +/** |
| 148 | + * Create a small responder for unit tests and local development flows. |
| 149 | + */ |
| 150 | +export function createHardcodedPromptResponder<TResponse = unknown>( |
| 151 | + sidecar: PromptTestSidecar<TResponse> | Array<PromptTestCase<TResponse>>, |
| 152 | +): (name: string) => TResponse { |
| 153 | + return (name) => getHardcodedPromptResponse(sidecar, name); |
| 154 | +} |
| 155 | + |
| 156 | +/** |
| 157 | + * Render a prompt using variables from a named sidecar case. |
| 158 | + */ |
| 159 | +export async function renderPromptTestCase<TResponse = unknown>( |
| 160 | + kit: PromptTestRenderer, |
| 161 | + options: { |
| 162 | + sidecar: PromptTestSidecar<TResponse> | Array<PromptTestCase<TResponse>>; |
| 163 | + caseName: string; |
| 164 | + path?: string; |
| 165 | + source?: string; |
| 166 | + provider: string; |
| 167 | + environment?: string; |
| 168 | + tier?: string; |
| 169 | + strict?: boolean; |
| 170 | + }, |
| 171 | +): Promise<{ |
| 172 | + testCase: PromptTestCase<TResponse>; |
| 173 | + rendered: unknown; |
| 174 | + response?: TResponse; |
| 175 | +}> { |
| 176 | + const testCase = getPromptTestCase(options.sidecar, options.caseName); |
| 177 | + const rendered = await kit.renderPrompt({ |
| 178 | + path: options.path, |
| 179 | + source: options.source, |
| 180 | + provider: options.provider, |
| 181 | + environment: options.environment, |
| 182 | + tier: options.tier, |
| 183 | + variables: testCase.variables, |
| 184 | + strict: options.strict, |
| 185 | + }); |
| 186 | + |
| 187 | + return { |
| 188 | + testCase, |
| 189 | + rendered, |
| 190 | + response: testCase.response ?? testCase.expected_response, |
| 191 | + }; |
| 192 | +} |
0 commit comments