chrometools-mcp 1.9.1 → 2.3.2
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 +305 -0
- package/README.md +279 -53
- package/browser/browser-manager.js +206 -0
- package/browser/page-manager.js +298 -0
- package/index.js +625 -1875
- package/package.json +1 -1
- package/recorder/page-object-generator.js +720 -0
- package/recorder/recorder-script.js +63 -9
- package/recorder/scenario-executor.js +47 -27
- package/recorder/scenario-storage.js +251 -29
- package/server/tool-definitions.js +655 -0
- package/server/tool-schemas.js +295 -0
- package/utils/code-generators/code-generator-base.js +61 -0
- package/utils/code-generators/file-appender.js +202 -0
- package/utils/code-generators/playwright-python.js +84 -0
- package/utils/code-generators/playwright-typescript.js +95 -0
- package/utils/code-generators/selenium-java.js +123 -0
- package/utils/code-generators/selenium-python.js +82 -0
- package/utils/css-utils.js +151 -0
- package/utils/image-processing.js +236 -0
- package/utils/platform-utils.js +62 -0
- package/utils/url-to-project.js +141 -0
- package/utils/project-detector.js +0 -87
|
@@ -227,4 +227,99 @@ export class PlaywrightTypeScriptGenerator extends CodeGeneratorBase {
|
|
|
227
227
|
return [this.indent(`await expect(page).toHaveURL('${this.escapeString(expectedUrl)}');`)];
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Append test to existing TypeScript file
|
|
233
|
+
*/
|
|
234
|
+
appendTest(existingContent, newTestCode, options = {}) {
|
|
235
|
+
const lines = existingContent.split('\n');
|
|
236
|
+
const insertPosition = options.insertPosition || 'end';
|
|
237
|
+
const referenceTestName = options.referenceTestName;
|
|
238
|
+
|
|
239
|
+
let insertIndex;
|
|
240
|
+
|
|
241
|
+
if (insertPosition === 'end') {
|
|
242
|
+
// Insert at end of file (or before last closing brace if exists)
|
|
243
|
+
insertIndex = this.findLastTestEnd(lines);
|
|
244
|
+
} else if (insertPosition === 'before' || insertPosition === 'after') {
|
|
245
|
+
if (!referenceTestName) {
|
|
246
|
+
throw new Error(`referenceTestName is required for insertPosition '${insertPosition}'`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
insertIndex = this.findTestByName(lines, referenceTestName);
|
|
250
|
+
if (insertIndex === -1) {
|
|
251
|
+
throw new Error(`Reference test '${referenceTestName}' not found in file`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (insertPosition === 'after') {
|
|
255
|
+
insertIndex = this.findTestEnd(lines, insertIndex);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Insert new test with proper spacing
|
|
260
|
+
lines.splice(insertIndex, 0, '', newTestCode, '');
|
|
261
|
+
|
|
262
|
+
return lines.join('\n');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Find test function by name
|
|
267
|
+
*/
|
|
268
|
+
findTestByName(lines, testName) {
|
|
269
|
+
const testRegex = new RegExp(`test\\(['"\`]${testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`);
|
|
270
|
+
return lines.findIndex(line => testRegex.test(line));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Find end of test function
|
|
275
|
+
*/
|
|
276
|
+
findTestEnd(lines, startIndex) {
|
|
277
|
+
let braceCount = 0;
|
|
278
|
+
let started = false;
|
|
279
|
+
|
|
280
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
281
|
+
const line = lines[i];
|
|
282
|
+
|
|
283
|
+
// Count braces
|
|
284
|
+
for (const char of line) {
|
|
285
|
+
if (char === '{') {
|
|
286
|
+
braceCount++;
|
|
287
|
+
started = true;
|
|
288
|
+
} else if (char === '}') {
|
|
289
|
+
braceCount--;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// When braces balance out after starting, we found the end
|
|
294
|
+
if (started && braceCount === 0) {
|
|
295
|
+
return i + 1;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return lines.length;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Find last test end (for 'end' insertion)
|
|
304
|
+
*/
|
|
305
|
+
findLastTestEnd(lines) {
|
|
306
|
+
// Find last test() call
|
|
307
|
+
let lastTestIndex = -1;
|
|
308
|
+
const testRegex = /test\(['"`]/;
|
|
309
|
+
|
|
310
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
311
|
+
if (testRegex.test(lines[i])) {
|
|
312
|
+
lastTestIndex = i;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (lastTestIndex === -1) {
|
|
318
|
+
// No tests found, insert at end
|
|
319
|
+
return lines.length;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Find end of last test
|
|
323
|
+
return this.findTestEnd(lines, lastTestIndex);
|
|
324
|
+
}
|
|
230
325
|
}
|
|
@@ -306,4 +306,127 @@ export class SeleniumJavaGenerator extends CodeGeneratorBase {
|
|
|
306
306
|
|
|
307
307
|
return lines;
|
|
308
308
|
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Append test to existing Java file
|
|
312
|
+
*/
|
|
313
|
+
appendTest(existingContent, newTestCode, options = {}) {
|
|
314
|
+
const lines = existingContent.split('\n');
|
|
315
|
+
const insertPosition = options.insertPosition || 'end';
|
|
316
|
+
const referenceTestName = options.referenceTestName;
|
|
317
|
+
|
|
318
|
+
let insertIndex;
|
|
319
|
+
|
|
320
|
+
if (insertPosition === 'end') {
|
|
321
|
+
// Insert before closing class brace
|
|
322
|
+
insertIndex = this.findLastJavaMethodEnd(lines);
|
|
323
|
+
} else if (insertPosition === 'before' || insertPosition === 'after') {
|
|
324
|
+
if (!referenceTestName) {
|
|
325
|
+
throw new Error(`referenceTestName is required for insertPosition '${insertPosition}'`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const javaTestName = this.javaTestName(referenceTestName);
|
|
329
|
+
insertIndex = this.findJavaTestByName(lines, javaTestName);
|
|
330
|
+
|
|
331
|
+
if (insertIndex === -1) {
|
|
332
|
+
throw new Error(`Reference test '${referenceTestName}' not found in file`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (insertPosition === 'after') {
|
|
336
|
+
insertIndex = this.findJavaMethodEnd(lines, insertIndex);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Insert new test with proper spacing
|
|
341
|
+
lines.splice(insertIndex, 0, '', this.indentBlock(newTestCode, 1), '');
|
|
342
|
+
|
|
343
|
+
return lines.join('\n');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Convert test name to Java camelCase convention
|
|
348
|
+
*/
|
|
349
|
+
javaTestName(name) {
|
|
350
|
+
return name
|
|
351
|
+
.replace(/[_-](.)/g, (_, char) => char.toUpperCase())
|
|
352
|
+
.replace(/[_-]/g, '')
|
|
353
|
+
.replace(/ (.)/g, (_, char) => char.toUpperCase())
|
|
354
|
+
.replace(/ /g, '')
|
|
355
|
+
.replace(/^./, char => char.toLowerCase());
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Find Java test method by name
|
|
360
|
+
*/
|
|
361
|
+
findJavaTestByName(lines, testName) {
|
|
362
|
+
const testRegex = new RegExp(`public\\s+void\\s+${testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
|
|
363
|
+
return lines.findIndex(line => testRegex.test(line.trim()));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Find end of Java method
|
|
368
|
+
*/
|
|
369
|
+
findJavaMethodEnd(lines, startIndex) {
|
|
370
|
+
let braceCount = 0;
|
|
371
|
+
let started = false;
|
|
372
|
+
|
|
373
|
+
for (let i = startIndex; i < lines.length; i++) {
|
|
374
|
+
const line = lines[i];
|
|
375
|
+
|
|
376
|
+
for (const char of line) {
|
|
377
|
+
if (char === '{') {
|
|
378
|
+
braceCount++;
|
|
379
|
+
started = true;
|
|
380
|
+
} else if (char === '}') {
|
|
381
|
+
braceCount--;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (started && braceCount === 0) {
|
|
386
|
+
return i + 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return lines.length;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Find last method end (for 'end' insertion - before closing class brace)
|
|
395
|
+
*/
|
|
396
|
+
findLastJavaMethodEnd(lines) {
|
|
397
|
+
// Find last @Test annotation or public method
|
|
398
|
+
let lastMethodIndex = -1;
|
|
399
|
+
|
|
400
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
401
|
+
const trimmed = lines[i].trim();
|
|
402
|
+
if (trimmed.startsWith('@Test') || trimmed.startsWith('public void test')) {
|
|
403
|
+
lastMethodIndex = i;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (lastMethodIndex === -1) {
|
|
409
|
+
// No methods found, insert before last closing brace
|
|
410
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
411
|
+
if (lines[i].trim() === '}') {
|
|
412
|
+
return i;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return lines.length;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Find end of last method
|
|
419
|
+
return this.findJavaMethodEnd(lines, lastMethodIndex);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Indent entire code block by specified level
|
|
424
|
+
*/
|
|
425
|
+
indentBlock(code, level = 1) {
|
|
426
|
+
const lines = code.split('\n');
|
|
427
|
+
return lines.map(line => {
|
|
428
|
+
if (line.trim() === '') return line;
|
|
429
|
+
return ' '.repeat(this.options.indentSize * level) + line;
|
|
430
|
+
}).join('\n');
|
|
431
|
+
}
|
|
309
432
|
}
|
|
@@ -325,4 +325,86 @@ export class SeleniumPythonGenerator extends CodeGeneratorBase {
|
|
|
325
325
|
|
|
326
326
|
return lines;
|
|
327
327
|
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Append test to existing Python file
|
|
331
|
+
* Reuses Python parsing logic similar to Playwright Python
|
|
332
|
+
*/
|
|
333
|
+
appendTest(existingContent, newTestCode, options = {}) {
|
|
334
|
+
const lines = existingContent.split('\n');
|
|
335
|
+
const insertPosition = options.insertPosition || 'end';
|
|
336
|
+
const referenceTestName = options.referenceTestName;
|
|
337
|
+
|
|
338
|
+
let insertIndex;
|
|
339
|
+
|
|
340
|
+
if (insertPosition === 'end') {
|
|
341
|
+
// Insert at end of file
|
|
342
|
+
insertIndex = lines.length;
|
|
343
|
+
} else if (insertPosition === 'before' || insertPosition === 'after') {
|
|
344
|
+
if (!referenceTestName) {
|
|
345
|
+
throw new Error(`referenceTestName is required for insertPosition '${insertPosition}'`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const pythonTestName = this.pythonTestName(referenceTestName);
|
|
349
|
+
insertIndex = this.findPythonTestByName(lines, pythonTestName);
|
|
350
|
+
|
|
351
|
+
if (insertIndex === -1) {
|
|
352
|
+
throw new Error(`Reference test '${referenceTestName}' not found in file`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (insertPosition === 'after') {
|
|
356
|
+
insertIndex = this.findPythonTestEnd(lines, insertIndex);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Insert new test with proper spacing (2 blank lines before test - PEP 8)
|
|
361
|
+
lines.splice(insertIndex, 0, '', '', newTestCode);
|
|
362
|
+
|
|
363
|
+
return lines.join('\n');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Convert test name to Python convention
|
|
368
|
+
*/
|
|
369
|
+
pythonTestName(name) {
|
|
370
|
+
return name
|
|
371
|
+
.replace(/([A-Z])/g, '_$1')
|
|
372
|
+
.replace(/-/g, '_')
|
|
373
|
+
.replace(/ /g, '_')
|
|
374
|
+
.toLowerCase()
|
|
375
|
+
.replace(/^_/, '')
|
|
376
|
+
.replace(/__+/g, '_');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Find Python test function by name
|
|
381
|
+
*/
|
|
382
|
+
findPythonTestByName(lines, testName) {
|
|
383
|
+
const testRegex = new RegExp(`^def test_${testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
|
|
384
|
+
return lines.findIndex(line => testRegex.test(line.trim()));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Find end of Python function (next def/class or end of file)
|
|
389
|
+
*/
|
|
390
|
+
findPythonTestEnd(lines, startIndex) {
|
|
391
|
+
const startLine = lines[startIndex];
|
|
392
|
+
const startIndent = startLine.match(/^\s*/)[0].length;
|
|
393
|
+
|
|
394
|
+
for (let i = startIndex + 1; i < lines.length; i++) {
|
|
395
|
+
const trimmed = lines[i].trim();
|
|
396
|
+
|
|
397
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const currentIndent = lines[i].match(/^\s*/)[0].length;
|
|
402
|
+
|
|
403
|
+
if (currentIndent <= startIndent && (trimmed.startsWith('def ') || trimmed.startsWith('class '))) {
|
|
404
|
+
return i;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return lines.length;
|
|
409
|
+
}
|
|
328
410
|
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils/css-utils.js
|
|
3
|
+
*
|
|
4
|
+
* CSS utilities for filtering and categorizing CSS properties
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CSS property categorization
|
|
9
|
+
*/
|
|
10
|
+
export const CSS_CATEGORIES = {
|
|
11
|
+
layout: [
|
|
12
|
+
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
|
|
13
|
+
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
14
|
+
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
15
|
+
'position', 'top', 'right', 'bottom', 'left', 'z-index',
|
|
16
|
+
'display', 'float', 'clear', 'overflow', 'overflow-x', 'overflow-y',
|
|
17
|
+
'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
|
|
18
|
+
'justify-content', 'align-items', 'align-content', 'align-self', 'order',
|
|
19
|
+
'grid', 'grid-template', 'grid-template-columns', 'grid-template-rows', 'grid-gap',
|
|
20
|
+
'gap', 'row-gap', 'column-gap',
|
|
21
|
+
'box-sizing', 'visibility', 'clip', 'clip-path'
|
|
22
|
+
],
|
|
23
|
+
typography: [
|
|
24
|
+
'font', 'font-family', 'font-size', 'font-weight', 'font-style', 'font-variant',
|
|
25
|
+
'line-height', 'letter-spacing', 'word-spacing', 'text-align', 'text-decoration',
|
|
26
|
+
'text-transform', 'text-indent', 'text-overflow', 'white-space', 'word-break',
|
|
27
|
+
'word-wrap', 'overflow-wrap', 'hyphens', 'direction', 'unicode-bidi',
|
|
28
|
+
'writing-mode', 'vertical-align'
|
|
29
|
+
],
|
|
30
|
+
colors: [
|
|
31
|
+
'color', 'background', 'background-color', 'background-image', 'background-position',
|
|
32
|
+
'background-size', 'background-repeat', 'background-attachment', 'background-clip',
|
|
33
|
+
'background-origin', 'background-blend-mode',
|
|
34
|
+
'border-color', 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
|
35
|
+
'outline-color', 'text-decoration-color', 'caret-color', 'column-rule-color'
|
|
36
|
+
],
|
|
37
|
+
visual: [
|
|
38
|
+
'opacity', 'transform', 'transform-origin', 'transform-style', 'perspective',
|
|
39
|
+
'perspective-origin', 'backface-visibility',
|
|
40
|
+
'transition', 'transition-property', 'transition-duration', 'transition-timing-function', 'transition-delay',
|
|
41
|
+
'animation', 'animation-name', 'animation-duration', 'animation-timing-function', 'animation-delay',
|
|
42
|
+
'animation-iteration-count', 'animation-direction', 'animation-fill-mode', 'animation-play-state',
|
|
43
|
+
'filter', 'backdrop-filter', 'mix-blend-mode', 'isolation',
|
|
44
|
+
'box-shadow', 'text-shadow',
|
|
45
|
+
'border', 'border-width', 'border-style', 'border-radius',
|
|
46
|
+
'border-top', 'border-right', 'border-bottom', 'border-left',
|
|
47
|
+
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
|
48
|
+
'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
|
|
49
|
+
'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius',
|
|
50
|
+
'outline', 'outline-width', 'outline-style', 'outline-offset',
|
|
51
|
+
'cursor', 'pointer-events', 'user-select'
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Common default CSS values to filter out
|
|
57
|
+
*/
|
|
58
|
+
export const CSS_DEFAULTS = {
|
|
59
|
+
'display': 'inline',
|
|
60
|
+
'position': 'static',
|
|
61
|
+
'float': 'none',
|
|
62
|
+
'clear': 'none',
|
|
63
|
+
'visibility': 'visible',
|
|
64
|
+
'overflow': 'visible',
|
|
65
|
+
'overflow-x': 'visible',
|
|
66
|
+
'overflow-y': 'visible',
|
|
67
|
+
'z-index': 'auto',
|
|
68
|
+
'opacity': '1',
|
|
69
|
+
'transform': 'none',
|
|
70
|
+
'filter': 'none',
|
|
71
|
+
'backdrop-filter': 'none',
|
|
72
|
+
'box-shadow': 'none',
|
|
73
|
+
'text-shadow': 'none',
|
|
74
|
+
'border-style': 'none',
|
|
75
|
+
'border-width': '0px',
|
|
76
|
+
'outline-style': 'none',
|
|
77
|
+
'outline-width': '0px',
|
|
78
|
+
'margin': '0px',
|
|
79
|
+
'margin-top': '0px',
|
|
80
|
+
'margin-right': '0px',
|
|
81
|
+
'margin-bottom': '0px',
|
|
82
|
+
'margin-left': '0px',
|
|
83
|
+
'padding': '0px',
|
|
84
|
+
'padding-top': '0px',
|
|
85
|
+
'padding-right': '0px',
|
|
86
|
+
'padding-bottom': '0px',
|
|
87
|
+
'padding-left': '0px',
|
|
88
|
+
'background-image': 'none',
|
|
89
|
+
'transition': 'all 0s ease 0s',
|
|
90
|
+
'animation': 'none',
|
|
91
|
+
'pointer-events': 'auto',
|
|
92
|
+
'user-select': 'auto',
|
|
93
|
+
'cursor': 'auto',
|
|
94
|
+
'text-decoration': 'none',
|
|
95
|
+
'text-transform': 'none',
|
|
96
|
+
'font-weight': '400',
|
|
97
|
+
'font-style': 'normal',
|
|
98
|
+
'font-variant': 'normal',
|
|
99
|
+
'letter-spacing': 'normal',
|
|
100
|
+
'word-spacing': 'normal',
|
|
101
|
+
'text-align': 'start',
|
|
102
|
+
'white-space': 'normal',
|
|
103
|
+
'word-break': 'normal',
|
|
104
|
+
'overflow-wrap': 'normal',
|
|
105
|
+
'hyphens': 'manual'
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Filter computed CSS styles based on options
|
|
110
|
+
* @param {Array} computedStyle - Array of CSS property objects with name and value
|
|
111
|
+
* @param {Object} options - Filter options
|
|
112
|
+
* @param {string} options.category - Category to filter by ('layout', 'typography', 'colors', 'visual', 'all')
|
|
113
|
+
* @param {string[]} options.properties - Specific properties to include
|
|
114
|
+
* @param {boolean} options.includeDefaults - Include properties with default values
|
|
115
|
+
* @returns {Array} - Filtered CSS properties
|
|
116
|
+
*/
|
|
117
|
+
export function filterCssStyles(computedStyle, options = {}) {
|
|
118
|
+
const { category, properties, includeDefaults = false } = options;
|
|
119
|
+
|
|
120
|
+
let filtered = computedStyle;
|
|
121
|
+
|
|
122
|
+
// Filter by specific properties (highest priority)
|
|
123
|
+
if (properties && properties.length > 0) {
|
|
124
|
+
filtered = filtered.filter(prop =>
|
|
125
|
+
properties.some(p => prop.name.toLowerCase() === p.toLowerCase())
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
// Filter by category
|
|
129
|
+
else if (category && category !== 'all') {
|
|
130
|
+
const categoryProps = CSS_CATEGORIES[category] || [];
|
|
131
|
+
filtered = filtered.filter(prop =>
|
|
132
|
+
categoryProps.some(p => prop.name.toLowerCase().startsWith(p.toLowerCase()))
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Filter out default values if requested
|
|
137
|
+
if (!includeDefaults) {
|
|
138
|
+
filtered = filtered.filter(prop => {
|
|
139
|
+
const defaultValue = CSS_DEFAULTS[prop.name];
|
|
140
|
+
if (!defaultValue) return true;
|
|
141
|
+
|
|
142
|
+
// Normalize values for comparison
|
|
143
|
+
const normalizedValue = prop.value.replace(/\s+/g, ' ').trim();
|
|
144
|
+
const normalizedDefault = defaultValue.replace(/\s+/g, ' ').trim();
|
|
145
|
+
|
|
146
|
+
return normalizedValue !== normalizedDefault;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return filtered;
|
|
151
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* utils/image-processing.js
|
|
3
|
+
*
|
|
4
|
+
* Image processing utilities for screenshots and comparisons
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import Jimp from 'jimp';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Process screenshot with compression and scaling
|
|
11
|
+
* @param {Buffer} screenshotBuffer - Raw screenshot buffer
|
|
12
|
+
* @param {Object} options - Processing options
|
|
13
|
+
* @param {number} options.maxWidth - Maximum width (default: 1024)
|
|
14
|
+
* @param {number} options.maxHeight - Maximum height (default: 8000)
|
|
15
|
+
* @param {number} options.quality - JPEG quality (default: 80)
|
|
16
|
+
* @param {string} options.format - Output format: 'auto', 'png', 'jpeg' (default: 'auto')
|
|
17
|
+
* @param {number} options.maxFileSize - Maximum file size in bytes (default: 3MB)
|
|
18
|
+
* @returns {Promise<Object>} - Processed image data with metadata
|
|
19
|
+
*/
|
|
20
|
+
export async function processScreenshot(screenshotBuffer, options = {}) {
|
|
21
|
+
const {
|
|
22
|
+
maxWidth = 1024,
|
|
23
|
+
maxHeight = 8000, // API limit is 8000px
|
|
24
|
+
quality = 80,
|
|
25
|
+
format = 'auto',
|
|
26
|
+
maxFileSize = 3 * 1024 * 1024 // 3 MB limit
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
// Load image with Jimp
|
|
30
|
+
const image = await Jimp.read(screenshotBuffer);
|
|
31
|
+
const originalWidth = image.bitmap.width;
|
|
32
|
+
const originalHeight = image.bitmap.height;
|
|
33
|
+
const originalSize = screenshotBuffer.length;
|
|
34
|
+
|
|
35
|
+
let processed = false;
|
|
36
|
+
|
|
37
|
+
// Apply scaling if needed to fit within maxWidth and maxHeight
|
|
38
|
+
if (maxWidth !== null || maxHeight !== null) {
|
|
39
|
+
let newWidth = originalWidth;
|
|
40
|
+
let newHeight = originalHeight;
|
|
41
|
+
|
|
42
|
+
// Calculate scale factors for both dimensions
|
|
43
|
+
let scaleWidth = 1.0;
|
|
44
|
+
let scaleHeight = 1.0;
|
|
45
|
+
|
|
46
|
+
if (maxWidth !== null && originalWidth > maxWidth) {
|
|
47
|
+
scaleWidth = maxWidth / originalWidth;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (maxHeight !== null && originalHeight > maxHeight) {
|
|
51
|
+
scaleHeight = maxHeight / originalHeight;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Use the smaller scale factor to ensure both dimensions fit
|
|
55
|
+
const scale = Math.min(scaleWidth, scaleHeight);
|
|
56
|
+
|
|
57
|
+
if (scale < 1.0) {
|
|
58
|
+
newWidth = Math.round(originalWidth * scale);
|
|
59
|
+
newHeight = Math.round(originalHeight * scale);
|
|
60
|
+
image.resize(newWidth, newHeight);
|
|
61
|
+
processed = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Determine output format
|
|
66
|
+
let outputFormat = format;
|
|
67
|
+
let mimeType = 'image/png';
|
|
68
|
+
|
|
69
|
+
if (format === 'auto') {
|
|
70
|
+
// Auto-select: use JPEG for large images, PNG for small
|
|
71
|
+
const estimatedSize = image.bitmap.width * image.bitmap.height * 4;
|
|
72
|
+
outputFormat = estimatedSize > 500000 ? 'jpeg' : 'png'; // ~500KB threshold
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Convert to buffer with appropriate format and quality
|
|
76
|
+
let currentQuality = quality;
|
|
77
|
+
let resultBuffer;
|
|
78
|
+
let compressionAttempts = 0;
|
|
79
|
+
const maxCompressionAttempts = 10;
|
|
80
|
+
|
|
81
|
+
if (outputFormat === 'jpeg') {
|
|
82
|
+
image.quality(currentQuality);
|
|
83
|
+
resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
|
|
84
|
+
mimeType = 'image/jpeg';
|
|
85
|
+
processed = true;
|
|
86
|
+
|
|
87
|
+
// If file exceeds maxFileSize, reduce quality iteratively
|
|
88
|
+
while (resultBuffer.length > maxFileSize && compressionAttempts < maxCompressionAttempts) {
|
|
89
|
+
compressionAttempts++;
|
|
90
|
+
// Reduce quality by 10 points each iteration
|
|
91
|
+
currentQuality = Math.max(10, currentQuality - 10);
|
|
92
|
+
image.quality(currentQuality);
|
|
93
|
+
resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
|
|
94
|
+
|
|
95
|
+
// If quality is already at minimum and still too large, scale down the image
|
|
96
|
+
if (currentQuality === 10 && resultBuffer.length > maxFileSize) {
|
|
97
|
+
const scaleFactor = Math.sqrt(maxFileSize / resultBuffer.length * 0.9); // 0.9 for safety margin
|
|
98
|
+
const newWidth = Math.round(image.bitmap.width * scaleFactor);
|
|
99
|
+
const newHeight = Math.round(image.bitmap.height * scaleFactor);
|
|
100
|
+
image.resize(newWidth, newHeight);
|
|
101
|
+
image.quality(currentQuality);
|
|
102
|
+
resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
|
|
103
|
+
processed = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
resultBuffer = await image.getBufferAsync(Jimp.MIME_PNG);
|
|
108
|
+
mimeType = 'image/png';
|
|
109
|
+
|
|
110
|
+
// If PNG exceeds maxFileSize, convert to JPEG and compress
|
|
111
|
+
if (resultBuffer.length > maxFileSize) {
|
|
112
|
+
outputFormat = 'jpeg';
|
|
113
|
+
mimeType = 'image/jpeg';
|
|
114
|
+
currentQuality = quality;
|
|
115
|
+
image.quality(currentQuality);
|
|
116
|
+
resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
|
|
117
|
+
processed = true;
|
|
118
|
+
|
|
119
|
+
// Reduce quality iteratively if still too large
|
|
120
|
+
while (resultBuffer.length > maxFileSize && compressionAttempts < maxCompressionAttempts) {
|
|
121
|
+
compressionAttempts++;
|
|
122
|
+
currentQuality = Math.max(10, currentQuality - 10);
|
|
123
|
+
image.quality(currentQuality);
|
|
124
|
+
resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
|
|
125
|
+
|
|
126
|
+
// If quality is already at minimum and still too large, scale down the image
|
|
127
|
+
if (currentQuality === 10 && resultBuffer.length > maxFileSize) {
|
|
128
|
+
const scaleFactor = Math.sqrt(maxFileSize / resultBuffer.length * 0.9);
|
|
129
|
+
const newWidth = Math.round(image.bitmap.width * scaleFactor);
|
|
130
|
+
const newHeight = Math.round(image.bitmap.height * scaleFactor);
|
|
131
|
+
image.resize(newWidth, newHeight);
|
|
132
|
+
image.quality(currentQuality);
|
|
133
|
+
resultBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
|
|
134
|
+
processed = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Return original if no processing was needed and format is PNG
|
|
141
|
+
if (!processed && outputFormat === 'png' && resultBuffer.length <= maxFileSize) {
|
|
142
|
+
return {
|
|
143
|
+
buffer: screenshotBuffer,
|
|
144
|
+
mimeType: 'image/png',
|
|
145
|
+
metadata: {
|
|
146
|
+
width: originalWidth,
|
|
147
|
+
height: originalHeight,
|
|
148
|
+
originalSize,
|
|
149
|
+
finalSize: screenshotBuffer.length,
|
|
150
|
+
format: 'png',
|
|
151
|
+
compressed: false,
|
|
152
|
+
scaled: false
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
buffer: resultBuffer,
|
|
159
|
+
mimeType,
|
|
160
|
+
metadata: {
|
|
161
|
+
width: image.bitmap.width,
|
|
162
|
+
height: image.bitmap.height,
|
|
163
|
+
originalWidth,
|
|
164
|
+
originalHeight,
|
|
165
|
+
originalSize,
|
|
166
|
+
finalSize: resultBuffer.length,
|
|
167
|
+
format: outputFormat,
|
|
168
|
+
compressed: outputFormat === 'jpeg' || compressionAttempts > 0,
|
|
169
|
+
scaled: processed,
|
|
170
|
+
compressionRatio: Math.round((1 - resultBuffer.length / originalSize) * 100),
|
|
171
|
+
quality: outputFormat === 'jpeg' ? currentQuality : undefined,
|
|
172
|
+
compressionAttempts: compressionAttempts > 0 ? compressionAttempts : undefined,
|
|
173
|
+
autoCompressed: compressionAttempts > 0 || (outputFormat === 'jpeg' && format === 'png')
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Calculate SSIM (Structural Similarity Index) for image comparison
|
|
180
|
+
* @param {Uint8ClampedArray} img1Data - First image data
|
|
181
|
+
* @param {Uint8ClampedArray} img2Data - Second image data
|
|
182
|
+
* @param {number} width - Image width
|
|
183
|
+
* @param {number} height - Image height
|
|
184
|
+
* @returns {number} - SSIM score (0-1, higher is more similar)
|
|
185
|
+
*/
|
|
186
|
+
export function calculateSSIM(img1Data, img2Data, width, height) {
|
|
187
|
+
if (img1Data.length !== img2Data.length) {
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const windowSize = 8;
|
|
192
|
+
const k1 = 0.01;
|
|
193
|
+
const k2 = 0.03;
|
|
194
|
+
const c1 = (k1 * 255) ** 2;
|
|
195
|
+
const c2 = (k2 * 255) ** 2;
|
|
196
|
+
|
|
197
|
+
let ssimSum = 0;
|
|
198
|
+
let validWindows = 0;
|
|
199
|
+
|
|
200
|
+
for (let y = 0; y <= height - windowSize; y += windowSize) {
|
|
201
|
+
for (let x = 0; x <= width - windowSize; x += windowSize) {
|
|
202
|
+
let sum1 = 0, sum2 = 0, sum1Sq = 0, sum2Sq = 0, sum12 = 0;
|
|
203
|
+
|
|
204
|
+
for (let dy = 0; dy < windowSize; dy++) {
|
|
205
|
+
for (let dx = 0; dx < windowSize; dx++) {
|
|
206
|
+
const idx = ((y + dy) * width + (x + dx)) * 4;
|
|
207
|
+
if (idx + 2 >= img1Data.length) continue;
|
|
208
|
+
|
|
209
|
+
const gray1 = (img1Data[idx] * 0.299 + img1Data[idx + 1] * 0.587 + img1Data[idx + 2] * 0.114);
|
|
210
|
+
const gray2 = (img2Data[idx] * 0.299 + img2Data[idx + 1] * 0.587 + img2Data[idx + 2] * 0.114);
|
|
211
|
+
|
|
212
|
+
sum1 += gray1;
|
|
213
|
+
sum2 += gray2;
|
|
214
|
+
sum1Sq += gray1 * gray1;
|
|
215
|
+
sum2Sq += gray2 * gray2;
|
|
216
|
+
sum12 += gray1 * gray2;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const n = windowSize * windowSize;
|
|
221
|
+
const mean1 = sum1 / n;
|
|
222
|
+
const mean2 = sum2 / n;
|
|
223
|
+
const variance1 = (sum1Sq / n) - (mean1 * mean1);
|
|
224
|
+
const variance2 = (sum2Sq / n) - (mean2 * mean2);
|
|
225
|
+
const covariance = (sum12 / n) - (mean1 * mean2);
|
|
226
|
+
|
|
227
|
+
const ssim = ((2 * mean1 * mean2 + c1) * (2 * covariance + c2)) /
|
|
228
|
+
((mean1 * mean1 + mean2 * mean2 + c1) * (variance1 + variance2 + c2));
|
|
229
|
+
|
|
230
|
+
ssimSum += ssim;
|
|
231
|
+
validWindows++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return validWindows > 0 ? ssimSum / validWindows : 0;
|
|
236
|
+
}
|