@arclabs561/ai-visual-test 0.5.1

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 (93) hide show
  1. package/.secretsignore.example +20 -0
  2. package/CHANGELOG.md +360 -0
  3. package/CONTRIBUTING.md +63 -0
  4. package/DEPLOYMENT.md +80 -0
  5. package/LICENSE +22 -0
  6. package/README.md +142 -0
  7. package/SECURITY.md +108 -0
  8. package/api/health.js +34 -0
  9. package/api/validate.js +252 -0
  10. package/index.d.ts +1221 -0
  11. package/package.json +112 -0
  12. package/public/index.html +149 -0
  13. package/src/batch-optimizer.mjs +451 -0
  14. package/src/bias-detector.mjs +370 -0
  15. package/src/bias-mitigation.mjs +233 -0
  16. package/src/cache.mjs +433 -0
  17. package/src/config.mjs +268 -0
  18. package/src/constants.mjs +80 -0
  19. package/src/context-compressor.mjs +350 -0
  20. package/src/convenience.mjs +617 -0
  21. package/src/cost-tracker.mjs +257 -0
  22. package/src/cross-modal-consistency.mjs +170 -0
  23. package/src/data-extractor.mjs +232 -0
  24. package/src/dynamic-few-shot.mjs +140 -0
  25. package/src/dynamic-prompts.mjs +361 -0
  26. package/src/ensemble/index.mjs +53 -0
  27. package/src/ensemble-judge.mjs +366 -0
  28. package/src/error-handler.mjs +67 -0
  29. package/src/errors.mjs +167 -0
  30. package/src/experience-propagation.mjs +128 -0
  31. package/src/experience-tracer.mjs +487 -0
  32. package/src/explanation-manager.mjs +299 -0
  33. package/src/feedback-aggregator.mjs +248 -0
  34. package/src/game-goal-prompts.mjs +478 -0
  35. package/src/game-player.mjs +548 -0
  36. package/src/hallucination-detector.mjs +155 -0
  37. package/src/helpers/playwright.mjs +80 -0
  38. package/src/human-validation-manager.mjs +516 -0
  39. package/src/index.mjs +364 -0
  40. package/src/judge.mjs +929 -0
  41. package/src/latency-aware-batch-optimizer.mjs +192 -0
  42. package/src/load-env.mjs +159 -0
  43. package/src/logger.mjs +55 -0
  44. package/src/metrics.mjs +187 -0
  45. package/src/model-tier-selector.mjs +221 -0
  46. package/src/multi-modal/index.mjs +36 -0
  47. package/src/multi-modal-fusion.mjs +190 -0
  48. package/src/multi-modal.mjs +524 -0
  49. package/src/natural-language-specs.mjs +1071 -0
  50. package/src/pair-comparison.mjs +277 -0
  51. package/src/persona/index.mjs +42 -0
  52. package/src/persona-enhanced.mjs +200 -0
  53. package/src/persona-experience.mjs +572 -0
  54. package/src/position-counterbalance.mjs +140 -0
  55. package/src/prompt-composer.mjs +375 -0
  56. package/src/render-change-detector.mjs +583 -0
  57. package/src/research-enhanced-validation.mjs +436 -0
  58. package/src/retry.mjs +152 -0
  59. package/src/rubrics.mjs +231 -0
  60. package/src/score-tracker.mjs +277 -0
  61. package/src/smart-validator.mjs +447 -0
  62. package/src/spec-config.mjs +106 -0
  63. package/src/spec-templates.mjs +347 -0
  64. package/src/specs/index.mjs +38 -0
  65. package/src/temporal/index.mjs +102 -0
  66. package/src/temporal-adaptive.mjs +163 -0
  67. package/src/temporal-batch-optimizer.mjs +222 -0
  68. package/src/temporal-constants.mjs +69 -0
  69. package/src/temporal-context.mjs +49 -0
  70. package/src/temporal-decision-manager.mjs +271 -0
  71. package/src/temporal-decision.mjs +669 -0
  72. package/src/temporal-errors.mjs +58 -0
  73. package/src/temporal-note-pruner.mjs +173 -0
  74. package/src/temporal-preprocessor.mjs +543 -0
  75. package/src/temporal-prompt-formatter.mjs +219 -0
  76. package/src/temporal-validation.mjs +159 -0
  77. package/src/temporal.mjs +415 -0
  78. package/src/type-guards.mjs +311 -0
  79. package/src/uncertainty-reducer.mjs +470 -0
  80. package/src/utils/index.mjs +175 -0
  81. package/src/validation-framework.mjs +321 -0
  82. package/src/validation-result-normalizer.mjs +64 -0
  83. package/src/validation.mjs +243 -0
  84. package/src/validators/accessibility-programmatic.mjs +345 -0
  85. package/src/validators/accessibility-validator.mjs +223 -0
  86. package/src/validators/batch-validator.mjs +143 -0
  87. package/src/validators/hybrid-validator.mjs +268 -0
  88. package/src/validators/index.mjs +34 -0
  89. package/src/validators/prompt-builder.mjs +218 -0
  90. package/src/validators/rubric.mjs +85 -0
  91. package/src/validators/state-programmatic.mjs +260 -0
  92. package/src/validators/state-validator.mjs +291 -0
  93. package/vercel.json +27 -0
