@prompd/test 0.5.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/EvaluatorEngine.d.ts +32 -0
  2. package/dist/EvaluatorEngine.d.ts.map +1 -0
  3. package/dist/EvaluatorEngine.js +97 -0
  4. package/dist/TestDiscovery.d.ts +28 -0
  5. package/dist/TestDiscovery.d.ts.map +1 -0
  6. package/dist/TestDiscovery.js +137 -0
  7. package/dist/TestParser.d.ts +25 -0
  8. package/dist/TestParser.d.ts.map +1 -0
  9. package/dist/TestParser.js +187 -0
  10. package/dist/TestRunner.d.ts +57 -0
  11. package/dist/TestRunner.d.ts.map +1 -0
  12. package/dist/TestRunner.js +463 -0
  13. package/dist/cli-types.d.ts +62 -0
  14. package/dist/cli-types.d.ts.map +1 -0
  15. package/dist/cli-types.js +6 -0
  16. package/dist/evaluators/NlpEvaluator.d.ts +26 -0
  17. package/dist/evaluators/NlpEvaluator.d.ts.map +1 -0
  18. package/dist/evaluators/NlpEvaluator.js +145 -0
  19. package/dist/evaluators/PrmdEvaluator.d.ts +42 -0
  20. package/dist/evaluators/PrmdEvaluator.d.ts.map +1 -0
  21. package/dist/evaluators/PrmdEvaluator.js +265 -0
  22. package/dist/evaluators/ScriptEvaluator.d.ts +19 -0
  23. package/dist/evaluators/ScriptEvaluator.d.ts.map +1 -0
  24. package/dist/evaluators/ScriptEvaluator.js +161 -0
  25. package/dist/evaluators/types.d.ts +19 -0
  26. package/dist/evaluators/types.d.ts.map +1 -0
  27. package/dist/evaluators/types.js +5 -0
  28. package/dist/index.d.ts +25 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +33 -0
  31. package/dist/reporters/ConsoleReporter.d.ts +17 -0
  32. package/dist/reporters/ConsoleReporter.d.ts.map +1 -0
  33. package/dist/reporters/ConsoleReporter.js +85 -0
  34. package/dist/reporters/JsonReporter.d.ts +11 -0
  35. package/dist/reporters/JsonReporter.d.ts.map +1 -0
  36. package/dist/reporters/JsonReporter.js +18 -0
  37. package/dist/reporters/JunitReporter.d.ts +15 -0
  38. package/dist/reporters/JunitReporter.d.ts.map +1 -0
  39. package/dist/reporters/JunitReporter.js +89 -0
  40. package/dist/reporters/types.d.ts +8 -0
  41. package/dist/reporters/types.d.ts.map +1 -0
  42. package/dist/reporters/types.js +5 -0
  43. package/dist/types.d.ts +115 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +5 -0
  46. package/package.json +34 -0
  47. package/src/EvaluatorEngine.ts +130 -0
  48. package/src/TestDiscovery.ts +133 -0
  49. package/src/TestParser.ts +235 -0
  50. package/src/TestRunner.ts +516 -0
  51. package/src/cli-types.ts +92 -0
  52. package/src/evaluators/NlpEvaluator.ts +184 -0
  53. package/src/evaluators/PrmdEvaluator.ts +284 -0
  54. package/src/evaluators/ScriptEvaluator.ts +149 -0
  55. package/src/evaluators/types.ts +24 -0
  56. package/src/index.ts +76 -0
  57. package/src/reporters/ConsoleReporter.ts +100 -0
  58. package/src/reporters/JsonReporter.ts +21 -0
  59. package/src/reporters/JunitReporter.ts +113 -0
  60. package/src/reporters/types.ts +9 -0
  61. package/src/types.ts +133 -0
  62. package/tsconfig.json +20 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Discovers .test.prmd files and pairs them with their source .prmd files.
