@arclabs561/ai-visual-test 0.5.1 → 0.7.3
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/CHANGELOG.md +102 -11
- package/DEPLOYMENT.md +225 -9
- package/README.md +71 -80
- package/index.d.ts +862 -3
- package/package.json +10 -51
- package/src/batch-optimizer.mjs +39 -0
- package/src/cache.mjs +241 -16
- package/src/config.mjs +33 -91
- package/src/constants.mjs +54 -0
- package/src/convenience.mjs +113 -10
- package/src/cost-optimization.mjs +1 -0
- package/src/cost-tracker.mjs +134 -2
- package/src/data-extractor.mjs +36 -7
- package/src/dynamic-few-shot.mjs +69 -11
- package/src/errors.mjs +6 -2
- package/src/experience-propagation.mjs +12 -0
- package/src/experience-tracer.mjs +12 -3
- package/src/game-player.mjs +222 -43
- package/src/graceful-shutdown.mjs +126 -0
- package/src/helpers/playwright.mjs +22 -8
- package/src/human-validation-manager.mjs +99 -2
- package/src/index.mjs +48 -3
- package/src/integrations/playwright.mjs +140 -0
- package/src/judge.mjs +697 -24
- package/src/load-env.mjs +2 -1
- package/src/logger.mjs +31 -3
- package/src/model-tier-selector.mjs +1 -221
- package/src/natural-language-specs.mjs +31 -3
- package/src/persona-enhanced.mjs +4 -2
- package/src/persona-experience.mjs +1 -1
- package/src/pricing.mjs +28 -0
- package/src/prompt-composer.mjs +162 -5
- package/src/provider-data.mjs +115 -0
- package/src/render-change-detector.mjs +5 -0
- package/src/research-enhanced-validation.mjs +7 -5
- package/src/retry.mjs +21 -7
- package/src/rubrics.mjs +4 -0
- package/src/safe-logger.mjs +71 -0
- package/src/session-cost-tracker.mjs +320 -0
- package/src/smart-validator.mjs +8 -8
- package/src/spec-templates.mjs +52 -6
- package/src/startup-validation.mjs +127 -0
- package/src/temporal-adaptive.mjs +2 -2
- package/src/temporal-decision-manager.mjs +1 -271
- package/src/temporal-logic.mjs +104 -0
- package/src/temporal-note-pruner.mjs +119 -0
- package/src/temporal-preprocessor.mjs +1 -543
- package/src/temporal.mjs +681 -79
- package/src/utils/action-hallucination-detector.mjs +301 -0
- package/src/utils/baseline-validator.mjs +82 -0
- package/src/utils/cache-stats.mjs +104 -0
- package/src/utils/cached-llm.mjs +164 -0
- package/src/utils/capability-stratifier.mjs +108 -0
- package/src/utils/counterfactual-tester.mjs +83 -0
- package/src/utils/error-recovery.mjs +117 -0
- package/src/utils/explainability-scorer.mjs +119 -0
- package/src/utils/exploratory-automation.mjs +131 -0
- package/src/utils/index.mjs +10 -0
- package/src/utils/intent-recognizer.mjs +201 -0
- package/src/utils/log-sanitizer.mjs +165 -0
- package/src/utils/path-validator.mjs +88 -0
- package/src/utils/performance-logger.mjs +316 -0
- package/src/utils/performance-measurement.mjs +280 -0
- package/src/utils/prompt-sanitizer.mjs +213 -0
- package/src/utils/rate-limiter.mjs +144 -0
- package/src/validation-framework.mjs +24 -20
- package/src/validation-result-normalizer.mjs +27 -1
- package/src/validation.mjs +75 -25
- package/src/validators/accessibility-validator.mjs +144 -0
- package/src/validators/hybrid-validator.mjs +48 -4
- package/api/health.js +0 -34
- package/api/validate.js +0 -252
- package/public/index.html +0 -149
- package/vercel.json +0 -27
|
@@ -219,5 +219,149 @@ Return detailed assessment with:
|
|
|
219
219
|
meetsRequirement: ratios.length > 0 ? ratios.every(r => r >= this.minContrast) : null
|
|
220
220
|
};
|
|
221
221
|
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Hybrid accessibility validation (programmatic + VLLM)
|
|
225
|
+
* Combines fast programmatic checks with semantic VLLM evaluation
|
|
226
|
+
*
|
|
227
|
+
* @param {any} page - Playwright page object
|
|
228
|
+
* @param {string} screenshotPath - Path to screenshot
|
|
229
|
+
* @param {Object} options - Validation options
|
|
230
|
+
* @returns {Promise<Object>} Combined validation result
|
|
231
|
+
*/
|
|
232
|
+
async validateHybrid(page, screenshotPath, options = {}) {
|
|
233
|
+
if (!page) {
|
|
234
|
+
throw new ValidationError('validateHybrid: page is required');
|
|
235
|
+
}
|
|
236
|
+
if (!screenshotPath) {
|
|
237
|
+
throw new ValidationError('validateHybrid: screenshotPath is required');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Run programmatic checks (fast, deterministic)
|
|
241
|
+
const programmaticChecks = await this._runProgrammaticChecks(page, options);
|
|
242
|
+
|
|
243
|
+
// Run VLLM semantic evaluation (comprehensive, contextual)
|
|
244
|
+
const semanticEvaluation = await this.validateAccessibility(screenshotPath, {
|
|
245
|
+
...options,
|
|
246
|
+
testType: 'accessibility-hybrid-semantic'
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Combine results
|
|
250
|
+
const combined = {
|
|
251
|
+
passed: programmaticChecks.passed && semanticEvaluation.score >= 7,
|
|
252
|
+
programmatic: programmaticChecks,
|
|
253
|
+
semantic: semanticEvaluation,
|
|
254
|
+
method: 'hybrid',
|
|
255
|
+
issues: [
|
|
256
|
+
...programmaticChecks.violations || [],
|
|
257
|
+
...semanticEvaluation.issues || []
|
|
258
|
+
],
|
|
259
|
+
// Deduplicate issues
|
|
260
|
+
uniqueIssues: this._deduplicateIssues([
|
|
261
|
+
...(programmaticChecks.violations || []),
|
|
262
|
+
...(semanticEvaluation.issues || [])
|
|
263
|
+
])
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return combined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Run programmatic accessibility checks
|
|
271
|
+
*/
|
|
272
|
+
async _runProgrammaticChecks(page, options = {}) {
|
|
273
|
+
const checks = {
|
|
274
|
+
contrast: { passed: true, violations: [] },
|
|
275
|
+
keyboard: { passed: true, violations: [] },
|
|
276
|
+
altText: { passed: true, violations: [] }
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
// Check contrast (if page has text elements)
|
|
281
|
+
const contrastResult = await page.evaluate((minContrast) => {
|
|
282
|
+
const violations = [];
|
|
283
|
+
const textElements = Array.from(document.querySelectorAll('*')).filter(el => {
|
|
284
|
+
const style = window.getComputedStyle(el);
|
|
285
|
+
return style.color !== 'transparent' &&
|
|
286
|
+
el.textContent &&
|
|
287
|
+
el.textContent.trim().length > 0;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Simple contrast check (would need actual contrast calculation in real implementation)
|
|
291
|
+
return { passed: true, violations: [] };
|
|
292
|
+
}, this.minContrast);
|
|
293
|
+
|
|
294
|
+
checks.contrast = contrastResult;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
checks.contrast = { passed: false, violations: [`Contrast check failed: ${err.message}`] };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
// Check keyboard navigation
|
|
301
|
+
const keyboardResult = await page.evaluate(() => {
|
|
302
|
+
const violations = [];
|
|
303
|
+
const interactiveElements = Array.from(document.querySelectorAll('a, button, input, select, textarea, [tabindex]'));
|
|
304
|
+
|
|
305
|
+
for (const el of interactiveElements) {
|
|
306
|
+
if (el.tabIndex < 0 && !el.hasAttribute('tabindex')) {
|
|
307
|
+
violations.push(`Element ${el.tagName} may not be keyboard accessible`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { passed: violations.length === 0, violations };
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
checks.keyboard = keyboardResult;
|
|
315
|
+
} catch (err) {
|
|
316
|
+
checks.keyboard = { passed: false, violations: [`Keyboard check failed: ${err.message}`] };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
// Check alt text
|
|
321
|
+
const altTextResult = await page.evaluate(() => {
|
|
322
|
+
const violations = [];
|
|
323
|
+
const images = Array.from(document.querySelectorAll('img'));
|
|
324
|
+
|
|
325
|
+
for (const img of images) {
|
|
326
|
+
if (!img.alt && !img.getAttribute('aria-hidden')) {
|
|
327
|
+
violations.push(`Image missing alt text: ${img.src}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { passed: violations.length === 0, violations };
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
checks.altText = altTextResult;
|
|
335
|
+
} catch (err) {
|
|
336
|
+
checks.altText = { passed: false, violations: [`Alt text check failed: ${err.message}`] };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const allViolations = [
|
|
340
|
+
...checks.contrast.violations,
|
|
341
|
+
...checks.keyboard.violations,
|
|
342
|
+
...checks.altText.violations
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
passed: allViolations.length === 0,
|
|
347
|
+
violations: allViolations,
|
|
348
|
+
checks
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Deduplicate issues
|
|
354
|
+
*/
|
|
355
|
+
_deduplicateIssues(issues) {
|
|
356
|
+
const seen = new Set();
|
|
357
|
+
return issues.filter(issue => {
|
|
358
|
+
const key = typeof issue === 'string' ? issue : JSON.stringify(issue);
|
|
359
|
+
if (seen.has(key)) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
seen.add(key);
|
|
363
|
+
return true;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
222
366
|
}
|
|
223
367
|
|
|
@@ -42,6 +42,18 @@ function getValidateScreenshot() {
|
|
|
42
42
|
return injectedValidateScreenshot || validateScreenshot;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function deduplicateIssues(issues) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
return issues.filter(issue => {
|
|
48
|
+
const key = typeof issue === 'string' ? issue : JSON.stringify(issue);
|
|
49
|
+
if (seen.has(key)) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
seen.add(key);
|
|
53
|
+
return true;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
/**
|
|
46
58
|
* Hybrid accessibility validation
|
|
47
59
|
*
|
|
@@ -105,7 +117,7 @@ Use this programmatic data as ground truth (no hallucinations about measurements
|
|
|
105
117
|
Evaluate semantic aspects:
|
|
106
118
|
1. Is contrast adequate for readability in context? (ratio alone doesn't tell you if it's readable)
|
|
107
119
|
2. Are contrast violations critical or minor? (some violations might be acceptable in context)
|
|
108
|
-
3. Is keyboard navigation
|
|
120
|
+
3. Is keyboard navigation usable? (semantic evaluation beyond just focusable elements)
|
|
109
121
|
4. Does overall accessibility support user goals? (holistic evaluation)
|
|
110
122
|
5. Are there accessibility issues that programmatic checks don't capture? (visual, semantic, contextual)
|
|
111
123
|
|
|
@@ -120,9 +132,39 @@ Provide actionable recommendations based on both programmatic and semantic analy
|
|
|
120
132
|
programmaticData
|
|
121
133
|
});
|
|
122
134
|
|
|
135
|
+
// Calculate programmatic pass status
|
|
136
|
+
const programmaticPassed = (programmaticData.contrast.failing === 0) &&
|
|
137
|
+
(programmaticData.keyboard.violations.length === 0);
|
|
138
|
+
|
|
139
|
+
// Combine results
|
|
123
140
|
return {
|
|
124
141
|
...result,
|
|
125
|
-
|
|
142
|
+
passed: programmaticPassed && (result.score === null || result.score >= 6),
|
|
143
|
+
programmaticData, // Required by tests/consumers
|
|
144
|
+
programmatic: programmaticData, // Alias for clarity
|
|
145
|
+
semantic: result,
|
|
146
|
+
method: 'hybrid',
|
|
147
|
+
issues: [
|
|
148
|
+
...(programmaticData.contrast.violations || []),
|
|
149
|
+
...(programmaticData.keyboard.violations || []),
|
|
150
|
+
...result.issues || []
|
|
151
|
+
],
|
|
152
|
+
uniqueIssues: deduplicateIssues([
|
|
153
|
+
...(programmaticData.contrast.violations || []),
|
|
154
|
+
...(programmaticData.keyboard.violations || []),
|
|
155
|
+
...(result.issues || [])
|
|
156
|
+
]).map(issue => {
|
|
157
|
+
// Normalize to strings for consistent formatting
|
|
158
|
+
if (typeof issue === 'string') return issue;
|
|
159
|
+
if (typeof issue === 'object' && issue !== null) {
|
|
160
|
+
if (issue.description) return issue.description;
|
|
161
|
+
if (issue.element && issue.issue) return `${issue.element}: ${issue.issue}`;
|
|
162
|
+
if (issue.ratio && issue.required) return `Contrast ${issue.ratio}:1 (required: ${issue.required}:1)`;
|
|
163
|
+
if (issue.message) return issue.message;
|
|
164
|
+
return JSON.stringify(issue);
|
|
165
|
+
}
|
|
166
|
+
return String(issue);
|
|
167
|
+
})
|
|
126
168
|
};
|
|
127
169
|
}
|
|
128
170
|
|
|
@@ -216,7 +258,8 @@ Provide actionable recommendations based on both programmatic and semantic analy
|
|
|
216
258
|
|
|
217
259
|
return {
|
|
218
260
|
...result,
|
|
219
|
-
programmaticData
|
|
261
|
+
programmaticData,
|
|
262
|
+
method: 'hybrid'
|
|
220
263
|
};
|
|
221
264
|
}
|
|
222
265
|
|
|
@@ -262,7 +305,8 @@ EVALUATION INSTRUCTIONS:
|
|
|
262
305
|
|
|
263
306
|
return {
|
|
264
307
|
...result,
|
|
265
|
-
programmaticData
|
|
308
|
+
programmaticData,
|
|
309
|
+
method: 'hybrid'
|
|
266
310
|
};
|
|
267
311
|
}
|
|
268
312
|
|
package/api/health.js
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Health check endpoint
|
|
3
|
-
*
|
|
4
|
-
* GET /api/health
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { createConfig } from '../src/index.mjs';
|
|
8
|
-
|
|
9
|
-
export default async function handler(req, res) {
|
|
10
|
-
if (req.method !== 'GET') {
|
|
11
|
-
return res.status(405).json({ error: 'Method not allowed' });
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
const config = createConfig();
|
|
16
|
-
|
|
17
|
-
return res.status(200).json({
|
|
18
|
-
status: 'ok',
|
|
19
|
-
enabled: config.enabled,
|
|
20
|
-
provider: config.provider,
|
|
21
|
-
version: '0.1.0',
|
|
22
|
-
timestamp: new Date().toISOString()
|
|
23
|
-
});
|
|
24
|
-
} catch (error) {
|
|
25
|
-
// SECURITY: Don't expose internal error details
|
|
26
|
-
// Log server-side for debugging, return generic message to client
|
|
27
|
-
console.error('[Health] Error:', error);
|
|
28
|
-
return res.status(500).json({
|
|
29
|
-
status: 'error',
|
|
30
|
-
error: 'Health check failed'
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
package/api/validate.js
DELETED
|
@@ -1,252 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vercel Serverless Function for VLLM Screenshot Validation
|
|
3
|
-
*
|
|
4
|
-
* POST /api/validate
|
|
5
|
-
*
|
|
6
|
-
* Body:
|
|
7
|
-
* {
|
|
8
|
-
* "image": "base64-encoded-image",
|
|
9
|
-
* "prompt": "Evaluation prompt",
|
|
10
|
-
* "context": { ... }
|
|
11
|
-
* }
|
|
12
|
-
*
|
|
13
|
-
* Returns:
|
|
14
|
-
* {
|
|
15
|
-
* "enabled": boolean,
|
|
16
|
-
* "provider": string,
|
|
17
|
-
* "score": number|null,
|
|
18
|
-
* "issues": string[],
|
|
19
|
-
* "assessment": string|null,
|
|
20
|
-
* "reasoning": string,
|
|
21
|
-
* "estimatedCost": object|null,
|
|
22
|
-
* "responseTime": number
|
|
23
|
-
* }
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import { validateScreenshot, createConfig, normalizeValidationResult } from '../src/index.mjs';
|
|
27
|
-
import { writeFileSync, unlinkSync } from 'fs';
|
|
28
|
-
import { join } from 'path';
|
|
29
|
-
import { tmpdir } from 'os';
|
|
30
|
-
import { randomBytes } from 'crypto';
|
|
31
|
-
|
|
32
|
-
// Security limits
|
|
33
|
-
const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
34
|
-
const MAX_PROMPT_LENGTH = 5000;
|
|
35
|
-
const MAX_CONTEXT_SIZE = 10000;
|
|
36
|
-
|
|
37
|
-
// Rate limiting configuration
|
|
38
|
-
const RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute
|
|
39
|
-
const RATE_LIMIT_MAX_REQUESTS = parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '10', 10);
|
|
40
|
-
const rateLimitStore = new Map(); // In-memory store (use Redis in production)
|
|
41
|
-
|
|
42
|
-
// Authentication configuration
|
|
43
|
-
const API_KEY = process.env.API_KEY || process.env.VLLM_API_KEY || null;
|
|
44
|
-
// Default to requiring auth if API key is set (more secure)
|
|
45
|
-
// Set REQUIRE_AUTH=false explicitly to disable
|
|
46
|
-
const REQUIRE_AUTH = process.env.REQUIRE_AUTH !== 'false' && API_KEY !== null;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Simple rate limiter (in-memory)
|
|
50
|
-
* For production, use Redis or a dedicated rate limiting service
|
|
51
|
-
*/
|
|
52
|
-
function checkRateLimit(identifier) {
|
|
53
|
-
const now = Date.now();
|
|
54
|
-
const windowStart = now - RATE_LIMIT_WINDOW;
|
|
55
|
-
|
|
56
|
-
// Clean up old entries
|
|
57
|
-
for (const [key, timestamps] of rateLimitStore.entries()) {
|
|
58
|
-
const recent = timestamps.filter(ts => ts > windowStart);
|
|
59
|
-
if (recent.length === 0) {
|
|
60
|
-
rateLimitStore.delete(key);
|
|
61
|
-
} else {
|
|
62
|
-
rateLimitStore.set(key, recent);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Check current identifier
|
|
67
|
-
const timestamps = rateLimitStore.get(identifier) || [];
|
|
68
|
-
const recent = timestamps.filter(ts => ts > windowStart);
|
|
69
|
-
|
|
70
|
-
if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
|
|
71
|
-
return {
|
|
72
|
-
allowed: false,
|
|
73
|
-
remaining: 0,
|
|
74
|
-
resetAt: Math.min(...recent) + RATE_LIMIT_WINDOW
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Add current request
|
|
79
|
-
recent.push(now);
|
|
80
|
-
rateLimitStore.set(identifier, recent);
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
allowed: true,
|
|
84
|
-
remaining: RATE_LIMIT_MAX_REQUESTS - recent.length,
|
|
85
|
-
resetAt: now + RATE_LIMIT_WINDOW
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Get client identifier for rate limiting
|
|
91
|
-
*/
|
|
92
|
-
function getClientIdentifier(req) {
|
|
93
|
-
// Try to get IP from various headers (Vercel, Cloudflare, etc.)
|
|
94
|
-
const forwarded = req.headers['x-forwarded-for'];
|
|
95
|
-
const realIp = req.headers['x-real-ip'];
|
|
96
|
-
const ip = forwarded?.split(',')[0] || realIp || req.socket?.remoteAddress || 'unknown';
|
|
97
|
-
|
|
98
|
-
// If API key is provided, use it as identifier (more accurate)
|
|
99
|
-
const apiKey = req.headers['x-api-key'] || req.headers['authorization']?.replace('Bearer ', '');
|
|
100
|
-
return apiKey || ip;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Check authentication
|
|
105
|
-
*/
|
|
106
|
-
function checkAuth(req) {
|
|
107
|
-
if (!REQUIRE_AUTH || !API_KEY) {
|
|
108
|
-
return { authenticated: true };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// SECURITY: Only accept API key from headers, not request body
|
|
112
|
-
// API keys in request bodies are logged, visible in dev tools, and stored in history
|
|
113
|
-
const providedKey = req.headers['x-api-key'] ||
|
|
114
|
-
req.headers['authorization']?.replace('Bearer ', '');
|
|
115
|
-
|
|
116
|
-
if (!providedKey) {
|
|
117
|
-
return { authenticated: false, error: 'Authentication required. Provide API key via X-API-Key header or Authorization: Bearer <key>' };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (providedKey !== API_KEY) {
|
|
121
|
-
return { authenticated: false, error: 'Invalid API key' };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
return { authenticated: true };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export default async function handler(req, res) {
|
|
128
|
-
// Only allow POST
|
|
129
|
-
if (req.method !== 'POST') {
|
|
130
|
-
return res.status(405).json({ error: 'Method not allowed' });
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Check authentication
|
|
134
|
-
const authResult = checkAuth(req);
|
|
135
|
-
if (!authResult.authenticated) {
|
|
136
|
-
return res.status(401).json({ error: authResult.error });
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Check rate limit
|
|
140
|
-
const clientId = getClientIdentifier(req);
|
|
141
|
-
const rateLimit = checkRateLimit(clientId);
|
|
142
|
-
if (!rateLimit.allowed) {
|
|
143
|
-
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS);
|
|
144
|
-
res.setHeader('X-RateLimit-Remaining', 0);
|
|
145
|
-
res.setHeader('X-RateLimit-Reset', new Date(rateLimit.resetAt).toISOString());
|
|
146
|
-
return res.status(429).json({
|
|
147
|
-
error: 'Rate limit exceeded',
|
|
148
|
-
retryAfter: Math.ceil((rateLimit.resetAt - Date.now()) / 1000)
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Set rate limit headers
|
|
153
|
-
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX_REQUESTS);
|
|
154
|
-
res.setHeader('X-RateLimit-Remaining', rateLimit.remaining);
|
|
155
|
-
res.setHeader('X-RateLimit-Reset', new Date(rateLimit.resetAt).toISOString());
|
|
156
|
-
|
|
157
|
-
try {
|
|
158
|
-
const { image, prompt, context = {} } = req.body;
|
|
159
|
-
|
|
160
|
-
// Validate input presence
|
|
161
|
-
if (!image) {
|
|
162
|
-
return res.status(400).json({ error: 'Missing image (base64 encoded)' });
|
|
163
|
-
}
|
|
164
|
-
if (!prompt) {
|
|
165
|
-
return res.status(400).json({ error: 'Missing prompt' });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Validate input size
|
|
169
|
-
if (typeof image !== 'string' || image.length > MAX_IMAGE_SIZE) {
|
|
170
|
-
return res.status(400).json({ error: 'Image too large or invalid format' });
|
|
171
|
-
}
|
|
172
|
-
if (typeof prompt !== 'string' || prompt.length > MAX_PROMPT_LENGTH) {
|
|
173
|
-
return res.status(400).json({ error: 'Prompt too long' });
|
|
174
|
-
}
|
|
175
|
-
if (context && typeof context === 'object') {
|
|
176
|
-
const contextSize = JSON.stringify(context).length;
|
|
177
|
-
if (contextSize > MAX_CONTEXT_SIZE) {
|
|
178
|
-
return res.status(400).json({ error: 'Context too large' });
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Decode base64 image
|
|
183
|
-
// SECURITY: Whitelist specific MIME types to prevent unexpected formats
|
|
184
|
-
const validMimeTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp'];
|
|
185
|
-
const mimeMatch = image.match(/^data:(image\/(?:png|jpeg|jpg|gif|webp));base64,/);
|
|
186
|
-
if (!mimeMatch) {
|
|
187
|
-
return res.status(400).json({ error: 'Invalid image MIME type. Supported: image/png, image/jpeg, image/jpg, image/gif, image/webp' });
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
let imageBuffer;
|
|
191
|
-
try {
|
|
192
|
-
const base64Data = image.replace(/^data:image\/(?:png|jpeg|jpg|gif|webp);base64,/, '');
|
|
193
|
-
imageBuffer = Buffer.from(base64Data, 'base64');
|
|
194
|
-
|
|
195
|
-
// Additional validation: check decoded buffer size matches expected
|
|
196
|
-
// Base64 encoding increases size by ~33%, so decoded should be smaller
|
|
197
|
-
const expectedMaxDecoded = Math.floor(MAX_IMAGE_SIZE * 0.75); // Conservative estimate
|
|
198
|
-
if (imageBuffer.length > expectedMaxDecoded) {
|
|
199
|
-
return res.status(400).json({ error: 'Decoded image exceeds maximum size' });
|
|
200
|
-
}
|
|
201
|
-
} catch (error) {
|
|
202
|
-
return res.status(400).json({ error: 'Invalid base64 image' });
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Save to temporary file with secure random name (prevents race conditions and information disclosure)
|
|
206
|
-
// SECURITY: Use cryptographically secure random suffix to prevent collisions
|
|
207
|
-
const randomSuffix = randomBytes(16).toString('hex');
|
|
208
|
-
const tempPath = join(tmpdir(), `vllm-validate-${randomSuffix}.png`);
|
|
209
|
-
|
|
210
|
-
// RESOURCE PROTECTION: File system operation is rate-limited by API rate limiting above
|
|
211
|
-
// This writeFileSync is bounded by:
|
|
212
|
-
// 1. Rate limiting (prevents too many concurrent operations)
|
|
213
|
-
// 2. Size limits (MAX_IMAGE_SIZE prevents large files)
|
|
214
|
-
// 3. Serverless timeout (function will timeout if operation takes too long)
|
|
215
|
-
writeFileSync(tempPath, imageBuffer);
|
|
216
|
-
|
|
217
|
-
try {
|
|
218
|
-
// Validate screenshot
|
|
219
|
-
const result = await validateScreenshot(tempPath, prompt, context);
|
|
220
|
-
|
|
221
|
-
// Clean up temp file
|
|
222
|
-
unlinkSync(tempPath);
|
|
223
|
-
|
|
224
|
-
// Normalize result structure before returning (ensures consistent API response)
|
|
225
|
-
const normalizedResult = normalizeValidationResult(result, 'api/validate');
|
|
226
|
-
|
|
227
|
-
// Return normalized result
|
|
228
|
-
return res.status(200).json(normalizedResult);
|
|
229
|
-
} catch (error) {
|
|
230
|
-
// Clean up temp file on error
|
|
231
|
-
try {
|
|
232
|
-
unlinkSync(tempPath);
|
|
233
|
-
} catch {}
|
|
234
|
-
|
|
235
|
-
throw error;
|
|
236
|
-
}
|
|
237
|
-
} catch (error) {
|
|
238
|
-
// Log full error for debugging (server-side only)
|
|
239
|
-
console.error('[VLLM API] Error:', error);
|
|
240
|
-
|
|
241
|
-
// Return sanitized error to client (don't leak internal details)
|
|
242
|
-
// Never expose: file paths, API keys, internal structure, stack traces
|
|
243
|
-
const sanitizedError = error instanceof Error
|
|
244
|
-
? 'Validation failed. Please check your input and try again.'
|
|
245
|
-
: 'Validation failed';
|
|
246
|
-
|
|
247
|
-
return res.status(500).json({
|
|
248
|
-
error: sanitizedError
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
package/public/index.html
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>VLLM Testing - Visual Validation API</title>
|
|
7
|
-
<style>
|
|
8
|
-
* {
|
|
9
|
-
margin: 0;
|
|
10
|
-
padding: 0;
|
|
11
|
-
box-sizing: border-box;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
body {
|
|
15
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
16
|
-
background: #0a0a0a;
|
|
17
|
-
color: #ffffff;
|
|
18
|
-
line-height: 1.6;
|
|
19
|
-
padding: 2rem;
|
|
20
|
-
max-width: 1200px;
|
|
21
|
-
margin: 0 auto;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
h1 {
|
|
25
|
-
font-size: 2.5rem;
|
|
26
|
-
margin-bottom: 1rem;
|
|
27
|
-
color: #ffffff;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
h2 {
|
|
31
|
-
font-size: 1.5rem;
|
|
32
|
-
margin-top: 2rem;
|
|
33
|
-
margin-bottom: 1rem;
|
|
34
|
-
color: #ffffff;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
.status {
|
|
38
|
-
display: inline-block;
|
|
39
|
-
padding: 0.5rem 1rem;
|
|
40
|
-
border-radius: 4px;
|
|
41
|
-
font-weight: bold;
|
|
42
|
-
margin-bottom: 2rem;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
.status.ok {
|
|
46
|
-
background: #00ff00;
|
|
47
|
-
color: #000000;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
.status.error {
|
|
51
|
-
background: #ff0000;
|
|
52
|
-
color: #ffffff;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
code {
|
|
56
|
-
background: #1a1a1a;
|
|
57
|
-
padding: 0.2rem 0.4rem;
|
|
58
|
-
border-radius: 3px;
|
|
59
|
-
font-family: 'Monaco', 'Courier New', monospace;
|
|
60
|
-
font-size: 0.9em;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
pre {
|
|
64
|
-
background: #1a1a1a;
|
|
65
|
-
padding: 1rem;
|
|
66
|
-
border-radius: 4px;
|
|
67
|
-
overflow-x: auto;
|
|
68
|
-
margin: 1rem 0;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
.endpoint {
|
|
72
|
-
background: #1a1a1a;
|
|
73
|
-
padding: 1.5rem;
|
|
74
|
-
border-radius: 4px;
|
|
75
|
-
margin: 1rem 0;
|
|
76
|
-
border-left: 4px solid #00ff00;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
.method {
|
|
80
|
-
display: inline-block;
|
|
81
|
-
padding: 0.25rem 0.5rem;
|
|
82
|
-
border-radius: 3px;
|
|
83
|
-
font-weight: bold;
|
|
84
|
-
margin-right: 0.5rem;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
.method.post {
|
|
88
|
-
background: #00ff00;
|
|
89
|
-
color: #000000;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
.method.get {
|
|
93
|
-
background: #0066ff;
|
|
94
|
-
color: #ffffff;
|
|
95
|
-
}
|
|
96
|
-
</style>
|
|
97
|
-
</head>
|
|
98
|
-
<body>
|
|
99
|
-
<h1>VLLM Testing API</h1>
|
|
100
|
-
<div id="status" class="status">Checking...</div>
|
|
101
|
-
|
|
102
|
-
<h2>API Endpoints</h2>
|
|
103
|
-
|
|
104
|
-
<div class="endpoint">
|
|
105
|
-
<span class="method post">POST</span>
|
|
106
|
-
<code>/api/validate</code>
|
|
107
|
-
<p style="margin-top: 1rem;">Validate a screenshot using Vision Language Models.</p>
|
|
108
|
-
<pre>{
|
|
109
|
-
"image": "base64-encoded-image",
|
|
110
|
-
"prompt": "Evaluate this screenshot...",
|
|
111
|
-
"context": {
|
|
112
|
-
"testType": "payment-screen",
|
|
113
|
-
"viewport": { "width": 1280, "height": 720 }
|
|
114
|
-
}
|
|
115
|
-
}</pre>
|
|
116
|
-
</div>
|
|
117
|
-
|
|
118
|
-
<div class="endpoint">
|
|
119
|
-
<span class="method get">GET</span>
|
|
120
|
-
<code>/api/health</code>
|
|
121
|
-
<p style="margin-top: 1rem;">Health check endpoint.</p>
|
|
122
|
-
</div>
|
|
123
|
-
|
|
124
|
-
<h2>Documentation</h2>
|
|
125
|
-
<p>See <a href="https://github.com/arclabs561/ai-visual-test" style="color: #00ff00;">GitHub repository</a> for full documentation.</p>
|
|
126
|
-
|
|
127
|
-
<script>
|
|
128
|
-
// Check health on load
|
|
129
|
-
fetch('/api/health')
|
|
130
|
-
.then(res => res.json())
|
|
131
|
-
.then(data => {
|
|
132
|
-
const statusEl = document.getElementById('status');
|
|
133
|
-
if (data.status === 'ok') {
|
|
134
|
-
statusEl.className = 'status ok';
|
|
135
|
-
statusEl.textContent = `✓ API Online - Provider: ${data.provider || 'none'}`;
|
|
136
|
-
} else {
|
|
137
|
-
statusEl.className = 'status error';
|
|
138
|
-
statusEl.textContent = '✗ API Error';
|
|
139
|
-
}
|
|
140
|
-
})
|
|
141
|
-
.catch(err => {
|
|
142
|
-
const statusEl = document.getElementById('status');
|
|
143
|
-
statusEl.className = 'status error';
|
|
144
|
-
statusEl.textContent = '✗ API Offline';
|
|
145
|
-
});
|
|
146
|
-
</script>
|
|
147
|
-
</body>
|
|
148
|
-
</html>
|
|
149
|
-
|
package/vercel.json
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 2,
|
|
3
|
-
"builds": [
|
|
4
|
-
{
|
|
5
|
-
"src": "api/**/*.js",
|
|
6
|
-
"use": "@vercel/node"
|
|
7
|
-
}
|
|
8
|
-
],
|
|
9
|
-
"routes": [
|
|
10
|
-
{
|
|
11
|
-
"src": "/api/validate",
|
|
12
|
-
"dest": "/api/validate.js"
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
"src": "/api/health",
|
|
16
|
-
"dest": "/api/health.js"
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
"src": "/",
|
|
20
|
-
"dest": "/public/index.html"
|
|
21
|
-
}
|
|
22
|
-
],
|
|
23
|
-
"env": {
|
|
24
|
-
"NODE_ENV": "production"
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|