@@ -0,0 +1,1071 @@
1
+ /**
2
+ * Natural Language Specifications
3
+ *
4
+ * Parses plain English test specifications and executes them against our validation interfaces.
5
+ *
6
+ * NOT formal specs (TLA+, Alloy, Z) - just LLM-parseable natural language.
7
+ *
8
+ * Based on real-world usage patterns from interactive web applications:
9
+ * - Direct natural language prompts in validateScreenshot() calls
10
+ * - Goal-based validation with testGameplay()
11
+ * - Multi-modal validation (screenshot + rendered code + game state)
12
+ * - Temporal sequences for gameplay over time
13
+ * - Game activation patterns (keyboard shortcuts, game selectors)
14
+ *
15
+ * Research Context:
16
+ * - Property-based testing (framework structure, not full fast-check/Hypothesis implementation)
17
+ * - BDD-style Given/When/Then (but LLM-parsed, not Gherkin)
18
+ * - Temporal decision-making (arXiv:2406.12125 - NOT IMPLEMENTED in spec parsing, see temporal-decision.mjs for related concepts)
19
+ * - Human perception time (NN/g, PMC - 0.1s threshold, used in temporal aggregation)
20
+ *
21
+ * Use Cases:
22
+ * - Flash website games
23
+ * - News pages
24
+ * - GitHub PR pages
25
+ * - Interactive web applications with games
26
+ * - Websites in development
27
+ * - Any web experience that needs validation
28
+ */
29
+
30
+ import { validateScreenshot } from './judge.mjs';
31
+ import { validateAccessibilitySmart, validateStateSmart, validateSmart } from './smart-validator.mjs';
32
+ import { testGameplay, testBrowserExperience, validateWithGoals } from './convenience.mjs';
33
+ import { playGame, GameGym } from './game-player.mjs';
34
+ import { log, warn } from './logger.mjs';
35
+ import { getSpecConfig } from './spec-config.mjs';
36
+
37
+ /**
38
+ * Extract context from natural language spec text using LLM
39
+ *
40
+ * Uses LLM to parse natural language and extract structured context.
41
+ * Falls back to regex heuristics if LLM is unavailable (for code assist).
42
+ *
43
+ * @param {string} spec - Natural language spec text
44
+ * @param {Object} [options] - Extraction options
45
+ * @returns {Promise<Object>} Extracted context
46
+ */
47
+ async function extractContextFromSpec(spec, options = {}) {
48
+ const specConfig = getSpecConfig();
49
+ const { useLLM = specConfig.useLLM, fallback = specConfig.fallback, provider = specConfig.provider } = options;
50
+
51
+ // Try LLM-based extraction first (more robust)
52
+ if (useLLM) {
53
+ try {
54
+ const { extractStructuredData } = await import('./data-extractor.mjs');
55
+
56
+ const schema = {
57
+ url: {
58
+ type: 'string',
59
+ required: false,
60
+ description: 'The URL or domain to visit (e.g., "example.com", "https://example.com"). Extract from phrases like "I visit example.com" or "Given I visit example.com"'
61
+ },
62
+ viewport: { type: 'object', required: false }, // Will be parsed as string then converted
63
+ viewportWidth: { type: 'number', required: false },
64
+ viewportHeight: { type: 'number', required: false },
65
+ device: { type: 'string', required: false },
66
+ persona: { type: 'string', required: false },
67
+ activationKey: { type: 'string', required: false },
68
+ gameActivationKey: { type: 'string', required: false },
69
+ selector: { type: 'string', required: false },
70
+ gameSelector: { type: 'string', required: false },
71
+ fps: { type: 'number', required: false },
72
+ duration: { type: 'number', required: false }, // In seconds, will convert to ms
73
+ captureTemporal: { type: 'boolean', required: false }
74
+ };
75
+
76
+ const extracted = await extractStructuredData(spec, schema, {
77
+ fallback: fallback === 'regex' ? 'auto' : 'llm',
78
+ provider: options.provider
79
+ });
80
+
81
+ if (extracted) {
82
+ const context = {};
83
+
84
+ // Normalize URL
85
+ if (extracted.url) {
86
+ let url = extracted.url.trim();
87
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
88
+ url = `https://${url}`;
89
+ }
90
+ context.url = url;
91
+ }
92
+
93
+ // Build viewport from width/height or object
94
+ if (extracted.viewportWidth && extracted.viewportHeight) {
95
+ context.viewport = {
96
+ width: extracted.viewportWidth,
97
+ height: extracted.viewportHeight
98
+ };
99
+ } else if (extracted.viewport && typeof extracted.viewport === 'object') {
100
+ context.viewport = extracted.viewport;
101
+ } else if (typeof extracted.viewport === 'string') {
102
+ // Parse "1280x720" format
103
+ const match = extracted.viewport.match(/(\d+)\s*[x×]\s*(\d+)/i);
104
+ if (match) {
105
+ context.viewport = {
106
+ width: parseInt(match[1]),
107
+ height: parseInt(match[2])
108
+ };
109
+ }
110
+ }
111
+
112
+ // Copy other fields
113
+ if (extracted.device) context.device = extracted.device.toLowerCase();
114
+ if (extracted.persona) context.persona = extracted.persona.trim();
115
+ if (extracted.activationKey) {
116
+ context.activationKey = extracted.activationKey.toLowerCase();
117
+ context.gameActivationKey = context.activationKey; // Backward compat
118
+ }
119
+ if (extracted.gameActivationKey) {
120
+ context.gameActivationKey = extracted.gameActivationKey.toLowerCase();
121
+ if (!context.activationKey) context.activationKey = context.gameActivationKey;
122
+ }
123
+ if (extracted.selector) {
124
+ context.selector = extracted.selector;
125
+ if (spec.toLowerCase().includes('game') || extracted.selector.includes('game')) {
126
+ context.gameSelector = extracted.selector;
127
+ }
128
+ }
129
+ if (extracted.gameSelector) {
130
+ context.gameSelector = extracted.gameSelector;
131
+ if (!context.selector) context.selector = extracted.gameSelector;
132
+ }
133
+ if (extracted.fps) context.fps = extracted.fps;
134
+ if (extracted.duration) context.duration = extracted.duration * 1000; // Convert to ms
135
+ if (extracted.captureTemporal !== undefined) context.captureTemporal = extracted.captureTemporal;
136
+
137
+ // Special handling for Konami code
138
+ if (spec.toLowerCase().includes('konami')) {
139
+ context.activationKey = 'konami';
140
+ context.gameActivationKey = 'konami';
141
+ }
142
+
143
+ // If critical fields are missing, try regex fallback to fill gaps
144
+ const needsFallback = !context.url || !context.viewport || !context.activationKey || context.captureTemporal === undefined;
145
+ if (needsFallback && (spec.toLowerCase().includes('visit') || spec.toLowerCase().includes('navigate') || spec.toLowerCase().includes('goto') || spec.toLowerCase().includes('context'))) {
146
+ const regexContext = extractContextFromSpecRegex(spec);
147
+ // Fill in missing fields from regex fallback
148
+ if (!context.url && regexContext.url) {
149
+ context.url = regexContext.url;
150
+ }
151
+ if (!context.viewport && regexContext.viewport) {
152
+ context.viewport = regexContext.viewport;
153
+ }
154
+ if (!context.activationKey && regexContext.activationKey) {
155
+ context.activationKey = regexContext.activationKey;
156
+ context.gameActivationKey = regexContext.activationKey;
157
+ }
158
+ if (!context.gameSelector && regexContext.gameSelector) {
159
+ context.gameSelector = regexContext.gameSelector;
160
+ if (!context.selector) context.selector = regexContext.gameSelector;
161
+ }
162
+ if (context.captureTemporal === undefined && regexContext.captureTemporal !== undefined) {
163
+ context.captureTemporal = regexContext.captureTemporal;
164
+ }
165
+ }
166
+
167
+ return context;
168
+ }
169
+ } catch (error) {
170
+ warn('[NaturalLanguageSpecs] LLM extraction failed, falling back to regex:', error.message);
171
+ // Fall through to regex fallback
172
+ }
173
+ }
174
+
175
+ // Fallback: Regex heuristics (for code assist when LLM unavailable)
176
+ return extractContextFromSpecRegex(spec);
177
+ }
178
+
179
+ /**
180
+ * Extract context using regex heuristics (fallback for code assist)
181
+ *
182
+ * @param {string} spec - Natural language spec text
183
+ * @returns {Object} Extracted context
184
+ */
185
+ function extractContextFromSpecRegex(spec) {
186
+ const context = {};
187
+ const lower = spec.toLowerCase();
188
+
189
+ // Extract URL
190
+ const urlPatterns = [
191
+ /(?:visit|open|navigate to|goto|go to|navigate)\s+(https?:\/\/[^\s\n]+)/i,
192
+ /(?:visit|open|navigate to|goto|go to|navigate)\s+([a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?(?:\/[^\s\n]*)?)/i,
193
+ /(?:I\s+)?(?:visit|open|navigate to|goto|go to|navigate)\s+([a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?::\d+)?(?:\/[^\s\n]*)?)/i, // Handle "I visit example.com"
194
+ /url[=:]\s*(https?:\/\/[^\s\n]+|[a-z0-9][a-z0-9.-]*\.[a-z]{2,})/i
195
+ ];
196
+
197
+ for (const pattern of urlPatterns) {
198
+ const urlMatch = spec.match(pattern);
199
+ if (urlMatch) {
200
+ let url = urlMatch[1].trim();
201
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
202
+ url = `https://${url}`;
203
+ }
204
+ context.url = url;
205
+ break;
206
+ }
207
+ }
208
+
209
+ // Extract viewport
210
+ const viewportPatterns = [
211
+ /viewport[=:]\s*(\d+)\s*[x×]\s*(\d+)/i,
212
+ /(\d+)\s*[x×]\s*(\d+)\s*(?:viewport|screen|resolution|px)/i,
213
+ /(\d+)\s*[x×]\s*(\d+)(?:\s|$)/i
214
+ ];
215
+
216
+ for (const pattern of viewportPatterns) {
217
+ const viewportMatch = spec.match(pattern);
218
+ if (viewportMatch) {
219
+ const width = parseInt(viewportMatch[1]);
220
+ const height = parseInt(viewportMatch[2]);
221
+ if (width && height && width > 100 && height > 100) {
222
+ context.viewport = { width, height };
223
+ break;
224
+ }
225
+ }
226
+ }
227
+
228
+ // Extract device
229
+ const deviceMatch = spec.match(/device[=:]\s*(\w+)|(?:on|using|with)\s+(desktop|mobile|tablet|phone)/i);
230
+ if (deviceMatch) {
231
+ context.device = (deviceMatch[1] || deviceMatch[2]).toLowerCase();
232
+ }
233
+
234
+ // Extract persona
235
+ const personaMatch = spec.match(/persona[=:]\s*([^\n,]+)|as\s+([A-Z][^\n,]+?)(?:\s+persona)?/i);
236
+ if (personaMatch) {
237
+ context.persona = (personaMatch[1] || personaMatch[2]).trim();
238
+ }
239
+
240
+ // Extract activation key
241
+ const keyPatterns = [
242
+ /(?:press|key|activation key|shortcut)[=:]\s*['"]?([a-z0-9])['"]?/i,
243
+ /press\s+['"]([a-z0-9])['"]/i,
244
+ /(?:press|hit|type)\s+([a-z0-9])(?:\s|$)/i
245
+ ];
246
+
247
+ for (const pattern of keyPatterns) {
248
+ const keyMatch = spec.match(pattern);
249
+ if (keyMatch) {
250
+ context.activationKey = (keyMatch[1] || keyMatch[2]).toLowerCase();
251
+ context.gameActivationKey = context.activationKey;
252
+ break;
253
+ }
254
+ }
255
+
256
+ if (lower.includes('konami')) {
257
+ context.activationKey = 'konami';
258
+ context.gameActivationKey = 'konami';
259
+ }
260
+
261
+ // Extract selector
262
+ const selectorPatterns = [
263
+ /selector[=:]\s*(#[a-z0-9-]+|\.?[a-z0-9-]+|\w+)/i,
264
+ /(#[a-z0-9-]+)(?:\s|\)|$)/i, // Match #game-paddle) or #game-paddle
265
+ /selector\s+(#[a-z0-9-]+|\.?[a-z0-9-]+)/i,
266
+ /element[=:]\s*(#[a-z0-9-]+|\.?[a-z0-9-]+)/i
267
+ ];
268
+
269
+ for (const pattern of selectorPatterns) {
270
+ const selectorMatch = spec.match(pattern);
271
+ if (selectorMatch) {
272
+ let selector = (selectorMatch[1] || selectorMatch[2]).trim();
273
+ // Remove trailing ) if present (from "selector: #game-paddle)")
274
+ if (selector.endsWith(')')) {
275
+ selector = selector.slice(0, -1);
276
+ }
277
+ if (selector.startsWith('#') || selector.startsWith('.') || /^[a-z]/.test(selector)) {
278
+ context.selector = selector;
279
+ if (lower.includes('game') || selector.includes('game')) {
280
+ context.gameSelector = selector;
281
+ }
282
+ break;
283
+ }
284
+ }
285
+ }
286
+
287
+ // Extract FPS
288
+ const fpsMatch = spec.match(/fps[=:]\s*(\d+)|(\d+)\s*fps/i);
289
+ if (fpsMatch) {
290
+ context.fps = parseInt(fpsMatch[1] || fpsMatch[2]);
291
+ }
292
+
293
+ // Extract duration
294
+ const durationMatch = spec.match(/(?:duration|for)[=:]\s*(\d+)|(\d+)\s*(?:second|sec|s)(?:onds)?/i);
295
+ if (durationMatch) {
296
+ const seconds = parseInt(durationMatch[1] || durationMatch[2]);
297
+ context.duration = seconds * 1000;
298
+ }
299
+
300
+ // Extract temporal flag
301
+ // Check for explicit temporal: true in Context line, or temporal keywords
302
+ // Also detect from fps + duration combination (implicit temporal)
303
+ if (lower.includes('temporal') || lower.includes('over time') || lower.includes('sequence') ||
304
+ lower.match(/temporal[=:]\s*(true|yes|1)/i)) {
305
+ context.captureTemporal = true;
306
+ } else if (lower.includes('fps') && lower.includes('duration')) {
307
+ // Implicit temporal: if fps and duration are both present, it's likely temporal
308
+ context.captureTemporal = true;
309
+ }
310
+
311
+ return context;
312
+ }
313
+
314
+ /**
315
+ * Parse natural language spec into structured test description
316
+ *
317
+ * Uses LLM to parse plain English into executable test structure.
318
+ * Auto-extracts context from spec text (URLs, viewports, devices, etc.).
319
+ *
320
+ * @param {string} spec - Natural language spec (Given/When/Then or property description)
321
+ * @returns {Promise<Object>} Parsed spec structure with extracted context
322
+ */
323
+ export async function parseSpec(spec, options = {}) {
324
+ // Extract context from spec text using LLM (with regex fallback)
325
+ const extractedContext = await extractContextFromSpec(spec, options);
326
+
327
+ // Use LLM to parse natural language
328
+ // For now, simple keyword-based parsing (can be enhanced with LLM)
329
+
330
+ const lines = spec.split('\n').map(l => l.trim()).filter(l => l.length > 0);
331
+
332
+ const parsed = {
333
+ type: 'behavior', // 'behavior' or 'property'
334
+ given: [],
335
+ when: [],
336
+ then: [],
337
+ properties: [],
338
+ keywords: [],
339
+ interfaces: [],
340
+ context: extractedContext || {} // Include extracted context (ensure it's an object)
341
+ };
342
+
343
+ let currentSection = null;
344
+
345
+ for (const line of lines) {
346
+ const lower = line.toLowerCase();
347
+
348
+ // Skip context lines (already extracted)
349
+ if (lower.startsWith('context:') || lower.startsWith('options:')) {
350
+ continue;
351
+ }
352
+
353
+ // Detect section
354
+ if (lower.startsWith('given')) {
355
+ currentSection = 'given';
356
+ parsed.given.push(line.replace(/^given\s+/i, '').trim());
357
+ } else if (lower.startsWith('when')) {
358
+ currentSection = 'when';
359
+ parsed.when.push(line.replace(/^when\s+/i, '').trim());
360
+ } else if (lower.startsWith('then')) {
361
+ currentSection = 'then';
362
+ parsed.then.push(line.replace(/^then\s+/i, '').trim());
363
+ } else if (lower.startsWith('and')) {
364
+ // Continue current section
365
+ const content = line.replace(/^and\s+/i, '').trim();
366
+ if (currentSection && parsed[currentSection] && Array.isArray(parsed[currentSection])) {
367
+ parsed[currentSection].push(content);
368
+ } else if (content) {
369
+ // If no current section, treat as property
370
+ parsed.properties.push(content);
371
+ }
372
+ } else if (lower.startsWith('for all') || lower.includes('should always')) {
373
+ // Property description
374
+ parsed.type = 'property';
375
+ parsed.properties.push(line);
376
+ } else {
377
+ // Generic line - add to current section or as property
378
+ if (currentSection && parsed[currentSection] && Array.isArray(parsed[currentSection])) {
379
+ parsed[currentSection].push(line);
380
+ } else if (line) {
381
+ parsed.properties.push(line);
382
+ }
383
+ }
384
+
385
+ // Extract keywords for interface selection
386
+ // Note: Multiple interfaces can be detected (e.g., accessibility + browser experience)
387
+ if (lower.includes('accessible') || lower.includes('accessibility')) {
388
+ parsed.keywords.push('accessibility');
389
+ if (!parsed.interfaces.includes('validateAccessibilitySmart')) {
390
+ parsed.interfaces.push('validateAccessibilitySmart');
391
+ }
392
+ }
393
+ // Game detection: be more careful - "game should work" in unstructured spec might not mean gameplay testing
394
+ // Only detect game if there's clear gameplay context (play, activate, playable) or structured spec
395
+ if ((lower.includes('play') || lower.includes('playable') || lower.includes('activate')) ||
396
+ (lower.includes('game') && (parsed.given.length > 0 || parsed.when.length > 0 || parsed.then.length > 0))) {
397
+ parsed.keywords.push('game');
398
+ if (!parsed.interfaces.includes('testGameplay')) {
399
+ parsed.interfaces.push('testGameplay');
400
+ }
401
+ }
402
+ if (lower.includes('state') || lower.includes('position') || lower.includes('consistency')) {
403
+ parsed.keywords.push('state');
404
+ if (!parsed.interfaces.includes('validateStateSmart')) {
405
+ parsed.interfaces.push('validateStateSmart');
406
+ }
407
+ }
408
+ // Only add validateScreenshot if explicitly mentioned or as fallback
409
+ // Don't add it just because of "visual" (too broad - catches "visually impaired", "visual representation")
410
+ // Only trigger on explicit screenshot mention or standalone "visual" (not in phrases)
411
+ if (lower.includes('screenshot') ||
412
+ (lower.includes(' visual ') && !lower.includes('visually') && !lower.includes('visual representation'))) {
413
+ parsed.keywords.push('visual');
414
+ if (!parsed.interfaces.includes('validateScreenshot')) {
415
+ parsed.interfaces.push('validateScreenshot');
416
+ }
417
+ }
418
+ // Browser experience keywords: journey, experience, navigate, browse, checkout, form, payment
419
+ if (lower.includes('experience') || lower.includes('journey') ||
420
+ lower.includes('navigate') || lower.includes('browse') ||
421
+ lower.includes('checkout') || lower.includes('cart') ||
422
+ lower.includes('payment') || lower.includes('form')) {
423
+ parsed.keywords.push('experience');
424
+ if (!parsed.interfaces.includes('testBrowserExperience')) {
425
+ parsed.interfaces.push('testBrowserExperience');
426
+ }
427
+ }
428
+ }
429
+
430
+ // For property-based specs, extract interface from property scope/content
431
+ if (parsed.type === 'property' && parsed.properties.length > 0) {
432
+ const propertyText = parsed.properties.join(' ').toLowerCase();
433
+
434
+ // Extract interface from property scope (e.g., "For all screenshots" → validateScreenshot)
435
+ if (propertyText.includes('screenshot')) {
436
+ if (!parsed.interfaces.includes('validateScreenshot')) {
437
+ parsed.interfaces.push('validateScreenshot');
438
+ }
439
+ }
440
+ if (propertyText.includes('game state') || propertyText.includes('state')) {
441
+ if (!parsed.interfaces.includes('validateStateSmart')) {
442
+ parsed.interfaces.push('validateStateSmart');
443
+ }
444
+ }
445
+ if (propertyText.includes('game')) {
446
+ if (!parsed.interfaces.includes('testGameplay')) {
447
+ parsed.interfaces.push('testGameplay');
448
+ }
449
+ }
450
+ }
451
+
452
+ // Default interface if none detected
453
+ if (parsed.interfaces.length === 0) {
454
+ parsed.interfaces.push('validateScreenshot');
455
+ }
456
+
457
+ return parsed;
458
+ }
459
+
460
+ /**
461
+ * Map parsed spec to interface calls
462
+ *
463
+ * Merges extracted context from spec with provided context/options.
464
+ * Extracted context takes precedence (spec is source of truth).
465
+ *
466
+ * @param {Object} parsedSpec - Parsed spec structure (includes extracted context)
467
+ * @param {Object} context - Execution context (page, url, etc.)
468
+ * @returns {Promise<Array>} Array of interface calls to execute
469
+ */
470
+ export async function mapToInterfaces(parsedSpec, context = {}) {
471
+ // Validate parsedSpec
472
+ if (!parsedSpec || typeof parsedSpec !== 'object') {
473
+ throw new Error('mapToInterfaces: parsedSpec must be a valid object');
474
+ }
475
+
476
+ const { page, url, screenshotPath, options = {} } = context;
477
+ const calls = [];
478
+
479
+ // Ensure parsedSpec has required properties with defaults
480
+ parsedSpec.interfaces = parsedSpec.interfaces || [];
481
+ parsedSpec.context = parsedSpec.context || {};
482
+ parsedSpec.given = parsedSpec.given || [];
483
+ parsedSpec.when = parsedSpec.when || [];
484
+ parsedSpec.then = parsedSpec.then || [];
485
+ parsedSpec.properties = parsedSpec.properties || [];
486
+
487
+ // Merge extracted context with provided options (extracted context takes precedence)
488
+ const mergedContext = {
489
+ ...options,
490
+ ...parsedSpec.context, // Extracted from spec (source of truth)
491
+ // But allow explicit overrides from options
492
+ url: parsedSpec.context?.url || url || options.url,
493
+ gameActivationKey: parsedSpec.context?.gameActivationKey || options.gameActivationKey,
494
+ gameSelector: parsedSpec.context?.gameSelector || options.gameSelector,
495
+ viewport: parsedSpec.context?.viewport || options.viewport,
496
+ device: parsedSpec.context?.device || options.device,
497
+ persona: parsedSpec.context?.persona || options.persona,
498
+ fps: parsedSpec.context?.fps || options.fps,
499
+ duration: parsedSpec.context?.duration || options.duration,
500
+ captureTemporal: parsedSpec.context?.captureTemporal !== undefined
501
+ ? parsedSpec.context.captureTemporal
502
+ : options.captureTemporal
503
+ };
504
+
505
+ // Support multiple interfaces when detected (e.g., accessibility + browser experience)
506
+ // Build interface calls for all detected interfaces
507
+ const detectedInterfaces = parsedSpec.interfaces.length > 0
508
+ ? parsedSpec.interfaces
509
+ : ['validateScreenshot']; // Default fallback
510
+
511
+ // Remove duplicates while preserving order
512
+ const uniqueInterfaces = [...new Set(detectedInterfaces)];
513
+
514
+ // Build interface calls based on spec content
515
+ // Note: page is passed separately, not in options
516
+ for (const primaryInterface of uniqueInterfaces) {
517
+ if (primaryInterface === 'validateAccessibilitySmart') {
518
+ const { page: _, ...accessibilityOptions } = options; // Remove page from options
519
+ calls.push({
520
+ interface: 'validateAccessibilitySmart',
521
+ page: page, // Pass page separately
522
+ args: {
523
+ page: page, // validateAccessibilitySmart accepts page in options
524
+ screenshotPath: screenshotPath,
525
+ minContrast: 4.5,
526
+ ...accessibilityOptions
527
+ }
528
+ });
529
+ } else if (primaryInterface === 'testGameplay') {
530
+ // Extract game-specific options from spec
531
+ // Use extracted context first, then fall back to parsing spec text, then options
532
+ const gameActivationKey = mergedContext.gameActivationKey ||
533
+ mergedContext.activationKey ||
534
+ parsedSpec.given.find(g =>
535
+ g.includes('activate') || g.includes('press')
536
+ )?.match(/press\s+['"]?([a-z])['"]?/i)?.[1] ||
537
+ parsedSpec.given.find(g => g.includes('konami')) ? 'konami' :
538
+ options.gameActivationKey;
539
+
540
+ const gameSelector = mergedContext.gameSelector ||
541
+ mergedContext.selector ||
542
+ parsedSpec.given.find(g =>
543
+ g.includes('selector') || g.includes('#')
544
+ )?.match(/#[\w-]+/)?.[0] || options.gameSelector;
545
+
546
+ // Extract goals from spec (common goals: fun, accessibility, performance, visual-consistency)
547
+ const goals = [];
548
+ const allText = [...parsedSpec.given, ...parsedSpec.when, ...parsedSpec.then].join(' ').toLowerCase();
549
+ if (allText.includes('fun') || allText.includes('playable') || allText.includes('enjoyable')) {
550
+ goals.push('fun');
551
+ }
552
+ if (allText.includes('accessible') || allText.includes('accessibility')) {
553
+ goals.push('accessibility');
554
+ }
555
+ if (allText.includes('performance') || allText.includes('smooth') || allText.includes('fast')) {
556
+ goals.push('performance');
557
+ }
558
+ if (allText.includes('visual') || allText.includes('consistency') || allText.includes('layout')) {
559
+ goals.push('visual-consistency');
560
+ }
561
+
562
+ // Extract temporal options (temporal capture for gameplay sequences)
563
+ const captureTemporal = allText.includes('temporal') || allText.includes('over time') ||
564
+ allText.includes('sequence') || options.captureTemporal;
565
+ const fps = allText.match(/fps[:\s]+(\d+)/i)?.[1] || options.fps || 2;
566
+ const duration = allText.match(/(\d+)\s*(?:second|sec)/i)?.[1] ? parseInt(allText.match(/(\d+)\s*(?:second|sec)/i)?.[1]) * 1000 : options.duration || 5000;
567
+
568
+ // Remove page from options - testGameplay expects (page, options)
569
+ const { page: _, ...gameplayOptions } = options;
570
+ calls.push({
571
+ interface: 'testGameplay',
572
+ page: page, // Pass page separately for testGameplay(page, options)
573
+ args: {
574
+ url: mergedContext.url,
575
+ goals: goals.length > 0 ? goals : ['fun', 'accessibility'],
576
+ gameActivationKey: mergedContext.gameActivationKey || gameActivationKey,
577
+ gameSelector: mergedContext.gameSelector || gameSelector,
578
+ captureTemporal: mergedContext.captureTemporal !== undefined ? mergedContext.captureTemporal : captureTemporal,
579
+ fps: mergedContext.fps || fps,
580
+ duration: mergedContext.duration || duration,
581
+ captureCode: true, // Dual-view: screenshot (rendered) + HTML/CSS (source)
582
+ checkConsistency: true, // Cross-modal consistency: visual vs. code
583
+ viewport: mergedContext.viewport,
584
+ device: mergedContext.device,
585
+ persona: mergedContext.persona ? { name: mergedContext.persona } : undefined,
586
+ // Research features integration
587
+ useTemporalPreprocessing: options.useTemporalPreprocessing !== undefined ? options.useTemporalPreprocessing : false,
588
+ // Pass through any other research-enhanced options
589
+ enableUncertaintyReduction: options.enableUncertaintyReduction,
590
+ enableHallucinationCheck: options.enableHallucinationCheck,
591
+ adaptiveSelfConsistency: options.adaptiveSelfConsistency,
592
+ ...gameplayOptions // Options without page (allows per-test overrides)
593
+ }
594
+ });
595
+ } else if (primaryInterface === 'validateStateSmart') {
596
+ const { page: _, ...stateOptions } = options; // Remove page from options
597
+ calls.push({
598
+ interface: 'validateStateSmart',
599
+ page: page, // Pass page separately
600
+ args: {
601
+ page: page, // validateStateSmart accepts page in options
602
+ screenshotPath: screenshotPath,
603
+ ...stateOptions
604
+ }
605
+ });
606
+ } else if (primaryInterface === 'testBrowserExperience') {
607
+ // Extract stages from spec
608
+ const stages = [];
609
+ if (parsedSpec.given.some(g => g.includes('visit') || g.includes('open'))) {
610
+ stages.push('initial');
611
+ }
612
+ if (parsedSpec.when.some(w => w.includes('form') || w.includes('fill'))) {
613
+ stages.push('form');
614
+ }
615
+ if (parsedSpec.when.some(w => w.includes('payment') || w.includes('checkout'))) {
616
+ stages.push('payment');
617
+ }
618
+
619
+ // Remove page from options - testBrowserExperience expects (page, options)
620
+ const { page: _, ...browserOptions } = options;
621
+ calls.push({
622
+ interface: 'testBrowserExperience',
623
+ page: page, // Pass page separately for testBrowserExperience(page, options)
624
+ args: {
625
+ url: mergedContext.url,
626
+ stages: stages.length > 0 ? stages : ['initial'],
627
+ ...browserOptions // Options without page
628
+ }
629
+ });
630
+ } else if (primaryInterface === 'validateScreenshot') {
631
+ // validateScreenshot (direct natural language prompts)
632
+ // Build prompt from spec (detailed, context-rich prompts)
633
+ const thenText = (parsedSpec.then || []).join(' ').trim();
634
+ const propertiesText = (parsedSpec.properties || []).join(' ').trim();
635
+ let prompt = thenText || propertiesText || 'Evaluate this page';
636
+
637
+ // Enhance prompt with context (game state, rendered code, etc.)
638
+ // Use safe JSON stringify to handle circular references
639
+ // Note: JSON.stringify may detect circular references before replacer runs,
640
+ // so we catch the error and return a safe message
641
+ const safeStringify = (obj) => {
642
+ if (obj === null || typeof obj !== 'object') {
643
+ return JSON.stringify(obj, null, 2);
644
+ }
645
+ const seen = new WeakSet();
646
+ // Add root object to seen set first
647
+ seen.add(obj);
648
+ const replacer = (key, value) => {
649
+ if (key && typeof value === 'object' && value !== null) {
650
+ // Check for circular reference
651
+ if (seen.has(value)) {
652
+ return '[Circular Reference]';
653
+ }
654
+ seen.add(value);
655
+ }
656
+ return value;
657
+ };
658
+ try {
659
+ return JSON.stringify(obj, replacer, 2);
660
+ } catch (e) {
661
+ // Fallback: if JSON.stringify still fails (e.g., very deep circular refs),
662
+ // return a safe error message
663
+ return `[Error serializing: ${e.message.substring(0, 100)}]`;
664
+ }
665
+ };
666
+
667
+ if (options.gameState) {
668
+ prompt += `\n\nCURRENT GAME STATE:\n${safeStringify(options.gameState)}`;
669
+ }
670
+ if (options.renderedCode) {
671
+ prompt += `\n\nRENDERED CODE:\n${safeStringify(options.renderedCode)}`;
672
+ }
673
+ if (options.state) {
674
+ prompt += `\n\nCURRENT STATE:\n${safeStringify(options.state)}`;
675
+ }
676
+
677
+ // Dual-view validation: screenshot (rendered visuals) + HTML/CSS (source code)
678
+ // This enables validation against both "source of truth" (code) and "rendered output" (visuals)
679
+ const useMultiModal = options.captureCode !== false; // Default true
680
+
681
+ // Build context with research enhancements support
682
+ const context = {
683
+ testType: 'natural-language-spec',
684
+ spec: parsedSpec,
685
+ gameState: options.gameState,
686
+ renderedCode: options.renderedCode,
687
+ useMultiModal: useMultiModal,
688
+ // Research features integration
689
+ enableUncertaintyReduction: options.enableUncertaintyReduction,
690
+ enableHallucinationCheck: options.enableHallucinationCheck,
691
+ adaptiveSelfConsistency: options.adaptiveSelfConsistency,
692
+ enableBiasMitigation: options.enableBiasMitigation,
693
+ useExplicitRubric: options.useExplicitRubric,
694
+ ...options.context // Allow per-test context overrides
695
+ };
696
+
697
+ calls.push({
698
+ interface: 'validateScreenshot',
699
+ args: {
700
+ imagePath: screenshotPath,
701
+ prompt: prompt,
702
+ context: context
703
+ }
704
+ });
705
+ } else {
706
+ // Unknown interface - log warning but don't crash
707
+ warn(`[NaturalLanguageSpecs] Unknown interface detected: ${primaryInterface}, skipping`);
708
+ }
709
+ }
710
+
711
+ // If no calls were generated (all interfaces were unknown), default to validateScreenshot
712
+ if (calls.length === 0) {
713
+ warn('[NaturalLanguageSpecs] No valid interfaces detected, defaulting to validateScreenshot');
714
+
715
+ // Build prompt from available content (empty arrays are truthy, so check length)
716
+ const thenText = parsedSpec.then.length > 0 ? parsedSpec.then.join(' ').trim() : '';
717
+ const propertiesText = parsedSpec.properties.length > 0 ? parsedSpec.properties.join(' ').trim() : '';
718
+ const prompt = thenText || propertiesText || 'Evaluate this page';
719
+
720
+ calls.push({
721
+ interface: 'validateScreenshot',
722
+ args: {
723
+ imagePath: screenshotPath,
724
+ prompt: prompt,
725
+ context: {
726
+ testType: 'natural-language-spec',
727
+ spec: parsedSpec,
728
+ useMultiModal: options.captureCode !== false
729
+ }
730
+ }
731
+ });
732
+ }
733
+
734
+ return calls;
735
+ }
736
+
737
+ /**
738
+ * Execute natural language spec
739
+ *
740
+ * Enhanced API: Supports both simple string spec and structured spec object.
741
+ * Auto-extracts context from spec text (URLs, viewports, devices, etc.).
742
+ *
743
+ * @param {import('playwright').Page} page - Playwright page object
744
+ * @param {string | Object} spec - Natural language spec (string) or structured spec object
745
+ * @param {Object} [options] - Execution options (merged with extracted context)
746
+ * @returns {Promise<Object>} Test results
747
+ *
748
+ * @example
749
+ * // Simple string spec (backward compatible)
750
+ * await executeSpec(page, 'Given I visit example.com...', { url: 'https://example.com' });
751
+ *
752
+ * @example
753
+ * // Structured spec object (new)
754
+ * await executeSpec(page, {
755
+ * spec: 'Given I visit example.com...',
756
+ * context: { viewport: { width: 1280, height: 720 } },
757
+ * options: { captureTemporal: true }
758
+ * });
759
+ */
760
+ export async function executeSpec(page, spec, options = {}) {
761
+ const specConfig = getSpecConfig();
762
+
763
+ // Support both string spec and structured spec object
764
+ let specText;
765
+ let structuredOptions = {};
766
+ let validateBeforeExecute = options.validate !== undefined
767
+ ? options.validate
768
+ : specConfig.validateBeforeExecute;
769
+
770
+ if (typeof spec === 'string') {
771
+ // Backward compatible: simple string spec
772
+ specText = spec;
773
+ structuredOptions = options;
774
+ } else if (spec && typeof spec === 'object') {
775
+ // New: structured spec object
776
+ specText = spec.spec || spec.text || '';
777
+ validateBeforeExecute = spec.validate !== false && options.validate !== false;
778
+ structuredOptions = {
779
+ ...spec.context,
780
+ ...spec.options,
781
+ ...options // Options parameter can override
782
+ };
783
+ } else {
784
+ throw new Error('executeSpec: spec must be a string or object with spec/text property');
785
+ }
786
+
787
+ // Validate spec structure (optional, can be disabled)
788
+ if (validateBeforeExecute) {
789
+ const validation = validateSpec(specText);
790
+ if (!validation.valid) {
791
+ const errorMsg = `Spec validation failed:\n${validation.errors.join('\n')}\n\nSuggestions:\n${validation.suggestions.join('\n')}`;
792
+ if (specConfig.strictValidation) {
793
+ throw new Error(errorMsg);
794
+ }
795
+ warn('[NaturalLanguageSpecs]', errorMsg);
796
+ // Don't throw - just warn, allow execution to continue (unless strict)
797
+ } else if (validation.warnings.length > 0 || validation.suggestions.length > 0) {
798
+ log('[NaturalLanguageSpecs] Validation warnings:', validation.warnings);
799
+ if (validation.suggestions.length > 0) {
800
+ log('[NaturalLanguageSpecs] Suggestions:', validation.suggestions);
801
+ }
802
+ }
803
+ }
804
+
805
+ log('[NaturalLanguageSpecs] Parsing spec:', specText.substring(0, 100));
806
+
807
+ // Parse spec (auto-extracts context from spec text)
808
+ const parsedSpec = await parseSpec(specText);
809
+
810
+ log('[NaturalLanguageSpecs] Parsed spec:', {
811
+ type: parsedSpec.type,
812
+ interfaces: parsedSpec.interfaces,
813
+ keywords: parsedSpec.keywords,
814
+ extractedContext: parsedSpec.context
815
+ });
816
+
817
+ // Map to interfaces (merged context: extracted from spec + provided options)
818
+ const calls = await mapToInterfaces(parsedSpec, {
819
+ page: page,
820
+ url: structuredOptions.url,
821
+ screenshotPath: structuredOptions.screenshotPath,
822
+ options: structuredOptions
823
+ });
824
+
825
+ // Execute interface calls
826
+ const results = [];
827
+
828
+ for (const call of calls) {
829
+ try {
830
+ let result;
831
+
832
+ switch (call.interface) {
833
+ case 'validateAccessibilitySmart':
834
+ // validateAccessibilitySmart(options) - page is in options
835
+ result = await validateAccessibilitySmart(call.args);
836
+ break;
837
+ case 'testGameplay':
838
+ // testGameplay(page, options) - page is separate, not in options
839
+ result = await testGameplay(call.page || page, call.args);
840
+ break;
841
+ case 'validateStateSmart':
842
+ // validateStateSmart(options) - page is in options
843
+ result = await validateStateSmart(call.args);
844
+ break;
845
+ case 'testBrowserExperience':
846
+ // testBrowserExperience(page, options) - page is separate, not in options
847
+ result = await testBrowserExperience(call.page || page, call.args);
848
+ break;
849
+ case 'validateScreenshot':
850
+ // validateScreenshot(imagePath, prompt, context)
851
+ result = await validateScreenshot(
852
+ call.args.imagePath,
853
+ call.args.prompt,
854
+ call.args.context
855
+ );
856
+ break;
857
+ default:
858
+ warn(`[NaturalLanguageSpecs] Unknown interface: ${call.interface}`);
859
+ continue;
860
+ }
861
+
862
+ results.push({
863
+ interface: call.interface,
864
+ result: result,
865
+ success: result?.score !== null && result?.score !== undefined
866
+ });
867
+ } catch (error) {
868
+ warn(`[NaturalLanguageSpecs] Error executing ${call.interface}:`, error.message);
869
+ results.push({
870
+ interface: call.interface,
871
+ error: error.message,
872
+ success: false
873
+ });
874
+ }
875
+ }
876
+
877
+ // Enhanced result with extracted context info
878
+ return {
879
+ spec: parsedSpec,
880
+ extractedContext: parsedSpec.context, // Show what was auto-extracted
881
+ results: results,
882
+ success: results.every(r => r.success),
883
+ summary: {
884
+ totalCalls: results.length,
885
+ successfulCalls: results.filter(r => r.success).length,
886
+ failedCalls: results.filter(r => !r.success).length
887
+ },
888
+ // Include merged context for debugging
889
+ mergedContext: calls.length > 0 ? calls[0].args : {}
890
+ };
891
+ }
892
+
893
+ /**
894
+ * Validate spec structure before execution
895
+ *
896
+ * Provides early feedback on spec issues.
897
+ *
898
+ * @param {string} spec - Natural language spec
899
+ * @returns {Object} Validation result with errors and suggestions
900
+ */
901
+ export function validateSpec(spec) {
902
+ const errors = [];
903
+ const warnings = [];
904
+ const suggestions = [];
905
+
906
+ if (!spec || typeof spec !== 'string') {
907
+ errors.push('Spec must be a non-empty string');
908
+ return { valid: false, errors, warnings, suggestions };
909
+ }
910
+
911
+ const lines = spec.split('\n').map(l => l.trim()).filter(l => l.length > 0);
912
+
913
+ // Check for basic structure
914
+ const hasGiven = lines.some(l => l.toLowerCase().startsWith('given'));
915
+ const hasWhen = lines.some(l => l.toLowerCase().startsWith('when'));
916
+ const hasThen = lines.some(l => l.toLowerCase().startsWith('then'));
917
+
918
+ if (!hasGiven && !hasWhen && !hasThen) {
919
+ warnings.push('Spec does not follow Given/When/Then structure - may be harder to parse');
920
+ suggestions.push('Consider using: Given [precondition], When [action], Then [expected result]');
921
+ }
922
+
923
+ // Check for common mistakes
924
+ if (spec.toLowerCase().includes('i should') && !spec.toLowerCase().includes('then')) {
925
+ suggestions.push("Consider using 'Then' instead of 'I should' for better parsing");
926
+ }
927
+
928
+ // Check for context extraction opportunities
929
+ const hasUrl = /(?:visit|open|navigate to|goto)\s+[^\s]+/i.test(spec);
930
+ if (!hasUrl && !spec.includes('http')) {
931
+ warnings.push('No URL detected in spec - may need to provide url in options');
932
+ }
933
+
934
+ // Check for interface keywords
935
+ const hasKeywords = /(?:game|accessible|state|screenshot|experience)/i.test(spec);
936
+ if (!hasKeywords) {
937
+ warnings.push('No clear validation keywords detected - may default to validateScreenshot');
938
+ suggestions.push('Consider including keywords like: game, accessible, state, screenshot, experience');
939
+ }
940
+
941
+ return {
942
+ valid: errors.length === 0,
943
+ errors,
944
+ warnings,
945
+ suggestions
946
+ };
947
+ }
948
+
949
+ /**
950
+ * Generate property-based tests from natural language properties
951
+ *
952
+ * NOTE: This is a framework/structure for property tests, not a full
953
+ * implementation of fast-check or Hypothesis. The actual property
954
+ * testing logic would need to be implemented separately.
955
+ *
956
+ * The structure returned provides a foundation for property-based testing
957
+ * but does not include generators or the full property testing infrastructure
958
+ * from libraries like fast-check or Hypothesis.
959
+ *
960
+ * @param {Array<string>} properties - Natural language property descriptions
961
+ * @param {Object} options - Generation options
962
+ * @param {string} [options.generator='fast-check'] - Generator library name (not implemented)
963
+ * @param {number} [options.numRuns=100] - Number of test runs (not implemented)
964
+ * @returns {Promise<Object>} Property test structure with placeholder run() method
965
+ */
966
+ export async function generatePropertyTests(properties, options = {}) {
967
+ const { generator = 'fast-check', numRuns = 100 } = options;
968
+
969
+ log('[NaturalLanguageSpecs] Generating property tests:', properties.length);
970
+
971
+ const propertyTests = [];
972
+
973
+ for (const property of properties) {
974
+ // Parse property description
975
+ const parsed = {
976
+ description: property,
977
+ type: 'invariant', // 'invariant', 'postcondition', 'precondition'
978
+ check: null
979
+ };
980
+
981
+ // Extract property type and check
982
+ const lower = property.toLowerCase();
983
+
984
+ if (lower.includes('should always') || lower.includes('for all')) {
985
+ parsed.type = 'invariant';
986
+ } else if (lower.includes('should be') || lower.includes('must be')) {
987
+ parsed.type = 'postcondition';
988
+ }
989
+
990
+ // Generate check function (simplified - would use LLM in production)
991
+ parsed.check = generatePropertyCheck(property);
992
+
993
+ propertyTests.push(parsed);
994
+ }
995
+
996
+ return {
997
+ properties: propertyTests,
998
+ generator: generator,
999
+ numRuns: numRuns,
1000
+ run: async function() {
1001
+ // Execute property tests
1002
+ const results = [];
1003
+
1004
+ for (const propertyTest of propertyTests) {
1005
+ try {
1006
+ // In production, would use fast-check or similar
1007
+ // For now, return structure
1008
+ results.push({
1009
+ property: propertyTest.description,
1010
+ type: propertyTest.type,
1011
+ status: 'pending' // Would be 'passed', 'failed', 'pending'
1012
+ });
1013
+ } catch (error) {
1014
+ results.push({
1015
+ property: propertyTest.description,
1016
+ type: propertyTest.type,
1017
+ status: 'error',
1018
+ error: error.message
1019
+ });
1020
+ }
1021
+ }
1022
+
1023
+ return results;
1024
+ }
1025
+ };
1026
+ }
1027
+
1028
+ /**
1029
+ * Generate property check function from natural language
1030
+ *
1031
+ * @param {string} property - Natural language property description
1032
+ * @returns {Function} Property check function
1033
+ */
1034
+ function generatePropertyCheck(property) {
1035
+ // Simplified - would use LLM to generate actual check function
1036
+ const lower = property.toLowerCase();
1037
+
1038
+ if (lower.includes('score') && lower.includes('between 0 and 10')) {
1039
+ return (result) => {
1040
+ return result.score >= 0 && result.score <= 10;
1041
+ };
1042
+ } else if (lower.includes('issues') && lower.includes('array')) {
1043
+ return (result) => {
1044
+ return Array.isArray(result.issues);
1045
+ };
1046
+ } else if (lower.includes('non-negative')) {
1047
+ return (value) => {
1048
+ return value >= 0;
1049
+ };
1050
+ }
1051
+
1052
+ // Default: always pass (would use LLM to generate actual check)
1053
+ return () => true;
1054
+ }
1055
+
1056
+ /**
1057
+ * Test behavior described in natural language
1058
+ *
1059
+ * @param {import('playwright').Page} page - Playwright page object
1060
+ * @param {string} behavior - Natural language behavior description
1061
+ * @param {Object} options - Test options
1062
+ * @returns {Promise<Object>} Behavior test results
1063
+ */
1064
+ export async function testBehavior(page, behavior, options = {}) {
1065
+ // Parse behavior as spec
1066
+ const spec = behavior;
1067
+
1068
+ // Execute as spec
1069
+ return await executeSpec(page, spec, options);
1070
+ }
1071
+