3
+ */
4
+
5
+ import * as path from 'path';
6
+ import * as fs from 'fs';
7
+ import { glob } from 'glob';
8
+ import { TestParser } from './TestParser';
9
+ import type { TestSuite } from './types';
10
+
11
+ export interface DiscoveryResult {
12
+ suites: TestSuite[];
13
+ errors: DiscoveryError[];
14
+ }
15
+
16
+ export interface DiscoveryError {
17
+ filePath: string;
18
+ message: string;
19
+ }
20
+
21
+ export class TestDiscovery {
22
+ private parser: TestParser;
23
+
24
+ constructor() {
25
+ this.parser = new TestParser();
26
+ }
27
+
28
+ /**
29
+ * Discover test suites from a target path.
30
+ *
31
+ * - If targetPath is a .test.prmd file, parse it directly.
32
+ * - If targetPath is a .prmd file, look for a colocated .test.prmd sidecar.
33
+ * - If targetPath is a directory, glob for all .test.prmd files recursively.
34
+ */
35
+ async discover(targetPath: string): Promise<DiscoveryResult> {
36
+ const resolved = path.resolve(targetPath);
37
+ const suites: TestSuite[] = [];
38
+ const errors: DiscoveryError[] = [];
39
+
40
+ if (!fs.existsSync(resolved)) {
41
+ errors.push({ filePath: resolved, message: 'Path does not exist' });
42
+ return { suites, errors };
43
+ }
44
+
45
+ const stat = fs.statSync(resolved);
46
+
47
+ if (stat.isDirectory()) {
48
+ return this.discoverDirectory(resolved);
49
+ }
50
+
51
+ if (resolved.endsWith('.test.prmd')) {
52
+ return this.discoverTestFile(resolved);
53
+ }
54
+
55
+ if (resolved.endsWith('.prmd')) {
56
+ return this.discoverFromSource(resolved);
57
+ }
58
+
59
+ errors.push({
60
+ filePath: resolved,
61
+ message: 'Target must be a .prmd file, .test.prmd file, or directory',
62
+ });
63
+ return { suites, errors };
64
+ }
65
+
66
+ private async discoverDirectory(dirPath: string): Promise<DiscoveryResult> {
67
+ const suites: TestSuite[] = [];
68
+ const errors: DiscoveryError[] = [];
69
+
70
+ const pattern = '**/*.test.prmd';
71
+ const testFiles = await glob(pattern, {
72
+ cwd: dirPath,
73
+ absolute: true,
74
+ nodir: true,
75
+ windowsPathsNoEscape: true,
76
+ });
77
+
78
+ for (const testFile of testFiles) {
79
+ const normalized = testFile.replace(/\\/g, '/');
80
+ const result = await this.discoverTestFile(normalized);
81
+ suites.push(...result.suites);
82
+ errors.push(...result.errors);
83
+ }
84
+
85
+ return { suites, errors };
86
+ }
87
+
88
+ private async discoverTestFile(testFilePath: string): Promise<DiscoveryResult> {
89
+ const suites: TestSuite[] = [];
90
+ const errors: DiscoveryError[] = [];
91
+
92
+ try {
93
+ const content = fs.readFileSync(testFilePath, 'utf-8');
94
+ const suite = this.parser.parse(content, testFilePath);
95
+
96
+ // Validate that the target .prmd file exists
97
+ if (!fs.existsSync(suite.target)) {
98
+ errors.push({
99
+ filePath: testFilePath,
100
+ message: `Target prompt file not found: ${suite.target}`,
101
+ });
102
+ return { suites, errors };
103
+ }
104
+
105
+ suites.push(suite);
106
+ } catch (err) {
107
+ errors.push({
108
+ filePath: testFilePath,
109
+ message: err instanceof Error ? err.message : String(err),
110
+ });
111
+ }
112
+
113
+ return { suites, errors };
114
+ }
115
+
116
+ private async discoverFromSource(sourcePath: string): Promise<DiscoveryResult> {
117
+ const dir = path.dirname(sourcePath);
118
+ const base = path.basename(sourcePath, '.prmd');
119
+ const testFilePath = path.join(dir, `${base}.test.prmd`);
120
+
121
+ if (!fs.existsSync(testFilePath)) {
122
+ return {
123
+ suites: [],
124
+ errors: [{
125
+ filePath: sourcePath,
126
+ message: `No colocated test file found: ${testFilePath}`,
127
+ }],
128
+ };
129
+ }
130
+
131
+ return this.discoverTestFile(testFilePath);
132
+ }
133
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Parses .test.prmd files into TestSuite structures.
3
+ *
4
+ * A .test.prmd file has YAML frontmatter (test definitions) and
5
+ * an optional content block (evaluator prompt for prmd evaluators).
6
+ */
7
+
8
+ import * as path from 'path';
9
+ import * as YAML from 'yaml';
10
+ import type { TestSuite, TestCase, AssertionDef, EvaluatorType, NlpCheck } from './types';
11
+
12
+ const VALID_EVALUATOR_TYPES: EvaluatorType[] = ['nlp', 'script', 'prmd'];
13
+ const VALID_NLP_CHECKS: NlpCheck[] = [
14
+ 'contains', 'not_contains', 'matches',
15
+ 'max_tokens', 'min_tokens', 'starts_with', 'ends_with'
16
+ ];
17
+
18
+ interface ParsedFrontmatter {
19
+ name?: string;
20
+ description?: string;
21
+ target?: string;
22
+ tests?: RawTestCase[];
23
+ }
24
+
25
+ interface RawTestCase {
26
+ name?: string;
27
+ params?: Record<string, unknown>;
28
+ assert?: RawAssertionDef[];
29
+ expect_error?: boolean;
30
+ }
31
+
32
+ interface RawAssertionDef {
33
+ evaluator?: string;
34
+ check?: string;
35
+ value?: unknown;
36
+ run?: string;
37
+ prompt?: string;
38
+ provider?: string;
39
+ model?: string;
40
+ }
41
+
42
+ export class TestParser {
43
+ /**
44
+ * Parse a .test.prmd file's raw content into a TestSuite.
45
+ */
46
+ parse(content: string, testFilePath: string): TestSuite {
47
+ const normalized = content.replace(/\r\n/g, '\n');
48
+ const { frontmatter, body } = this.splitFrontmatter(normalized);
49
+
50
+ if (!frontmatter) {
51
+ throw new TestParseError('Missing YAML frontmatter in .test.prmd file', testFilePath);
52
+ }
53
+
54
+ let parsed: ParsedFrontmatter;
55
+ try {
56
+ parsed = YAML.parse(frontmatter) as ParsedFrontmatter;
57
+ } catch (err) {
58
+ const message = err instanceof Error ? err.message : String(err);
59
+ throw new TestParseError(`Invalid YAML frontmatter: ${message}`, testFilePath);
60
+ }
61
+
62
+ if (!parsed || typeof parsed !== 'object') {
63
+ throw new TestParseError('Frontmatter must be a YAML object', testFilePath);
64
+ }
65
+
66
+ const name = parsed.name || path.basename(testFilePath, '.test.prmd');
67
+ const target = this.resolveTarget(parsed.target, testFilePath);
68
+ const tests = this.parseTests(parsed.tests, testFilePath);
69
+ const evaluatorPrompt = body.trim() || undefined;
70
+
71
+ return {
72
+ name,
73
+ description: parsed.description,
74
+ target,
75
+ testFilePath,
76
+ tests,
77
+ evaluatorPrompt,
78
+ };
79
+ }
80
+
81
+ private splitFrontmatter(content: string): { frontmatter: string | null; body: string } {
82
+ const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
83
+ if (!match) {
84
+ return { frontmatter: null, body: content };
85
+ }
86
+ return {
87
+ frontmatter: match[1],
88
+ body: match[2],
89
+ };
90
+ }
91
+
92
+ private resolveTarget(target: string | undefined, testFilePath: string): string {
93
+ if (target) {
94
+ const dir = path.dirname(testFilePath);
95
+ return path.resolve(dir, target);
96
+ }
97
+
98
+ // Auto-discover: summarize.test.prmd -> summarize.prmd
99
+ const dir = path.dirname(testFilePath);
100
+ const base = path.basename(testFilePath);
101
+ const sourceBase = base.replace(/\.test\.prmd$/, '.prmd');
102
+ return path.resolve(dir, sourceBase);
103
+ }
104
+
105
+ private parseTests(rawTests: RawTestCase[] | undefined, filePath: string): TestCase[] {
106
+ if (!rawTests || !Array.isArray(rawTests)) {
107
+ throw new TestParseError('Frontmatter must contain a "tests" array', filePath);
108
+ }
109
+
110
+ if (rawTests.length === 0) {
111
+ throw new TestParseError('"tests" array must not be empty', filePath);
112
+ }
113
+
114
+ return rawTests.map((raw, index) => {
115
+ const name = raw.name || `test_${index + 1}`;
116
+ const params = raw.params && typeof raw.params === 'object' ? raw.params : {};
117
+
118
+ if (raw.expect_error) {
119
+ return {
120
+ name,
121
+ params,
122
+ assert: [],
123
+ expect_error: true,
124
+ };
125
+ }
126
+
127
+ const assertions = this.parseAssertions(raw.assert, name, filePath);
128
+ return { name, params, assert: assertions };
129
+ });
130
+ }
131
+
132
+ private parseAssertions(
133
+ rawAssertions: RawAssertionDef[] | undefined,
134
+ testName: string,
135
+ filePath: string
136
+ ): AssertionDef[] {
137
+ if (!rawAssertions || !Array.isArray(rawAssertions)) {
138
+ return [];
139
+ }
140
+
141
+ return rawAssertions.map((raw, index) => {
142
+ if (!raw.evaluator || !VALID_EVALUATOR_TYPES.includes(raw.evaluator as EvaluatorType)) {
143
+ throw new TestParseError(
144
+ `Test "${testName}", assertion ${index + 1}: invalid evaluator "${raw.evaluator}". ` +
145
+ `Must be one of: ${VALID_EVALUATOR_TYPES.join(', ')}`,
146
+ filePath
147
+ );
148
+ }
149
+
150
+ const evaluator = raw.evaluator as EvaluatorType;
151
+
152
+ if (evaluator === 'nlp') {
153
+ return this.validateNlpAssertion(raw, testName, index, filePath);
154
+ }
155
+
156
+ if (evaluator === 'script') {
157
+ return this.validateScriptAssertion(raw, testName, index, filePath);
158
+ }
159
+
160
+ return this.validatePrmdAssertion(raw, testName, index, filePath);
161
+ });
162
+ }
163
+
164
+ private validateNlpAssertion(
165
+ raw: RawAssertionDef,
166
+ testName: string,
167
+ index: number,
168
+ filePath: string
169
+ ): AssertionDef {
170
+ if (!raw.check || !VALID_NLP_CHECKS.includes(raw.check as NlpCheck)) {
171
+ throw new TestParseError(
172
+ `Test "${testName}", assertion ${index + 1}: NLP evaluator requires a valid "check". ` +
173
+ `Must be one of: ${VALID_NLP_CHECKS.join(', ')}`,
174
+ filePath
175
+ );
176
+ }
177
+
178
+ if (raw.value === undefined || raw.value === null) {
179
+ throw new TestParseError(
180
+ `Test "${testName}", assertion ${index + 1}: NLP evaluator requires a "value"`,
181
+ filePath
182
+ );
183
+ }
184
+
185
+ return {
186
+ evaluator: 'nlp',
187
+ check: raw.check as NlpCheck,
188
+ value: raw.value as string | string[] | number,
189
+ };
190
+ }
191
+
192
+ private validateScriptAssertion(
193
+ raw: RawAssertionDef,
194
+ testName: string,
195
+ index: number,
196
+ filePath: string
197
+ ): AssertionDef {
198
+ if (!raw.run || typeof raw.run !== 'string') {
199
+ throw new TestParseError(
200
+ `Test "${testName}", assertion ${index + 1}: script evaluator requires a "run" path`,
201
+ filePath
202
+ );
203
+ }
204
+
205
+ return {
206
+ evaluator: 'script',
207
+ run: raw.run,
208
+ };
209
+ }
210
+
211
+ private validatePrmdAssertion(
212
+ raw: RawAssertionDef,
213
+ _testName: string,
214
+ _index: number,
215
+ _filePath: string
216
+ ): AssertionDef {
217
+ // prompt: is optional — if omitted, uses the content block of the .test.prmd
218
+ return {
219
+ evaluator: 'prmd',
220
+ prompt: raw.prompt || undefined,
221
+ provider: raw.provider || undefined,
222
+ model: raw.model || undefined,
223
+ };
224
+ }
225
+ }
226
+
227
+ export class TestParseError extends Error {
228
+ public readonly filePath: string;
229
+
230
+ constructor(message: string, filePath: string) {
231
+ super(`${message} (${filePath})`);
232
+ this.name = 'TestParseError';
233
+ this.filePath = filePath;
234
+ }
235
+ }