@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,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render Change Detector
|
|
3
|
+
*
|
|
4
|
+
* Detects when page content actually renders/changes and triggers screenshots.
|
|
5
|
+
* Uses MutationObserver, ResizeObserver, requestAnimationFrame, and visual diff detection.
|
|
6
|
+
*
|
|
7
|
+
* Research: Visual event detection improves temporal understanding (VETL, WALT)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { warn, log } from './logger.mjs';
|
|
11
|
+
import { existsSync, statSync } from 'fs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detect render changes using MutationObserver + ResizeObserver + requestAnimationFrame
|
|
15
|
+
*
|
|
16
|
+
* Enhanced to detect:
|
|
17
|
+
* - DOM mutations (MutationObserver)
|
|
18
|
+
* - Layout changes (ResizeObserver)
|
|
19
|
+
* - Visual updates (requestAnimationFrame polling for CSS animations)
|
|
20
|
+
*
|
|
21
|
+
* @param {any} page - Playwright page object
|
|
22
|
+
* @param {Function} onChange - Callback when change detected
|
|
23
|
+
* @param {Object} options - Detection options
|
|
24
|
+
* @returns {Promise<Function>} Cleanup function
|
|
25
|
+
*/
|
|
26
|
+
export async function detectRenderChanges(page, onChange, options = {}) {
|
|
27
|
+
const {
|
|
28
|
+
subtree = true,
|
|
29
|
+
childList = true,
|
|
30
|
+
attributes = true,
|
|
31
|
+
characterData = true,
|
|
32
|
+
attributeFilter = null,
|
|
33
|
+
pollInterval = 100, // Check every 100ms
|
|
34
|
+
detectCSSAnimations = true, // Also detect CSS animations via RAF polling
|
|
35
|
+
detectLayoutChanges = true // Use ResizeObserver for layout changes
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
let observer = null;
|
|
39
|
+
let resizeObserver = null;
|
|
40
|
+
let rafId = null;
|
|
41
|
+
let changeCount = 0;
|
|
42
|
+
const changeHistory = [];
|
|
43
|
+
const maxHistory = 100;
|
|
44
|
+
let lastRAFTime = 0;
|
|
45
|
+
let rafChangeCount = 0;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
// Set up MutationObserver in page context
|
|
49
|
+
await page.evaluate(({ subtree, childList, attributes, characterData, attributeFilter, detectLayoutChanges }) => {
|
|
50
|
+
// MutationObserver for DOM changes
|
|
51
|
+
window.__renderChangeObserver = new MutationObserver((mutations) => {
|
|
52
|
+
window.__renderChangeCount = (window.__renderChangeCount || 0) + 1;
|
|
53
|
+
window.__renderChangeTime = Date.now();
|
|
54
|
+
window.__renderChangeMutations = mutations.length;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const config = {
|
|
58
|
+
subtree,
|
|
59
|
+
childList,
|
|
60
|
+
attributes,
|
|
61
|
+
characterData
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
if (attributeFilter) {
|
|
65
|
+
config.attributeFilter = attributeFilter;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
window.__renderChangeObserver.observe(document.body, config);
|
|
69
|
+
|
|
70
|
+
// ResizeObserver for layout changes (if enabled)
|
|
71
|
+
if (detectLayoutChanges) {
|
|
72
|
+
window.__renderResizeObserver = new ResizeObserver((entries) => {
|
|
73
|
+
window.__renderResizeCount = (window.__renderResizeCount || 0) + 1;
|
|
74
|
+
window.__renderResizeTime = Date.now();
|
|
75
|
+
window.__renderResizeEntries = entries.length;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
window.__renderResizeObserver.observe(document.body);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// requestAnimationFrame polling for CSS animations (if enabled)
|
|
82
|
+
if (window.__detectCSSAnimations) {
|
|
83
|
+
let lastFrameTime = performance.now();
|
|
84
|
+
let frameCount = 0;
|
|
85
|
+
|
|
86
|
+
function rafLoop() {
|
|
87
|
+
const currentTime = performance.now();
|
|
88
|
+
const deltaTime = currentTime - lastFrameTime;
|
|
89
|
+
|
|
90
|
+
// If frame time is consistent (likely animation), count as change
|
|
91
|
+
// CSS animations typically run at 60fps (16.67ms per frame)
|
|
92
|
+
if (deltaTime > 0 && deltaTime < 20) { // 0-20ms = likely animation frame
|
|
93
|
+
frameCount++;
|
|
94
|
+
if (frameCount % 10 === 0) { // Every 10 frames = ~166ms
|
|
95
|
+
window.__renderRAFCount = (window.__renderRAFCount || 0) + 1;
|
|
96
|
+
window.__renderRAFTime = Date.now();
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
frameCount = 0; // Reset if not consistent
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lastFrameTime = currentTime;
|
|
103
|
+
window.__renderRAFId = requestAnimationFrame(rafLoop);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
window.__renderRAFId = requestAnimationFrame(rafLoop);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Initialize counters
|
|
110
|
+
window.__renderChangeCount = 0;
|
|
111
|
+
window.__renderChangeTime = 0;
|
|
112
|
+
window.__renderChangeMutations = 0;
|
|
113
|
+
window.__renderResizeCount = 0;
|
|
114
|
+
window.__renderResizeTime = 0;
|
|
115
|
+
window.__renderResizeEntries = 0;
|
|
116
|
+
window.__renderRAFCount = 0;
|
|
117
|
+
window.__renderRAFTime = 0;
|
|
118
|
+
}, { subtree, childList, attributes, characterData, attributeFilter, detectLayoutChanges });
|
|
119
|
+
|
|
120
|
+
// Enable CSS animation detection if requested
|
|
121
|
+
if (detectCSSAnimations) {
|
|
122
|
+
await page.evaluate(() => {
|
|
123
|
+
window.__detectCSSAnimations = true;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Poll for changes (Playwright doesn't support direct MutationObserver callbacks)
|
|
128
|
+
const pollId = setInterval(async () => {
|
|
129
|
+
try {
|
|
130
|
+
const changeInfo = await page.evaluate(() => {
|
|
131
|
+
const info = {
|
|
132
|
+
mutations: {
|
|
133
|
+
count: window.__renderChangeCount || 0,
|
|
134
|
+
time: window.__renderChangeTime || 0,
|
|
135
|
+
mutations: window.__renderChangeMutations || 0
|
|
136
|
+
},
|
|
137
|
+
resize: {
|
|
138
|
+
count: window.__renderResizeCount || 0,
|
|
139
|
+
time: window.__renderResizeTime || 0,
|
|
140
|
+
entries: window.__renderResizeEntries || 0
|
|
141
|
+
},
|
|
142
|
+
raf: {
|
|
143
|
+
count: window.__renderRAFCount || 0,
|
|
144
|
+
time: window.__renderRAFTime || 0
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Reset counters
|
|
149
|
+
window.__renderChangeCount = 0;
|
|
150
|
+
window.__renderChangeTime = 0;
|
|
151
|
+
window.__renderChangeMutations = 0;
|
|
152
|
+
window.__renderResizeCount = 0;
|
|
153
|
+
window.__renderResizeTime = 0;
|
|
154
|
+
window.__renderResizeEntries = 0;
|
|
155
|
+
window.__renderRAFCount = 0;
|
|
156
|
+
window.__renderRAFTime = 0;
|
|
157
|
+
|
|
158
|
+
return info;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const totalChanges = changeInfo.mutations.count + changeInfo.resize.count + changeInfo.raf.count;
|
|
162
|
+
|
|
163
|
+
if (totalChanges > 0) {
|
|
164
|
+
changeCount += totalChanges;
|
|
165
|
+
changeHistory.push({
|
|
166
|
+
timestamp: changeInfo.mutations.time || changeInfo.resize.time || changeInfo.raf.time || Date.now(),
|
|
167
|
+
mutations: changeInfo.mutations.mutations,
|
|
168
|
+
resizeEntries: changeInfo.resize.entries,
|
|
169
|
+
rafFrames: changeInfo.raf.count,
|
|
170
|
+
count: totalChanges,
|
|
171
|
+
types: {
|
|
172
|
+
dom: changeInfo.mutations.count > 0,
|
|
173
|
+
layout: changeInfo.resize.count > 0,
|
|
174
|
+
visual: changeInfo.raf.count > 0
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Keep history limited
|
|
179
|
+
if (changeHistory.length > maxHistory) {
|
|
180
|
+
changeHistory.shift();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Call onChange callback
|
|
184
|
+
if (onChange) {
|
|
185
|
+
await onChange({
|
|
186
|
+
count: totalChanges,
|
|
187
|
+
mutations: changeInfo.mutations.mutations,
|
|
188
|
+
resizeEntries: changeInfo.resize.entries,
|
|
189
|
+
rafFrames: changeInfo.raf.count,
|
|
190
|
+
timestamp: changeInfo.mutations.time || changeInfo.resize.time || changeInfo.raf.time || Date.now(),
|
|
191
|
+
totalChanges: changeCount,
|
|
192
|
+
types: {
|
|
193
|
+
dom: changeInfo.mutations.count > 0,
|
|
194
|
+
layout: changeInfo.resize.count > 0,
|
|
195
|
+
visual: changeInfo.raf.count > 0
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
warn(`[Render Change] Poll error: ${error.message}`);
|
|
202
|
+
}
|
|
203
|
+
}, pollInterval);
|
|
204
|
+
|
|
205
|
+
// Return cleanup function
|
|
206
|
+
return async () => {
|
|
207
|
+
clearInterval(pollId);
|
|
208
|
+
try {
|
|
209
|
+
await page.evaluate(() => {
|
|
210
|
+
if (window.__renderChangeObserver) {
|
|
211
|
+
window.__renderChangeObserver.disconnect();
|
|
212
|
+
delete window.__renderChangeObserver;
|
|
213
|
+
}
|
|
214
|
+
if (window.__renderResizeObserver) {
|
|
215
|
+
window.__renderResizeObserver.disconnect();
|
|
216
|
+
delete window.__renderResizeObserver;
|
|
217
|
+
}
|
|
218
|
+
if (window.__renderRAFId) {
|
|
219
|
+
cancelAnimationFrame(window.__renderRAFId);
|
|
220
|
+
delete window.__renderRAFId;
|
|
221
|
+
}
|
|
222
|
+
delete window.__renderChangeCount;
|
|
223
|
+
delete window.__renderChangeTime;
|
|
224
|
+
delete window.__renderChangeMutations;
|
|
225
|
+
delete window.__renderResizeCount;
|
|
226
|
+
delete window.__renderResizeTime;
|
|
227
|
+
delete window.__renderResizeEntries;
|
|
228
|
+
delete window.__renderRAFCount;
|
|
229
|
+
delete window.__renderRAFTime;
|
|
230
|
+
delete window.__detectCSSAnimations;
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
warn(`[Render Change] Cleanup error: ${error.message}`);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
} catch (error) {
|
|
237
|
+
warn(`[Render Change] Setup error: ${error.message}`);
|
|
238
|
+
return async () => {}; // Return no-op cleanup
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Calculate optimal frame rate based on change rate
|
|
244
|
+
*
|
|
245
|
+
* @param {Array} changeHistory - History of changes
|
|
246
|
+
* @param {Object} options - Calculation options
|
|
247
|
+
* @returns {number} Optimal FPS
|
|
248
|
+
*/
|
|
249
|
+
export function calculateOptimalFPS(changeHistory, options = {}) {
|
|
250
|
+
const {
|
|
251
|
+
minFPS = 1,
|
|
252
|
+
maxFPS = 60,
|
|
253
|
+
targetChangeInterval = 100 // Target: capture every 100ms of changes
|
|
254
|
+
} = options;
|
|
255
|
+
|
|
256
|
+
if (changeHistory.length < 2) {
|
|
257
|
+
return minFPS;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Calculate average time between changes
|
|
261
|
+
const intervals = [];
|
|
262
|
+
for (let i = 1; i < changeHistory.length; i++) {
|
|
263
|
+
const interval = changeHistory[i].timestamp - changeHistory[i - 1].timestamp;
|
|
264
|
+
if (interval > 0) {
|
|
265
|
+
intervals.push(interval);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (intervals.length === 0) {
|
|
270
|
+
return minFPS;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
274
|
+
|
|
275
|
+
// Calculate FPS based on average interval
|
|
276
|
+
// If changes happen every 16ms (60fps), we need 60fps capture
|
|
277
|
+
// If changes happen every 1000ms (1fps), we need 1fps capture
|
|
278
|
+
const optimalFPS = Math.max(minFPS, Math.min(maxFPS, 1000 / avgInterval));
|
|
279
|
+
|
|
280
|
+
return Math.round(optimalFPS);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Compare two screenshots to detect visual changes
|
|
285
|
+
*
|
|
286
|
+
* Enhanced with pixel-level comparison using lightweight method.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} screenshot1Path - Path to first screenshot
|
|
289
|
+
* @param {string} screenshot2Path - Path to second screenshot
|
|
290
|
+
* @param {Object} options - Comparison options
|
|
291
|
+
* @returns {Promise<{ changed: boolean, diff: number, regions: Array }>} Comparison result
|
|
292
|
+
*/
|
|
293
|
+
export async function detectVisualChanges(screenshot1Path, screenshot2Path, options = {}) {
|
|
294
|
+
const {
|
|
295
|
+
threshold = 0.01, // 1% difference threshold
|
|
296
|
+
useImageDiff = false, // Use image diff library if available
|
|
297
|
+
usePixelComparison = true // Use lightweight pixel comparison
|
|
298
|
+
} = options;
|
|
299
|
+
|
|
300
|
+
// Basic implementation: file size and timestamp comparison
|
|
301
|
+
try {
|
|
302
|
+
if (!existsSync(screenshot1Path) || !existsSync(screenshot2Path)) {
|
|
303
|
+
return {
|
|
304
|
+
changed: true, // Assume changed if files don't exist
|
|
305
|
+
diff: 1.0,
|
|
306
|
+
timeDiff: 0,
|
|
307
|
+
regions: []
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const stat1 = statSync(screenshot1Path);
|
|
312
|
+
const stat2 = statSync(screenshot2Path);
|
|
313
|
+
|
|
314
|
+
// If file sizes are different, content likely changed
|
|
315
|
+
const sizeDiff = Math.abs(stat1.size - stat2.size) / Math.max(stat1.size, stat2.size);
|
|
316
|
+
const timeDiff = Math.abs(stat1.mtimeMs - stat2.mtimeMs);
|
|
317
|
+
|
|
318
|
+
// Basic check: if size diff > threshold or time diff > 100ms, likely changed
|
|
319
|
+
let changed = sizeDiff > threshold || timeDiff > 100;
|
|
320
|
+
|
|
321
|
+
// Enhanced: Try lightweight pixel comparison if enabled
|
|
322
|
+
if (usePixelComparison && changed) {
|
|
323
|
+
try {
|
|
324
|
+
// Read first few bytes of PNG files to compare
|
|
325
|
+
// PNG files have headers, so identical files will have same header
|
|
326
|
+
const fs = await import('fs');
|
|
327
|
+
const buffer1 = fs.readFileSync(screenshot1Path, { start: 0, end: 1000 });
|
|
328
|
+
const buffer2 = fs.readFileSync(screenshot2Path, { start: 0, end: 1000 });
|
|
329
|
+
|
|
330
|
+
// Compare first 1000 bytes (header + some pixel data)
|
|
331
|
+
let byteMatches = 0;
|
|
332
|
+
const compareLength = Math.min(buffer1.length, buffer2.length, 1000);
|
|
333
|
+
for (let i = 0; i < compareLength; i++) {
|
|
334
|
+
if (buffer1[i] === buffer2[i]) {
|
|
335
|
+
byteMatches++;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const byteSimilarity = byteMatches / compareLength;
|
|
340
|
+
// If >95% of bytes match in header region, likely same image
|
|
341
|
+
if (byteSimilarity > 0.95 && sizeDiff < 0.05) {
|
|
342
|
+
changed = false; // Probably same image
|
|
343
|
+
}
|
|
344
|
+
} catch (error) {
|
|
345
|
+
// Fall back to size/timestamp comparison
|
|
346
|
+
warn(`[Visual Change] Pixel comparison failed: ${error.message}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
changed,
|
|
352
|
+
diff: sizeDiff,
|
|
353
|
+
timeDiff,
|
|
354
|
+
regions: [] // Would need image processing for region detection
|
|
355
|
+
};
|
|
356
|
+
} catch (error) {
|
|
357
|
+
warn(`[Visual Change] Comparison error: ${error.message}`);
|
|
358
|
+
return {
|
|
359
|
+
changed: true, // Assume changed if comparison fails
|
|
360
|
+
diff: 1.0,
|
|
361
|
+
timeDiff: 0,
|
|
362
|
+
regions: []
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Capture screenshots on render changes
|
|
369
|
+
*
|
|
370
|
+
* @param {any} page - Playwright page object
|
|
371
|
+
* @param {Object} options - Capture options
|
|
372
|
+
* @param {Function} [options.onChange] - Callback when change detected
|
|
373
|
+
* @param {number} [options.maxScreenshots] - Maximum screenshots to capture
|
|
374
|
+
* @param {number} [options.duration] - Maximum duration in ms
|
|
375
|
+
* @param {boolean} [options.visualDiff] - Use visual diff detection
|
|
376
|
+
* @param {boolean} [options.detectCSSAnimations] - Detect CSS animations
|
|
377
|
+
* @returns {Promise<Array>} Array of screenshot paths
|
|
378
|
+
*/
|
|
379
|
+
export async function captureOnRenderChanges(page, options = {}) {
|
|
380
|
+
const {
|
|
381
|
+
onChange = null,
|
|
382
|
+
maxScreenshots = 100,
|
|
383
|
+
duration = 10000, // 10 seconds default
|
|
384
|
+
visualDiff = false,
|
|
385
|
+
outputDir = 'test-results',
|
|
386
|
+
detectCSSAnimations = true, // Enable CSS animation detection
|
|
387
|
+
detectLayoutChanges = true
|
|
388
|
+
} = options;
|
|
389
|
+
|
|
390
|
+
const screenshots = [];
|
|
391
|
+
const startTime = Date.now();
|
|
392
|
+
let lastScreenshotPath = null;
|
|
393
|
+
|
|
394
|
+
// Set up render change detection (enhanced with CSS animation detection)
|
|
395
|
+
const cleanup = await detectRenderChanges(page, async (changeInfo) => {
|
|
396
|
+
// Check limits
|
|
397
|
+
if (screenshots.length >= maxScreenshots) {
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (Date.now() - startTime > duration) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Capture screenshot
|
|
405
|
+
const timestamp = Date.now();
|
|
406
|
+
const screenshotPath = `${outputDir}/render-change-${timestamp}.png`;
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
await page.screenshot({ path: screenshotPath, type: 'png' });
|
|
410
|
+
|
|
411
|
+
// Visual diff check (if enabled)
|
|
412
|
+
if (visualDiff && lastScreenshotPath) {
|
|
413
|
+
const diff = await detectVisualChanges(lastScreenshotPath, screenshotPath);
|
|
414
|
+
if (!diff.changed) {
|
|
415
|
+
// No visual change, skip this screenshot
|
|
416
|
+
const fs = await import('fs');
|
|
417
|
+
if (existsSync(screenshotPath)) {
|
|
418
|
+
fs.unlinkSync(screenshotPath);
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
screenshots.push({
|
|
425
|
+
path: screenshotPath,
|
|
426
|
+
timestamp,
|
|
427
|
+
changeInfo
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
lastScreenshotPath = screenshotPath;
|
|
431
|
+
|
|
432
|
+
// Call onChange callback
|
|
433
|
+
if (onChange) {
|
|
434
|
+
await onChange(screenshotPath, changeInfo);
|
|
435
|
+
}
|
|
436
|
+
} catch (error) {
|
|
437
|
+
warn(`[Render Change] Screenshot capture error: ${error.message}`);
|
|
438
|
+
}
|
|
439
|
+
}, {
|
|
440
|
+
detectCSSAnimations,
|
|
441
|
+
detectLayoutChanges
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Wait for duration or max screenshots
|
|
445
|
+
const checkInterval = setInterval(() => {
|
|
446
|
+
if (screenshots.length >= maxScreenshots || Date.now() - startTime > duration) {
|
|
447
|
+
clearInterval(checkInterval);
|
|
448
|
+
}
|
|
449
|
+
}, 100);
|
|
450
|
+
|
|
451
|
+
// Wait for completion
|
|
452
|
+
await new Promise((resolve) => {
|
|
453
|
+
const finalCheck = setInterval(() => {
|
|
454
|
+
if (screenshots.length >= maxScreenshots || Date.now() - startTime > duration) {
|
|
455
|
+
clearInterval(finalCheck);
|
|
456
|
+
resolve();
|
|
457
|
+
}
|
|
458
|
+
}, 100);
|
|
459
|
+
|
|
460
|
+
// Timeout after duration
|
|
461
|
+
setTimeout(() => {
|
|
462
|
+
clearInterval(finalCheck);
|
|
463
|
+
resolve();
|
|
464
|
+
}, duration);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// Cleanup
|
|
468
|
+
await cleanup();
|
|
469
|
+
|
|
470
|
+
return screenshots;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Adaptive temporal screenshot capture
|
|
475
|
+
*
|
|
476
|
+
* Adjusts capture rate based on detected change rate.
|
|
477
|
+
* Enhanced with CSS animation detection and optimized screenshot quality.
|
|
478
|
+
*
|
|
479
|
+
* @param {any} page - Playwright page object
|
|
480
|
+
* @param {Object} options - Capture options
|
|
481
|
+
* @param {number} [options.minFPS] - Minimum FPS (default: 1)
|
|
482
|
+
* @param {number} [options.maxFPS] - Maximum FPS (default: 60)
|
|
483
|
+
* @param {number} [options.duration] - Duration in ms
|
|
484
|
+
* @param {number} [options.adaptationInterval] - How often to recalculate FPS (ms)
|
|
485
|
+
* @param {boolean} [options.optimizeForSpeed] - Use lower quality screenshots for speed
|
|
486
|
+
* @param {boolean} [options.detectCSSAnimations] - Detect CSS animations
|
|
487
|
+
* @returns {Promise<Array>} Array of screenshot paths with metadata
|
|
488
|
+
*/
|
|
489
|
+
export async function captureAdaptiveTemporalScreenshots(page, options = {}) {
|
|
490
|
+
const {
|
|
491
|
+
minFPS = 1,
|
|
492
|
+
maxFPS = 60,
|
|
493
|
+
duration = 10000,
|
|
494
|
+
adaptationInterval = 2000, // Recalculate every 2 seconds
|
|
495
|
+
outputDir = 'test-results',
|
|
496
|
+
optimizeForSpeed = false, // Optimize screenshot quality for speed
|
|
497
|
+
detectCSSAnimations = true // Enable CSS animation detection
|
|
498
|
+
} = options;
|
|
499
|
+
|
|
500
|
+
const screenshots = [];
|
|
501
|
+
const changeHistory = [];
|
|
502
|
+
const startTime = Date.now();
|
|
503
|
+
let currentFPS = minFPS;
|
|
504
|
+
let lastCaptureTime = 0;
|
|
505
|
+
|
|
506
|
+
// Set up render change detection (enhanced with CSS animation detection)
|
|
507
|
+
const cleanup = await detectRenderChanges(page, async (changeInfo) => {
|
|
508
|
+
changeHistory.push({
|
|
509
|
+
timestamp: changeInfo.timestamp,
|
|
510
|
+
mutations: changeInfo.mutations,
|
|
511
|
+
types: changeInfo.types
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Keep history limited
|
|
515
|
+
if (changeHistory.length > 100) {
|
|
516
|
+
changeHistory.shift();
|
|
517
|
+
}
|
|
518
|
+
}, {
|
|
519
|
+
detectCSSAnimations,
|
|
520
|
+
detectLayoutChanges: true
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Adaptive capture loop
|
|
524
|
+
const captureLoop = async () => {
|
|
525
|
+
while (Date.now() - startTime < duration) {
|
|
526
|
+
// Recalculate optimal FPS periodically
|
|
527
|
+
if (changeHistory.length >= 2) {
|
|
528
|
+
const recentHistory = changeHistory.slice(-20); // Last 20 changes
|
|
529
|
+
currentFPS = calculateOptimalFPS(recentHistory, { minFPS, maxFPS });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Calculate next capture time based on current FPS
|
|
533
|
+
const interval = 1000 / currentFPS;
|
|
534
|
+
const nextCaptureTime = lastCaptureTime + interval;
|
|
535
|
+
|
|
536
|
+
// Wait until next capture time
|
|
537
|
+
const waitTime = Math.max(0, nextCaptureTime - Date.now());
|
|
538
|
+
if (waitTime > 0) {
|
|
539
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Capture screenshot (optimize quality for speed if requested)
|
|
543
|
+
const timestamp = Date.now();
|
|
544
|
+
const screenshotPath = `${outputDir}/adaptive-${timestamp}.png`;
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
// Optimize screenshot quality for high FPS
|
|
548
|
+
const screenshotOptions = {
|
|
549
|
+
path: screenshotPath,
|
|
550
|
+
type: 'png'
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
if (optimizeForSpeed && currentFPS > 30) {
|
|
554
|
+
// For high FPS, use lower quality to reduce overhead
|
|
555
|
+
screenshotOptions.quality = 70; // Lower quality (if supported)
|
|
556
|
+
// Could also reduce size, but Playwright doesn't support that directly
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
await page.screenshot(screenshotOptions);
|
|
560
|
+
screenshots.push({
|
|
561
|
+
path: screenshotPath,
|
|
562
|
+
timestamp,
|
|
563
|
+
fps: currentFPS,
|
|
564
|
+
changeCount: changeHistory.length
|
|
565
|
+
});
|
|
566
|
+
lastCaptureTime = timestamp;
|
|
567
|
+
} catch (error) {
|
|
568
|
+
warn(`[Adaptive Capture] Screenshot error: ${error.message}`);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Small delay to prevent tight loop
|
|
572
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// Start capture loop
|
|
577
|
+
await captureLoop();
|
|
578
|
+
|
|
579
|
+
// Cleanup
|
|
580
|
+
await cleanup();
|
|
581
|
+
|
|
582
|
+
return screenshots;
|
|
583
|
+
}
|