design-clone 1.0.0
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/.env.example +14 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/SKILL.md +239 -0
- package/bin/cli.js +45 -0
- package/bin/commands/help.js +29 -0
- package/bin/commands/init.js +126 -0
- package/bin/commands/verify.js +99 -0
- package/bin/utils/copy.js +65 -0
- package/bin/utils/validate.js +122 -0
- package/docs/basic-clone.md +63 -0
- package/docs/cli-reference.md +94 -0
- package/docs/design-clone-architecture.md +247 -0
- package/docs/pixel-perfect.md +86 -0
- package/docs/troubleshooting.md +97 -0
- package/package.json +57 -0
- package/requirements.txt +5 -0
- package/src/ai/analyze-structure.py +305 -0
- package/src/ai/extract-design-tokens.py +439 -0
- package/src/ai/prompts/__init__.py +2 -0
- package/src/ai/prompts/design_tokens.py +183 -0
- package/src/ai/prompts/structure_analysis.py +273 -0
- package/src/core/cookie-handler.js +76 -0
- package/src/core/css-extractor.js +107 -0
- package/src/core/dimension-extractor.js +366 -0
- package/src/core/dimension-output.js +208 -0
- package/src/core/extract-assets.js +468 -0
- package/src/core/filter-css.js +499 -0
- package/src/core/html-extractor.js +102 -0
- package/src/core/lazy-loader.js +188 -0
- package/src/core/page-readiness.js +161 -0
- package/src/core/screenshot.js +380 -0
- package/src/post-process/enhance-assets.js +157 -0
- package/src/post-process/fetch-images.js +398 -0
- package/src/post-process/inject-icons.js +311 -0
- package/src/utils/__init__.py +16 -0
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/browser.js +103 -0
- package/src/utils/env.js +153 -0
- package/src/utils/env.py +134 -0
- package/src/utils/helpers.js +71 -0
- package/src/utils/puppeteer.js +281 -0
- package/src/verification/verify-layout.js +424 -0
- package/src/verification/verify-menu.js +422 -0
- package/templates/base.css +705 -0
- package/templates/base.html +293 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Puppeteer browser wrapper for design-clone scripts
|
|
3
|
+
* Provides browser automation without requiring chrome-devtools skill
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Auto-detects Chrome installation path (macOS, Linux, Windows)
|
|
7
|
+
* - Session persistence via WebSocket endpoint file
|
|
8
|
+
* - Graceful connect/disconnect lifecycle
|
|
9
|
+
*
|
|
10
|
+
* @note Session file may have race conditions in concurrent execution scenarios.
|
|
11
|
+
* For production use with multiple parallel scripts, consider external lock.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
// Session file for browser reuse across script invocations
|
|
21
|
+
const SESSION_FILE = path.join(__dirname, '..', '.browser-session.json');
|
|
22
|
+
const SESSION_MAX_AGE = 3600000; // 1 hour
|
|
23
|
+
|
|
24
|
+
let browserInstance = null;
|
|
25
|
+
let pageInstance = null;
|
|
26
|
+
let puppeteer = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect Chrome executable path by platform
|
|
30
|
+
* @returns {string|null} Chrome path or null if not found
|
|
31
|
+
*/
|
|
32
|
+
function detectChromePath() {
|
|
33
|
+
const platform = process.platform;
|
|
34
|
+
|
|
35
|
+
const paths = {
|
|
36
|
+
darwin: [
|
|
37
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
38
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
39
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'
|
|
40
|
+
],
|
|
41
|
+
linux: [
|
|
42
|
+
'/usr/bin/google-chrome',
|
|
43
|
+
'/usr/bin/google-chrome-stable',
|
|
44
|
+
'/usr/bin/chromium',
|
|
45
|
+
'/usr/bin/chromium-browser',
|
|
46
|
+
'/snap/bin/chromium'
|
|
47
|
+
],
|
|
48
|
+
win32: [
|
|
49
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
50
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
51
|
+
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`
|
|
52
|
+
]
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const candidates = paths[platform] || [];
|
|
56
|
+
for (const chromePath of candidates) {
|
|
57
|
+
if (fs.existsSync(chromePath)) {
|
|
58
|
+
return chromePath;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Load puppeteer module (try puppeteer first, then puppeteer-core)
|
|
67
|
+
* @returns {Promise<Object>} Puppeteer module
|
|
68
|
+
* @throws {Error} If neither puppeteer nor puppeteer-core is installed
|
|
69
|
+
*/
|
|
70
|
+
async function loadPuppeteer() {
|
|
71
|
+
if (puppeteer) return puppeteer;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
// Try full puppeteer first (includes bundled Chrome)
|
|
75
|
+
puppeteer = (await import('puppeteer')).default;
|
|
76
|
+
return puppeteer;
|
|
77
|
+
} catch (e1) {
|
|
78
|
+
try {
|
|
79
|
+
// Fall back to puppeteer-core (requires Chrome)
|
|
80
|
+
puppeteer = (await import('puppeteer-core')).default;
|
|
81
|
+
return puppeteer;
|
|
82
|
+
} catch (e2) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
'Puppeteer not found. Install with: npm install puppeteer\n' +
|
|
85
|
+
'Or for smaller install: npm install puppeteer-core\n' +
|
|
86
|
+
`Details: puppeteer: ${e1.message}, puppeteer-core: ${e2.message}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read browser session from file
|
|
94
|
+
* @returns {Object|null} Session data with wsEndpoint and timestamp, or null if invalid/missing
|
|
95
|
+
*/
|
|
96
|
+
function readSession() {
|
|
97
|
+
try {
|
|
98
|
+
if (fs.existsSync(SESSION_FILE)) {
|
|
99
|
+
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
|
100
|
+
// Validate session age
|
|
101
|
+
if (data.timestamp && Date.now() - data.timestamp < SESSION_MAX_AGE) {
|
|
102
|
+
return data;
|
|
103
|
+
}
|
|
104
|
+
// Session expired, clean up
|
|
105
|
+
clearSession();
|
|
106
|
+
}
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error(`[browser] Failed to read session: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write browser session to file with PID tracking
|
|
115
|
+
* @param {string} wsEndpoint - WebSocket endpoint URL
|
|
116
|
+
*/
|
|
117
|
+
function writeSession(wsEndpoint) {
|
|
118
|
+
try {
|
|
119
|
+
fs.writeFileSync(SESSION_FILE, JSON.stringify({
|
|
120
|
+
wsEndpoint,
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
pid: process.pid
|
|
123
|
+
}));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error(`[browser] Failed to write session: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Clear browser session file
|
|
131
|
+
*/
|
|
132
|
+
function clearSession() {
|
|
133
|
+
try {
|
|
134
|
+
if (fs.existsSync(SESSION_FILE)) {
|
|
135
|
+
fs.unlinkSync(SESSION_FILE);
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error(`[browser] Failed to clear session: ${err.message}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Launch or connect to browser instance
|
|
144
|
+
* Reuses existing session if available and valid
|
|
145
|
+
*
|
|
146
|
+
* @param {Object} options - Browser options
|
|
147
|
+
* @param {boolean} [options.headless=true] - Run in headless mode
|
|
148
|
+
* @param {Object} [options.viewport] - Default viewport dimensions
|
|
149
|
+
* @param {string} [options.executablePath] - Chrome executable path override
|
|
150
|
+
* @param {string[]} [options.args] - Additional Chrome arguments
|
|
151
|
+
* @returns {Promise<Browser>} Puppeteer browser instance
|
|
152
|
+
* @throws {Error} If Chrome not found and no executablePath provided
|
|
153
|
+
*/
|
|
154
|
+
export async function getBrowser(options = {}) {
|
|
155
|
+
const pptr = await loadPuppeteer();
|
|
156
|
+
|
|
157
|
+
// Reuse existing browser in this process
|
|
158
|
+
if (browserInstance && browserInstance.isConnected()) {
|
|
159
|
+
return browserInstance;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Try to connect to existing browser from session
|
|
163
|
+
const session = readSession();
|
|
164
|
+
if (session?.wsEndpoint) {
|
|
165
|
+
try {
|
|
166
|
+
browserInstance = await pptr.connect({
|
|
167
|
+
browserWSEndpoint: session.wsEndpoint
|
|
168
|
+
});
|
|
169
|
+
console.error('[browser] Connected to existing session');
|
|
170
|
+
return browserInstance;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`[browser] Failed to connect to existing session: ${err.message}`);
|
|
173
|
+
clearSession();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Determine executable path
|
|
178
|
+
let executablePath = options.executablePath;
|
|
179
|
+
if (!executablePath && pptr.executablePath) {
|
|
180
|
+
try {
|
|
181
|
+
// Full puppeteer has built-in Chrome
|
|
182
|
+
executablePath = pptr.executablePath();
|
|
183
|
+
} catch {
|
|
184
|
+
// puppeteer-core needs manual path
|
|
185
|
+
executablePath = detectChromePath();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!executablePath) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
'Chrome not found. Either:\n' +
|
|
192
|
+
'1. Install Google Chrome\n' +
|
|
193
|
+
'2. Use full puppeteer (npm install puppeteer)\n' +
|
|
194
|
+
'3. Set executablePath option'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Launch new browser
|
|
199
|
+
const launchOptions = {
|
|
200
|
+
headless: options.headless !== false,
|
|
201
|
+
executablePath,
|
|
202
|
+
args: [
|
|
203
|
+
'--no-sandbox',
|
|
204
|
+
'--disable-setuid-sandbox',
|
|
205
|
+
'--disable-dev-shm-usage',
|
|
206
|
+
'--disable-gpu',
|
|
207
|
+
...(options.args || [])
|
|
208
|
+
],
|
|
209
|
+
defaultViewport: options.viewport || {
|
|
210
|
+
width: 1920,
|
|
211
|
+
height: 1080
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
browserInstance = await pptr.launch(launchOptions);
|
|
216
|
+
|
|
217
|
+
// Save session for reuse
|
|
218
|
+
const wsEndpoint = browserInstance.wsEndpoint();
|
|
219
|
+
writeSession(wsEndpoint);
|
|
220
|
+
console.error('[browser] Launched new browser');
|
|
221
|
+
|
|
222
|
+
return browserInstance;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Get current page or create new one
|
|
227
|
+
* Reuses existing page if available
|
|
228
|
+
*
|
|
229
|
+
* @param {Browser} browser - Puppeteer browser instance
|
|
230
|
+
* @returns {Promise<Page>} Puppeteer page instance
|
|
231
|
+
* @throws {Error} If browser is null or disconnected
|
|
232
|
+
*/
|
|
233
|
+
export async function getPage(browser) {
|
|
234
|
+
if (!browser || !browser.isConnected()) {
|
|
235
|
+
throw new Error('Browser not connected. Call getBrowser() first.');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (pageInstance && !pageInstance.isClosed()) {
|
|
239
|
+
return pageInstance;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const pages = await browser.pages();
|
|
243
|
+
pageInstance = pages.length > 0 ? pages[0] : await browser.newPage();
|
|
244
|
+
|
|
245
|
+
return pageInstance;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Close browser and clear session
|
|
250
|
+
* Use when completely done with browser
|
|
251
|
+
*/
|
|
252
|
+
export async function closeBrowser() {
|
|
253
|
+
if (browserInstance) {
|
|
254
|
+
try {
|
|
255
|
+
await browserInstance.close();
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error(`[browser] Error closing browser: ${err.message}`);
|
|
258
|
+
}
|
|
259
|
+
browserInstance = null;
|
|
260
|
+
pageInstance = null;
|
|
261
|
+
clearSession();
|
|
262
|
+
console.error('[browser] Closed and cleared session');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Disconnect from browser without closing it
|
|
268
|
+
* Use to keep browser running for future script executions
|
|
269
|
+
*/
|
|
270
|
+
export async function disconnectBrowser() {
|
|
271
|
+
if (browserInstance) {
|
|
272
|
+
try {
|
|
273
|
+
browserInstance.disconnect();
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error(`[browser] Error disconnecting: ${err.message}`);
|
|
276
|
+
}
|
|
277
|
+
browserInstance = null;
|
|
278
|
+
pageInstance = null;
|
|
279
|
+
console.error('[browser] Disconnected (browser still running)');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Layout Verification Script
|
|
4
|
+
*
|
|
5
|
+
* Compares generated HTML against original website screenshots
|
|
6
|
+
* using Gemini Vision to identify layout discrepancies.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node verify-layout.js --html <path> --original <dir> [--output <dir>] [--verbose]
|
|
10
|
+
*
|
|
11
|
+
* Options:
|
|
12
|
+
* --html Path to generated HTML file
|
|
13
|
+
* --original Directory containing original screenshots (desktop.png, tablet.png, mobile.png)
|
|
14
|
+
* --output Output directory for comparison screenshots and report
|
|
15
|
+
* --verbose Show detailed progress
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'fs/promises';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
|
|
22
|
+
// Import browser abstraction (auto-detects chrome-devtools or standalone)
|
|
23
|
+
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from '../utils/browser.js';
|
|
24
|
+
|
|
25
|
+
// Import Gemini for vision comparison
|
|
26
|
+
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
|
|
27
|
+
|
|
28
|
+
// Viewport configurations matching original screenshots
|
|
29
|
+
const VIEWPORTS = {
|
|
30
|
+
desktop: { width: 1920, height: 1080, deviceScaleFactor: 1 },
|
|
31
|
+
tablet: { width: 768, height: 1024, deviceScaleFactor: 1 },
|
|
32
|
+
mobile: { width: 375, height: 812, deviceScaleFactor: 2 }
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Capture screenshot of generated HTML at specific viewport
|
|
37
|
+
*/
|
|
38
|
+
async function captureGeneratedScreenshot(page, viewport, outputPath) {
|
|
39
|
+
await page.setViewport(viewport);
|
|
40
|
+
await new Promise(r => setTimeout(r, 500)); // Wait for CSS to apply
|
|
41
|
+
|
|
42
|
+
await page.screenshot({
|
|
43
|
+
path: outputPath,
|
|
44
|
+
fullPage: true
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return outputPath;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compare two screenshots using Gemini Vision
|
|
52
|
+
*/
|
|
53
|
+
async function compareWithGemini(originalPath, generatedPath, viewportName) {
|
|
54
|
+
if (!GEMINI_API_KEY) {
|
|
55
|
+
return {
|
|
56
|
+
success: false,
|
|
57
|
+
error: 'GEMINI_API_KEY not set',
|
|
58
|
+
discrepancies: [],
|
|
59
|
+
similarity: 0
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const { GoogleGenerativeAI } = await import('@google/generative-ai');
|
|
65
|
+
const genai = new GoogleGenerativeAI(GEMINI_API_KEY);
|
|
66
|
+
|
|
67
|
+
// Read both images as base64
|
|
68
|
+
const originalBuffer = await fs.readFile(originalPath);
|
|
69
|
+
const generatedBuffer = await fs.readFile(generatedPath);
|
|
70
|
+
|
|
71
|
+
const originalBase64 = originalBuffer.toString('base64');
|
|
72
|
+
const generatedBase64 = generatedBuffer.toString('base64');
|
|
73
|
+
|
|
74
|
+
const prompt = `You are a UI/UX expert comparing two website screenshots for layout accuracy.
|
|
75
|
+
|
|
76
|
+
IMAGE 1 (left/first): Original website screenshot
|
|
77
|
+
IMAGE 2 (right/second): Generated HTML clone screenshot
|
|
78
|
+
|
|
79
|
+
Viewport: ${viewportName} (${VIEWPORTS[viewportName].width}x${VIEWPORTS[viewportName].height})
|
|
80
|
+
|
|
81
|
+
Analyze and compare these two images. Focus on:
|
|
82
|
+
1. **Layout Structure** - Are sections positioned correctly? Any misalignment?
|
|
83
|
+
2. **Spacing** - Are margins, padding, gaps correct?
|
|
84
|
+
3. **Typography** - Font sizes, line heights, text alignment
|
|
85
|
+
4. **Colors** - Background colors, text colors, borders
|
|
86
|
+
5. **Responsive Elements** - Menu, grid layouts, card widths
|
|
87
|
+
6. **Components** - Buttons, forms, icons positioning
|
|
88
|
+
|
|
89
|
+
Return a JSON object with this exact structure:
|
|
90
|
+
{
|
|
91
|
+
"similarity_score": <0-100 number>,
|
|
92
|
+
"overall_assessment": "<brief assessment>",
|
|
93
|
+
"discrepancies": [
|
|
94
|
+
{
|
|
95
|
+
"section": "<section name>",
|
|
96
|
+
"severity": "<critical|major|minor>",
|
|
97
|
+
"issue": "<description of the issue>",
|
|
98
|
+
"css_fix": "<suggested CSS fix or null>"
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
"recommendations": ["<actionable fix 1>", "<actionable fix 2>"]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
Be specific about CSS selectors and property values when suggesting fixes.
|
|
105
|
+
If similarity is >90%, discrepancies array can be empty.`;
|
|
106
|
+
|
|
107
|
+
const model = genai.getGenerativeModel({ model: 'gemini-2.5-flash' });
|
|
108
|
+
|
|
109
|
+
const response = await model.generateContent([
|
|
110
|
+
prompt,
|
|
111
|
+
{
|
|
112
|
+
inlineData: {
|
|
113
|
+
mimeType: 'image/png',
|
|
114
|
+
data: originalBase64
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
inlineData: {
|
|
119
|
+
mimeType: 'image/png',
|
|
120
|
+
data: generatedBase64
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
]);
|
|
124
|
+
|
|
125
|
+
const text = response.response.text();
|
|
126
|
+
|
|
127
|
+
// Extract JSON from response
|
|
128
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
129
|
+
if (!jsonMatch) {
|
|
130
|
+
return {
|
|
131
|
+
success: false,
|
|
132
|
+
error: 'Could not parse Gemini response',
|
|
133
|
+
raw: text
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = JSON.parse(jsonMatch[0]);
|
|
138
|
+
return {
|
|
139
|
+
success: true,
|
|
140
|
+
viewport: viewportName,
|
|
141
|
+
...result
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
} catch (error) {
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
error: error.message,
|
|
148
|
+
discrepancies: []
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Alternative: Use image comparison without API
|
|
155
|
+
* Basic pixel difference calculation
|
|
156
|
+
*/
|
|
157
|
+
async function basicImageCompare(originalPath, generatedPath) {
|
|
158
|
+
// This is a fallback that just checks file sizes
|
|
159
|
+
// Real comparison should use Gemini
|
|
160
|
+
try {
|
|
161
|
+
const originalStats = await fs.stat(originalPath);
|
|
162
|
+
const generatedStats = await fs.stat(generatedPath);
|
|
163
|
+
|
|
164
|
+
// Crude estimation based on file size difference
|
|
165
|
+
const sizeDiff = Math.abs(originalStats.size - generatedStats.size) / originalStats.size;
|
|
166
|
+
const similarity = Math.max(0, 100 - (sizeDiff * 100));
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
success: true,
|
|
170
|
+
method: 'basic',
|
|
171
|
+
similarity_score: Math.round(similarity),
|
|
172
|
+
note: 'Basic comparison - use Gemini for accurate analysis'
|
|
173
|
+
};
|
|
174
|
+
} catch (error) {
|
|
175
|
+
return { success: false, error: error.message };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Generate CSS fix suggestions based on discrepancies
|
|
181
|
+
*/
|
|
182
|
+
function generateCSSFixes(discrepancies) {
|
|
183
|
+
const fixes = [];
|
|
184
|
+
|
|
185
|
+
for (const disc of discrepancies) {
|
|
186
|
+
if (disc.css_fix) {
|
|
187
|
+
fixes.push({
|
|
188
|
+
section: disc.section,
|
|
189
|
+
severity: disc.severity,
|
|
190
|
+
issue: disc.issue,
|
|
191
|
+
fix: disc.css_fix
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return fixes;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Write comparison report
|
|
201
|
+
*/
|
|
202
|
+
async function writeReport(outputDir, results) {
|
|
203
|
+
const reportPath = path.join(outputDir, 'layout-verification.md');
|
|
204
|
+
|
|
205
|
+
let report = `# Layout Verification Report
|
|
206
|
+
|
|
207
|
+
Generated: ${new Date().toISOString()}
|
|
208
|
+
|
|
209
|
+
## Summary
|
|
210
|
+
|
|
211
|
+
| Viewport | Similarity | Issues |
|
|
212
|
+
|----------|------------|--------|
|
|
213
|
+
`;
|
|
214
|
+
|
|
215
|
+
for (const [viewport, result] of Object.entries(results.viewports)) {
|
|
216
|
+
const score = result.similarity_score || 0;
|
|
217
|
+
const issues = result.discrepancies?.length || 0;
|
|
218
|
+
const status = score >= 90 ? 'ā
' : score >= 70 ? 'ā ļø' : 'ā';
|
|
219
|
+
report += `| ${viewport} | ${status} ${score}% | ${issues} |\n`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
report += `\n## Overall Score: ${results.overall_score}%\n\n`;
|
|
223
|
+
|
|
224
|
+
// Detail each viewport
|
|
225
|
+
for (const [viewport, result] of Object.entries(results.viewports)) {
|
|
226
|
+
report += `## ${viewport.charAt(0).toUpperCase() + viewport.slice(1)} (${VIEWPORTS[viewport].width}x${VIEWPORTS[viewport].height})\n\n`;
|
|
227
|
+
|
|
228
|
+
if (result.overall_assessment) {
|
|
229
|
+
report += `**Assessment:** ${result.overall_assessment}\n\n`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (result.discrepancies?.length > 0) {
|
|
233
|
+
report += `### Discrepancies\n\n`;
|
|
234
|
+
for (const disc of result.discrepancies) {
|
|
235
|
+
const icon = disc.severity === 'critical' ? 'š“' : disc.severity === 'major' ? 'š ' : 'š”';
|
|
236
|
+
report += `${icon} **${disc.section}** (${disc.severity})\n`;
|
|
237
|
+
report += ` - Issue: ${disc.issue}\n`;
|
|
238
|
+
if (disc.css_fix) {
|
|
239
|
+
report += ` - Fix: \`${disc.css_fix}\`\n`;
|
|
240
|
+
}
|
|
241
|
+
report += '\n';
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
report += `ā
No significant discrepancies found.\n\n`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (result.recommendations?.length > 0) {
|
|
248
|
+
report += `### Recommendations\n\n`;
|
|
249
|
+
for (const rec of result.recommendations) {
|
|
250
|
+
report += `- ${rec}\n`;
|
|
251
|
+
}
|
|
252
|
+
report += '\n';
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Consolidated CSS fixes
|
|
257
|
+
const allFixes = [];
|
|
258
|
+
for (const result of Object.values(results.viewports)) {
|
|
259
|
+
if (result.discrepancies) {
|
|
260
|
+
allFixes.push(...generateCSSFixes(result.discrepancies));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (allFixes.length > 0) {
|
|
265
|
+
report += `## Suggested CSS Fixes\n\n\`\`\`css\n`;
|
|
266
|
+
for (const fix of allFixes) {
|
|
267
|
+
report += `/* ${fix.section}: ${fix.issue} */\n`;
|
|
268
|
+
report += `${fix.fix}\n\n`;
|
|
269
|
+
}
|
|
270
|
+
report += `\`\`\`\n`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
await fs.writeFile(reportPath, report);
|
|
274
|
+
return reportPath;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Main verification function
|
|
279
|
+
*/
|
|
280
|
+
async function verifyLayout() {
|
|
281
|
+
const args = parseArgs(process.argv.slice(2));
|
|
282
|
+
|
|
283
|
+
if (!args.html) {
|
|
284
|
+
outputError(new Error('--html is required'));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (!args.original) {
|
|
289
|
+
outputError(new Error('--original directory is required'));
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const verbose = args.verbose === 'true';
|
|
294
|
+
const outputDir = args.output || path.dirname(args.html);
|
|
295
|
+
|
|
296
|
+
// Ensure output directory exists
|
|
297
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
if (verbose) console.error('\nš Starting layout verification...\n');
|
|
301
|
+
|
|
302
|
+
// Check original screenshots exist
|
|
303
|
+
const originalScreenshots = {};
|
|
304
|
+
for (const viewport of ['desktop', 'tablet', 'mobile']) {
|
|
305
|
+
const screenshotPath = path.join(args.original, `${viewport}.png`);
|
|
306
|
+
try {
|
|
307
|
+
await fs.access(screenshotPath);
|
|
308
|
+
originalScreenshots[viewport] = screenshotPath;
|
|
309
|
+
if (verbose) console.error(` ā Found ${viewport}.png`);
|
|
310
|
+
} catch {
|
|
311
|
+
if (verbose) console.error(` ā Missing ${viewport}.png`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (Object.keys(originalScreenshots).length === 0) {
|
|
316
|
+
outputError(new Error('No original screenshots found'));
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Launch browser and capture generated screenshots
|
|
321
|
+
if (verbose) console.error('\nšø Capturing generated screenshots...\n');
|
|
322
|
+
|
|
323
|
+
const browser = await getBrowser({ headless: args.headless !== 'false' });
|
|
324
|
+
const page = await getPage(browser);
|
|
325
|
+
|
|
326
|
+
// Navigate to generated HTML
|
|
327
|
+
const absolutePath = path.resolve(args.html);
|
|
328
|
+
const targetUrl = `file://${absolutePath}`;
|
|
329
|
+
|
|
330
|
+
await page.goto(targetUrl, {
|
|
331
|
+
waitUntil: 'networkidle2',
|
|
332
|
+
timeout: 30000
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Capture screenshots at each viewport
|
|
336
|
+
const generatedScreenshots = {};
|
|
337
|
+
for (const [viewport, config] of Object.entries(VIEWPORTS)) {
|
|
338
|
+
if (originalScreenshots[viewport]) {
|
|
339
|
+
const outputPath = path.join(outputDir, `generated-${viewport}.png`);
|
|
340
|
+
await captureGeneratedScreenshot(page, config, outputPath);
|
|
341
|
+
generatedScreenshots[viewport] = outputPath;
|
|
342
|
+
if (verbose) console.error(` ā Captured ${viewport}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Close browser
|
|
347
|
+
if (args.close === 'true') {
|
|
348
|
+
await closeBrowser();
|
|
349
|
+
} else {
|
|
350
|
+
await disconnectBrowser();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Compare screenshots
|
|
354
|
+
if (verbose) console.error('\nš¬ Comparing layouts...\n');
|
|
355
|
+
|
|
356
|
+
const results = {
|
|
357
|
+
success: true,
|
|
358
|
+
html: args.html,
|
|
359
|
+
viewports: {},
|
|
360
|
+
overall_score: 0,
|
|
361
|
+
all_fixes: []
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
let totalScore = 0;
|
|
365
|
+
let viewportCount = 0;
|
|
366
|
+
|
|
367
|
+
for (const [viewport, originalPath] of Object.entries(originalScreenshots)) {
|
|
368
|
+
const generatedPath = generatedScreenshots[viewport];
|
|
369
|
+
if (!generatedPath) continue;
|
|
370
|
+
|
|
371
|
+
if (verbose) console.error(` Comparing ${viewport}...`);
|
|
372
|
+
|
|
373
|
+
let comparison;
|
|
374
|
+
if (GEMINI_API_KEY) {
|
|
375
|
+
comparison = await compareWithGemini(originalPath, generatedPath, viewport);
|
|
376
|
+
} else {
|
|
377
|
+
comparison = await basicImageCompare(originalPath, generatedPath);
|
|
378
|
+
if (verbose) console.error(' ā Using basic comparison (set GEMINI_API_KEY for accurate analysis)');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
results.viewports[viewport] = comparison;
|
|
382
|
+
|
|
383
|
+
if (comparison.success && comparison.similarity_score !== undefined) {
|
|
384
|
+
totalScore += comparison.similarity_score;
|
|
385
|
+
viewportCount++;
|
|
386
|
+
|
|
387
|
+
const icon = comparison.similarity_score >= 90 ? 'ā
' : comparison.similarity_score >= 70 ? 'ā ļø' : 'ā';
|
|
388
|
+
if (verbose) console.error(` ${icon} Similarity: ${comparison.similarity_score}%`);
|
|
389
|
+
|
|
390
|
+
if (comparison.discrepancies?.length > 0) {
|
|
391
|
+
if (verbose) console.error(` Found ${comparison.discrepancies.length} discrepancies`);
|
|
392
|
+
results.all_fixes.push(...generateCSSFixes(comparison.discrepancies));
|
|
393
|
+
}
|
|
394
|
+
} else if (!comparison.success) {
|
|
395
|
+
if (verbose) console.error(` ā Error: ${comparison.error}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
results.overall_score = viewportCount > 0 ? Math.round(totalScore / viewportCount) : 0;
|
|
400
|
+
results.success = results.overall_score >= 70;
|
|
401
|
+
|
|
402
|
+
// Write report
|
|
403
|
+
const reportPath = await writeReport(outputDir, results);
|
|
404
|
+
results.report = reportPath;
|
|
405
|
+
|
|
406
|
+
// Final summary
|
|
407
|
+
if (verbose) {
|
|
408
|
+
console.error('\nš Summary:');
|
|
409
|
+
console.error(` Overall Score: ${results.overall_score}%`);
|
|
410
|
+
console.error(` Status: ${results.success ? 'ā PASS' : 'ā NEEDS FIXES'}`);
|
|
411
|
+
console.error(` Report: ${reportPath}\n`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
outputJSON(results);
|
|
415
|
+
process.exit(results.success ? 0 : 1);
|
|
416
|
+
|
|
417
|
+
} catch (error) {
|
|
418
|
+
outputError(error);
|
|
419
|
+
process.exit(1);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Run
|
|
424
|
+
verifyLayout();
|