@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.
- package/.secretsignore.example +20 -0
- package/CHANGELOG.md +360 -0
- package/CONTRIBUTING.md +63 -0
- package/DEPLOYMENT.md +80 -0
- package/LICENSE +22 -0
- package/README.md +142 -0
- package/SECURITY.md +108 -0
- package/api/health.js +34 -0
- package/api/validate.js +252 -0
- package/index.d.ts +1221 -0
- package/package.json +112 -0
- package/public/index.html +149 -0
- package/src/batch-optimizer.mjs +451 -0
- package/src/bias-detector.mjs +370 -0
- package/src/bias-mitigation.mjs +233 -0
- package/src/cache.mjs +433 -0
- package/src/config.mjs +268 -0
- package/src/constants.mjs +80 -0
- package/src/context-compressor.mjs +350 -0
- package/src/convenience.mjs +617 -0
- package/src/cost-tracker.mjs +257 -0
- package/src/cross-modal-consistency.mjs +170 -0
- package/src/data-extractor.mjs +232 -0
- package/src/dynamic-few-shot.mjs +140 -0
- package/src/dynamic-prompts.mjs +361 -0
- package/src/ensemble/index.mjs +53 -0
- package/src/ensemble-judge.mjs +366 -0
- package/src/error-handler.mjs +67 -0
- package/src/errors.mjs +167 -0
- package/src/experience-propagation.mjs +128 -0
- package/src/experience-tracer.mjs +487 -0
- package/src/explanation-manager.mjs +299 -0
- package/src/feedback-aggregator.mjs +248 -0
- package/src/game-goal-prompts.mjs +478 -0
- package/src/game-player.mjs +548 -0
- package/src/hallucination-detector.mjs +155 -0
- package/src/helpers/playwright.mjs +80 -0
- package/src/human-validation-manager.mjs +516 -0
- package/src/index.mjs +364 -0
- package/src/judge.mjs +929 -0
- package/src/latency-aware-batch-optimizer.mjs +192 -0
- package/src/load-env.mjs +159 -0
- package/src/logger.mjs +55 -0
- package/src/metrics.mjs +187 -0
- package/src/model-tier-selector.mjs +221 -0
- package/src/multi-modal/index.mjs +36 -0
- package/src/multi-modal-fusion.mjs +190 -0
- package/src/multi-modal.mjs +524 -0
- package/src/natural-language-specs.mjs +1071 -0
- package/src/pair-comparison.mjs +277 -0
- package/src/persona/index.mjs +42 -0
- package/src/persona-enhanced.mjs +200 -0
- package/src/persona-experience.mjs +572 -0
- package/src/position-counterbalance.mjs +140 -0
- package/src/prompt-composer.mjs +375 -0
- package/src/render-change-detector.mjs +583 -0
- package/src/research-enhanced-validation.mjs +436 -0
- package/src/retry.mjs +152 -0
- package/src/rubrics.mjs +231 -0
- package/src/score-tracker.mjs +277 -0
- package/src/smart-validator.mjs +447 -0
- package/src/spec-config.mjs +106 -0
- package/src/spec-templates.mjs +347 -0
- package/src/specs/index.mjs +38 -0
- package/src/temporal/index.mjs +102 -0
- package/src/temporal-adaptive.mjs +163 -0
- package/src/temporal-batch-optimizer.mjs +222 -0
- package/src/temporal-constants.mjs +69 -0
- package/src/temporal-context.mjs +49 -0
- package/src/temporal-decision-manager.mjs +271 -0
- package/src/temporal-decision.mjs +669 -0
- package/src/temporal-errors.mjs +58 -0
- package/src/temporal-note-pruner.mjs +173 -0
- package/src/temporal-preprocessor.mjs +543 -0
- package/src/temporal-prompt-formatter.mjs +219 -0
- package/src/temporal-validation.mjs +159 -0
- package/src/temporal.mjs +415 -0
- package/src/type-guards.mjs +311 -0
- package/src/uncertainty-reducer.mjs +470 -0
- package/src/utils/index.mjs +175 -0
- package/src/validation-framework.mjs +321 -0
- package/src/validation-result-normalizer.mjs +64 -0
- package/src/validation.mjs +243 -0
- package/src/validators/accessibility-programmatic.mjs +345 -0
- package/src/validators/accessibility-validator.mjs +223 -0
- package/src/validators/batch-validator.mjs +143 -0
- package/src/validators/hybrid-validator.mjs +268 -0
- package/src/validators/index.mjs +34 -0
- package/src/validators/prompt-builder.mjs +218 -0
- package/src/validators/rubric.mjs +85 -0
- package/src/validators/state-programmatic.mjs +260 -0
- package/src/validators/state-validator.mjs +291 -0
- 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
|
+
|