design-clone 1.2.0 → 2.1.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/README.md +26 -12
- package/bin/commands/clone-site.js +75 -10
- package/bin/commands/init.js +33 -1
- package/bin/commands/verify.js +5 -3
- package/bin/utils/validate.js +24 -8
- package/docs/cli-reference.md +200 -2
- package/docs/codebase-summary.md +309 -0
- package/docs/design-clone-architecture.md +259 -42
- package/docs/pixel-perfect.md +35 -4
- package/docs/project-roadmap.md +382 -0
- package/docs/troubleshooting.md +5 -4
- package/package.json +10 -8
- package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
- package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
- package/src/ai/analyze-structure.py +73 -3
- package/src/ai/extract-design-tokens.py +356 -13
- package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
- package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
- package/src/ai/prompts/design_tokens.py +133 -0
- package/src/ai/prompts/structure_analysis.py +329 -10
- package/src/ai/prompts/ux_audit.py +198 -0
- package/src/ai/ux-audit.js +596 -0
- package/src/core/app-state-snapshot.js +511 -0
- package/src/core/content-counter.js +342 -0
- package/src/core/cookie-handler.js +1 -1
- package/src/core/css-extractor.js +4 -4
- package/src/core/dimension-extractor.js +93 -21
- package/src/core/dimension-output.js +103 -6
- package/src/core/discover-pages.js +242 -14
- package/src/core/dom-tree-analyzer.js +298 -0
- package/src/core/extract-assets.js +1 -1
- package/src/core/framework-detector.js +538 -0
- package/src/core/html-extractor.js +45 -4
- package/src/core/lazy-loader.js +7 -7
- package/src/core/multi-page-screenshot.js +9 -6
- package/src/core/page-readiness.js +8 -8
- package/src/core/screenshot.js +138 -9
- package/src/core/section-cropper.js +209 -0
- package/src/core/section-detector.js +386 -0
- package/src/core/semantic-enhancer.js +492 -0
- package/src/core/state-capture.js +18 -22
- package/src/core/tests/test-section-cropper.js +177 -0
- package/src/core/tests/test-section-detector.js +55 -0
- package/src/core/video-capture.js +152 -146
- package/src/route-discoverers/angular-discoverer.js +157 -0
- package/src/route-discoverers/astro-discoverer.js +123 -0
- package/src/route-discoverers/base-discoverer.js +242 -0
- package/src/route-discoverers/index.js +106 -0
- package/src/route-discoverers/next-discoverer.js +130 -0
- package/src/route-discoverers/nuxt-discoverer.js +138 -0
- package/src/route-discoverers/react-discoverer.js +139 -0
- package/src/route-discoverers/svelte-discoverer.js +109 -0
- package/src/route-discoverers/universal-discoverer.js +227 -0
- package/src/route-discoverers/vue-discoverer.js +118 -0
- package/src/utils/__init__.py +1 -1
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/browser.js +11 -37
- package/src/utils/playwright.js +213 -0
- package/src/verification/generate-audit-report.js +398 -0
- package/src/verification/verify-footer.js +493 -0
- package/src/verification/verify-header.js +486 -0
- package/src/verification/verify-layout.js +2 -2
- package/src/verification/verify-menu.js +4 -20
- package/src/verification/verify-slider.js +533 -0
- package/src/utils/puppeteer.js +0 -281
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal Route Discoverer
|
|
3
|
+
*
|
|
4
|
+
* Fallback discoverer for unknown frameworks or static sites.
|
|
5
|
+
* Uses comprehensive techniques:
|
|
6
|
+
* - history.pushState/replaceState interception
|
|
7
|
+
* - Exhaustive link scraping from navigation elements
|
|
8
|
+
* - Sitemap.xml parsing if available
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { BaseDiscoverer } from './base-discoverer.js';
|
|
12
|
+
|
|
13
|
+
export class UniversalDiscoverer extends BaseDiscoverer {
|
|
14
|
+
/**
|
|
15
|
+
* Discover routes using universal techniques
|
|
16
|
+
* @returns {Promise<import('./base-discoverer.js').DiscoveredRoute[]>}
|
|
17
|
+
*/
|
|
18
|
+
async discover() {
|
|
19
|
+
// First, inject history interception
|
|
20
|
+
await this.injectHistoryInterception();
|
|
21
|
+
|
|
22
|
+
// Get routes from multiple sources
|
|
23
|
+
const rawRoutes = await this.page.evaluate(() => {
|
|
24
|
+
const routes = [];
|
|
25
|
+
const seenPaths = new Set();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Add route if not already seen
|
|
29
|
+
*/
|
|
30
|
+
function addRoute(path, name, source) {
|
|
31
|
+
if (!path || seenPaths.has(path)) return;
|
|
32
|
+
if (!path.startsWith('/')) return;
|
|
33
|
+
|
|
34
|
+
// Skip common non-page paths
|
|
35
|
+
const skipPatterns = [
|
|
36
|
+
/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map)$/i,
|
|
37
|
+
/^\/api\//,
|
|
38
|
+
/^\/_next\//,
|
|
39
|
+
/^\/_nuxt\//,
|
|
40
|
+
/^\/static\//,
|
|
41
|
+
/^\/assets\//,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
if (skipPatterns.some(pattern => pattern.test(path))) return;
|
|
45
|
+
|
|
46
|
+
seenPaths.add(path);
|
|
47
|
+
routes.push({
|
|
48
|
+
path,
|
|
49
|
+
name: name || '',
|
|
50
|
+
source
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Method 1: History interception results
|
|
55
|
+
if (window.__UNIVERSAL_DISCOVERED_ROUTES__ && Array.isArray(window.__UNIVERSAL_DISCOVERED_ROUTES__)) {
|
|
56
|
+
window.__UNIVERSAL_DISCOVERED_ROUTES__.forEach(url => {
|
|
57
|
+
try {
|
|
58
|
+
const path = new URL(url, window.location.origin).pathname;
|
|
59
|
+
addRoute(path, '', 'interception');
|
|
60
|
+
} catch {
|
|
61
|
+
// Invalid URL, skip
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Method 2: Navigation elements (high confidence)
|
|
67
|
+
const navSelectors = [
|
|
68
|
+
'nav a[href]',
|
|
69
|
+
'header a[href]',
|
|
70
|
+
'[role="navigation"] a[href]',
|
|
71
|
+
'[class*="nav"] a[href]',
|
|
72
|
+
'[class*="menu"] a[href]',
|
|
73
|
+
'[class*="sidebar"] a[href]',
|
|
74
|
+
'footer a[href]'
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
navSelectors.forEach(selector => {
|
|
78
|
+
document.querySelectorAll(selector).forEach(link => {
|
|
79
|
+
const href = link.getAttribute('href');
|
|
80
|
+
if (href && href.startsWith('/')) {
|
|
81
|
+
addRoute(href, link.textContent?.trim() || '', 'link-scrape');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Method 3: All internal links (lower confidence but comprehensive)
|
|
87
|
+
document.querySelectorAll('a[href^="/"]').forEach(link => {
|
|
88
|
+
const href = link.getAttribute('href');
|
|
89
|
+
if (href) {
|
|
90
|
+
// Skip if has target="_blank" or download attribute
|
|
91
|
+
if (link.hasAttribute('download')) return;
|
|
92
|
+
if (link.getAttribute('target') === '_blank') return;
|
|
93
|
+
|
|
94
|
+
addRoute(href, link.textContent?.trim() || '', 'link-scrape');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Method 4: Links in main content area
|
|
99
|
+
const mainSelectors = ['main', '[role="main"]', '#content', '.content', 'article'];
|
|
100
|
+
mainSelectors.forEach(selector => {
|
|
101
|
+
const main = document.querySelector(selector);
|
|
102
|
+
if (main) {
|
|
103
|
+
main.querySelectorAll('a[href^="/"]').forEach(link => {
|
|
104
|
+
const href = link.getAttribute('href');
|
|
105
|
+
if (href && !link.hasAttribute('download')) {
|
|
106
|
+
addRoute(href, link.textContent?.trim() || '', 'link-scrape');
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return routes;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Try to fetch sitemap
|
|
116
|
+
const sitemapRoutes = await this.fetchSitemapRoutes();
|
|
117
|
+
|
|
118
|
+
// Combine all routes
|
|
119
|
+
const allRoutes = [...rawRoutes, ...sitemapRoutes];
|
|
120
|
+
|
|
121
|
+
const processedRoutes = allRoutes.map(route => ({
|
|
122
|
+
...route,
|
|
123
|
+
name: route.name || this.extractPageName(route.path),
|
|
124
|
+
path: this.normalizeRoute(route.path)
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
return this.deduplicateRoutes(processedRoutes);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Inject history.pushState/replaceState interception
|
|
132
|
+
*/
|
|
133
|
+
async injectHistoryInterception() {
|
|
134
|
+
try {
|
|
135
|
+
await this.page.evaluate(() => {
|
|
136
|
+
if (window.__UNIVERSAL_INTERCEPTION_ACTIVE__) return;
|
|
137
|
+
|
|
138
|
+
window.__UNIVERSAL_DISCOVERED_ROUTES__ = [];
|
|
139
|
+
window.__UNIVERSAL_INTERCEPTION_ACTIVE__ = true;
|
|
140
|
+
|
|
141
|
+
// Intercept pushState
|
|
142
|
+
const originalPushState = history.pushState.bind(history);
|
|
143
|
+
history.pushState = function(state, title, url) {
|
|
144
|
+
if (url) {
|
|
145
|
+
window.__UNIVERSAL_DISCOVERED_ROUTES__.push(url.toString());
|
|
146
|
+
}
|
|
147
|
+
return originalPushState(state, title, url);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Intercept replaceState
|
|
151
|
+
const originalReplaceState = history.replaceState.bind(history);
|
|
152
|
+
history.replaceState = function(state, title, url) {
|
|
153
|
+
if (url) {
|
|
154
|
+
window.__UNIVERSAL_DISCOVERED_ROUTES__.push(url.toString());
|
|
155
|
+
}
|
|
156
|
+
return originalReplaceState(state, title, url);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Listen for popstate
|
|
160
|
+
window.addEventListener('popstate', () => {
|
|
161
|
+
window.__UNIVERSAL_DISCOVERED_ROUTES__.push(window.location.pathname);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Listen for hashchange (for hash-based routing)
|
|
165
|
+
window.addEventListener('hashchange', () => {
|
|
166
|
+
window.__UNIVERSAL_DISCOVERED_ROUTES__.push(window.location.href);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
} catch {
|
|
170
|
+
// Interception may fail in some browser contexts, continue without it
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Try to fetch and parse sitemap.xml
|
|
176
|
+
* @returns {Promise<import('./base-discoverer.js').DiscoveredRoute[]>}
|
|
177
|
+
*/
|
|
178
|
+
async fetchSitemapRoutes() {
|
|
179
|
+
const routes = [];
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const sitemapUrl = new URL('/sitemap.xml', this.baseUrl).href;
|
|
183
|
+
|
|
184
|
+
const response = await this.page.evaluate(async (url) => {
|
|
185
|
+
try {
|
|
186
|
+
// Add timeout using AbortController
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
|
189
|
+
|
|
190
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
191
|
+
clearTimeout(timeoutId);
|
|
192
|
+
|
|
193
|
+
if (!res.ok) return null;
|
|
194
|
+
return await res.text();
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}, sitemapUrl);
|
|
199
|
+
|
|
200
|
+
if (response) {
|
|
201
|
+
// Parse sitemap XML
|
|
202
|
+
const urlMatches = response.matchAll(/<loc>([^<]+)<\/loc>/gi);
|
|
203
|
+
for (const match of urlMatches) {
|
|
204
|
+
try {
|
|
205
|
+
const url = new URL(match[1]);
|
|
206
|
+
// Only include paths from same origin
|
|
207
|
+
if (url.origin === new URL(this.baseUrl).origin) {
|
|
208
|
+
routes.push({
|
|
209
|
+
path: url.pathname,
|
|
210
|
+
name: '',
|
|
211
|
+
source: 'sitemap'
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
} catch {
|
|
215
|
+
// Invalid URL in sitemap
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// Sitemap fetch failed, continue without it
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return routes;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default UniversalDiscoverer;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vue Route Discoverer
|
|
3
|
+
*
|
|
4
|
+
* Extracts routes from Vue 2 and Vue 3 applications using:
|
|
5
|
+
* - Vue 3: app.__vue_app__.$router
|
|
6
|
+
* - Vue 2: window.Vue.prototype.$router
|
|
7
|
+
* - Fallback: data-v-* attributes and router-link elements
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { BaseDiscoverer } from './base-discoverer.js';
|
|
11
|
+
|
|
12
|
+
export class VueDiscoverer extends BaseDiscoverer {
|
|
13
|
+
/**
|
|
14
|
+
* Discover routes from a Vue application
|
|
15
|
+
* @returns {Promise<import('./base-discoverer.js').DiscoveredRoute[]>}
|
|
16
|
+
*/
|
|
17
|
+
async discover() {
|
|
18
|
+
const rawRoutes = await this.page.evaluate(() => {
|
|
19
|
+
const routes = [];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recursively extract routes from Vue Router config
|
|
23
|
+
*/
|
|
24
|
+
function extractRoutes(routeList, prefix = '') {
|
|
25
|
+
if (!Array.isArray(routeList)) return;
|
|
26
|
+
|
|
27
|
+
routeList.forEach(r => {
|
|
28
|
+
if (!r.path && r.path !== '') return;
|
|
29
|
+
|
|
30
|
+
let path = r.path;
|
|
31
|
+
if (!path.startsWith('/') && prefix) {
|
|
32
|
+
path = prefix + (prefix.endsWith('/') ? '' : '/') + path;
|
|
33
|
+
} else if (!path.startsWith('/') && path !== '') {
|
|
34
|
+
path = '/' + path;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Handle root path
|
|
38
|
+
if (path === '') path = '/';
|
|
39
|
+
|
|
40
|
+
routes.push({
|
|
41
|
+
path,
|
|
42
|
+
name: r.name || '',
|
|
43
|
+
component: r.component?.name || r.name || '',
|
|
44
|
+
source: 'framework'
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (r.children) {
|
|
48
|
+
extractRoutes(r.children, path);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Method 1: Vue 3 - __vue_app__ on root element
|
|
54
|
+
const appElements = document.querySelectorAll('[data-v-app], #app, #__nuxt');
|
|
55
|
+
for (const el of appElements) {
|
|
56
|
+
const vueApp = el.__vue_app__;
|
|
57
|
+
if (vueApp?.config?.globalProperties?.$router?.options?.routes) {
|
|
58
|
+
extractRoutes(vueApp.config.globalProperties.$router.options.routes);
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Method 2: Vue 2 - window.Vue.prototype.$router
|
|
64
|
+
if (routes.length === 0 && window.Vue?.prototype?.$router?.options?.routes) {
|
|
65
|
+
extractRoutes(window.Vue.prototype.$router.options.routes);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Method 3: __VUE_ROUTER__ global (some configurations)
|
|
69
|
+
if (routes.length === 0 && window.__VUE_ROUTER__?.options?.routes) {
|
|
70
|
+
extractRoutes(window.__VUE_ROUTER__.options.routes);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Method 4: router-link elements
|
|
74
|
+
document.querySelectorAll('router-link, a[href]').forEach(link => {
|
|
75
|
+
let href = link.getAttribute('to') || link.getAttribute('href');
|
|
76
|
+
if (href && href.startsWith('/')) {
|
|
77
|
+
const text = link.textContent?.trim();
|
|
78
|
+
const isVueComponent = link.tagName.toLowerCase() === 'router-link' ||
|
|
79
|
+
link.closest('[data-v-]');
|
|
80
|
+
|
|
81
|
+
if (!routes.some(r => r.path === href)) {
|
|
82
|
+
routes.push({
|
|
83
|
+
path: href,
|
|
84
|
+
name: text || '',
|
|
85
|
+
source: isVueComponent ? 'framework' : 'link-scrape'
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Method 5: Links with data-v-* attributes (scoped styles indicate Vue components)
|
|
92
|
+
if (routes.length === 0) {
|
|
93
|
+
document.querySelectorAll('nav a, header a, [role="navigation"] a').forEach(link => {
|
|
94
|
+
const href = link.getAttribute('href');
|
|
95
|
+
if (href && href.startsWith('/')) {
|
|
96
|
+
routes.push({
|
|
97
|
+
path: href,
|
|
98
|
+
name: link.textContent?.trim() || '',
|
|
99
|
+
source: 'link-scrape'
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return routes;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const processedRoutes = rawRoutes.map(route => ({
|
|
109
|
+
...route,
|
|
110
|
+
name: route.name || this.extractPageName(route.path, route.component),
|
|
111
|
+
path: this.normalizeRoute(route.path)
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
return this.deduplicateRoutes(processedRoutes);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default VueDiscoverer;
|
package/src/utils/__init__.py
CHANGED
|
@@ -3,7 +3,7 @@ Design Clone skill library modules.
|
|
|
3
3
|
|
|
4
4
|
JavaScript modules:
|
|
5
5
|
- browser.js: Browser abstraction facade
|
|
6
|
-
-
|
|
6
|
+
- playwright.js: Playwright browser wrapper
|
|
7
7
|
- utils.js: CLI utilities
|
|
8
8
|
- env.js: Environment variable resolution
|
|
9
9
|
|
|
Binary file
|
package/src/utils/browser.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser abstraction facade for design-clone scripts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1. chrome-devtools skill (if installed) - Preferred
|
|
6
|
-
* 2. Standalone puppeteer wrapper - Fallback
|
|
4
|
+
* Uses Playwright wrapper for browser automation.
|
|
7
5
|
*
|
|
8
|
-
* Exports same API
|
|
6
|
+
* Exports same API:
|
|
9
7
|
* - getBrowser(options)
|
|
10
8
|
* - getPage(browser)
|
|
11
9
|
* - closeBrowser()
|
|
@@ -15,18 +13,6 @@
|
|
|
15
13
|
* - outputError(error)
|
|
16
14
|
*/
|
|
17
15
|
|
|
18
|
-
import fs from 'fs';
|
|
19
|
-
import path from 'path';
|
|
20
|
-
import { fileURLToPath } from 'url';
|
|
21
|
-
|
|
22
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
-
|
|
24
|
-
// Chrome DevTools skill path
|
|
25
|
-
const CHROME_DEVTOOLS_PATH = path.join(
|
|
26
|
-
process.env.HOME,
|
|
27
|
-
'.claude/skills/chrome-devtools/scripts/lib/browser.js'
|
|
28
|
-
);
|
|
29
|
-
|
|
30
16
|
let browserModule = null;
|
|
31
17
|
let providerName = 'unknown';
|
|
32
18
|
|
|
@@ -36,22 +22,9 @@ let providerName = 'unknown';
|
|
|
36
22
|
async function initProvider() {
|
|
37
23
|
if (browserModule) return;
|
|
38
24
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
browserModule = await import(CHROME_DEVTOOLS_PATH);
|
|
43
|
-
providerName = 'chrome-devtools';
|
|
44
|
-
console.error('[browser] Using chrome-devtools skill');
|
|
45
|
-
return;
|
|
46
|
-
} catch (e) {
|
|
47
|
-
console.error('[browser] chrome-devtools found but failed to load:', e.message);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Fall back to standalone puppeteer wrapper
|
|
52
|
-
browserModule = await import('./puppeteer.js');
|
|
53
|
-
providerName = 'standalone';
|
|
54
|
-
console.error('[browser] Using standalone puppeteer wrapper');
|
|
25
|
+
browserModule = await import('./playwright.js');
|
|
26
|
+
providerName = 'playwright';
|
|
27
|
+
console.error('[browser] Using Playwright wrapper');
|
|
55
28
|
}
|
|
56
29
|
|
|
57
30
|
// Import utilities (always use local helpers)
|
|
@@ -60,7 +33,7 @@ export { parseArgs, outputJSON, outputError };
|
|
|
60
33
|
|
|
61
34
|
/**
|
|
62
35
|
* Get current browser provider name
|
|
63
|
-
* @returns {string} '
|
|
36
|
+
* @returns {string} 'playwright'
|
|
64
37
|
*/
|
|
65
38
|
export function getProviderName() {
|
|
66
39
|
return providerName;
|
|
@@ -79,15 +52,16 @@ export async function getBrowser(options = {}) {
|
|
|
79
52
|
/**
|
|
80
53
|
* Get page from browser
|
|
81
54
|
* @param {Browser} browser - Browser instance
|
|
55
|
+
* @param {Object} [options] - Page options
|
|
82
56
|
* @returns {Promise<Page>} Page instance
|
|
83
57
|
*/
|
|
84
|
-
export async function getPage(browser) {
|
|
58
|
+
export async function getPage(browser, options = {}) {
|
|
85
59
|
await initProvider();
|
|
86
|
-
return browserModule.getPage(browser);
|
|
60
|
+
return browserModule.getPage(browser, options);
|
|
87
61
|
}
|
|
88
62
|
|
|
89
63
|
/**
|
|
90
|
-
* Close browser
|
|
64
|
+
* Close browser
|
|
91
65
|
*/
|
|
92
66
|
export async function closeBrowser() {
|
|
93
67
|
await initProvider();
|
|
@@ -95,7 +69,7 @@ export async function closeBrowser() {
|
|
|
95
69
|
}
|
|
96
70
|
|
|
97
71
|
/**
|
|
98
|
-
* Disconnect from browser
|
|
72
|
+
* Disconnect from browser (alias for close in Playwright)
|
|
99
73
|
*/
|
|
100
74
|
export async function disconnectBrowser() {
|
|
101
75
|
await initProvider();
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone Playwright browser wrapper for design-clone scripts
|
|
3
|
+
* Provides browser automation with Playwright
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - Auto-detects Chrome installation path (macOS, Linux, Windows)
|
|
7
|
+
* - Fast browser launch (no session persistence needed)
|
|
8
|
+
* - Compatible API with previous Puppeteer wrapper
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
|
|
13
|
+
/** @type {import('playwright').Browser|null} */
|
|
14
|
+
let browserInstance = null;
|
|
15
|
+
/** @type {import('playwright').Page|null} */
|
|
16
|
+
let pageInstance = null;
|
|
17
|
+
/** @type {typeof import('playwright')|null} */
|
|
18
|
+
let playwright = null;
|
|
19
|
+
|
|
20
|
+
/** Default viewport dimensions */
|
|
21
|
+
const DEFAULT_VIEWPORT = { width: 1920, height: 1080 };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detect Chrome executable path by platform
|
|
25
|
+
* Used for playwright-core fallback when full playwright is not installed
|
|
26
|
+
* @returns {string|null} Chrome path or null if not found
|
|
27
|
+
*/
|
|
28
|
+
function detectChromePath() {
|
|
29
|
+
const platform = process.platform;
|
|
30
|
+
|
|
31
|
+
const paths = {
|
|
32
|
+
darwin: [
|
|
33
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
34
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
35
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary'
|
|
36
|
+
],
|
|
37
|
+
linux: [
|
|
38
|
+
'/usr/bin/google-chrome',
|
|
39
|
+
'/usr/bin/google-chrome-stable',
|
|
40
|
+
'/usr/bin/chromium',
|
|
41
|
+
'/usr/bin/chromium-browser',
|
|
42
|
+
'/snap/bin/chromium'
|
|
43
|
+
],
|
|
44
|
+
win32: [
|
|
45
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
46
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
47
|
+
...(process.env.LOCALAPPDATA ? [`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`] : [])
|
|
48
|
+
]
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const candidates = paths[platform] || [];
|
|
52
|
+
for (const chromePath of candidates) {
|
|
53
|
+
if (fs.existsSync(chromePath)) {
|
|
54
|
+
return chromePath;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load playwright module (try playwright first, then playwright-core)
|
|
63
|
+
* @returns {Promise<Object>} Playwright module with chromium browser type
|
|
64
|
+
* @throws {Error} If neither playwright nor playwright-core is installed
|
|
65
|
+
*/
|
|
66
|
+
async function loadPlaywright() {
|
|
67
|
+
if (playwright) return playwright;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// Try full playwright first (includes bundled browsers)
|
|
71
|
+
playwright = await import('playwright');
|
|
72
|
+
return playwright;
|
|
73
|
+
} catch (e1) {
|
|
74
|
+
try {
|
|
75
|
+
// Fall back to playwright-core (requires Chrome)
|
|
76
|
+
playwright = await import('playwright-core');
|
|
77
|
+
return playwright;
|
|
78
|
+
} catch (e2) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
'Playwright not found. Install with: npm install playwright\n' +
|
|
81
|
+
'Or for smaller install: npm install playwright-core\n' +
|
|
82
|
+
`Details: playwright: ${e1.message}, playwright-core: ${e2.message}`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Launch browser instance
|
|
90
|
+
*
|
|
91
|
+
* @param {Object} options - Browser options
|
|
92
|
+
* @param {boolean} [options.headless=true] - Run in headless mode
|
|
93
|
+
* @param {Object} [options.viewport] - Default viewport dimensions (applied per context)
|
|
94
|
+
* @param {string} [options.executablePath] - Chrome executable path override
|
|
95
|
+
* @param {string[]} [options.args] - Additional Chrome arguments
|
|
96
|
+
* @returns {Promise<Browser>} Playwright browser instance
|
|
97
|
+
* @throws {Error} If Chrome not found and no executablePath provided (playwright-core)
|
|
98
|
+
*/
|
|
99
|
+
export async function getBrowser(options = {}) {
|
|
100
|
+
const pw = await loadPlaywright();
|
|
101
|
+
|
|
102
|
+
// Reuse existing browser if connected
|
|
103
|
+
if (browserInstance && browserInstance.isConnected()) {
|
|
104
|
+
return browserInstance;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Determine executable path for playwright-core
|
|
108
|
+
let executablePath = options.executablePath;
|
|
109
|
+
if (!executablePath) {
|
|
110
|
+
// Check if we're using playwright-core (no bundled browser)
|
|
111
|
+
const isCore = !pw.chromium?.executablePath;
|
|
112
|
+
if (isCore) {
|
|
113
|
+
executablePath = detectChromePath();
|
|
114
|
+
if (!executablePath) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'Chrome not found. Either:\n' +
|
|
117
|
+
'1. Install Google Chrome\n' +
|
|
118
|
+
'2. Use full playwright (npm install playwright)\n' +
|
|
119
|
+
'3. Set executablePath option'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Build launch options
|
|
126
|
+
const launchOptions = {
|
|
127
|
+
headless: options.headless !== false,
|
|
128
|
+
args: [
|
|
129
|
+
'--no-sandbox',
|
|
130
|
+
'--disable-setuid-sandbox',
|
|
131
|
+
'--disable-dev-shm-usage',
|
|
132
|
+
...(options.args || [])
|
|
133
|
+
]
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Only set executablePath if needed (playwright-core or override)
|
|
137
|
+
if (executablePath) {
|
|
138
|
+
launchOptions.executablePath = executablePath;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Launch browser
|
|
142
|
+
browserInstance = await pw.chromium.launch(launchOptions);
|
|
143
|
+
console.error('[browser] Launched Playwright browser');
|
|
144
|
+
|
|
145
|
+
return browserInstance;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get current page or create new one
|
|
150
|
+
* Reuses existing page if available
|
|
151
|
+
*
|
|
152
|
+
* @param {import('playwright').Browser} browser - Playwright browser instance
|
|
153
|
+
* @param {Object} [options] - Page options
|
|
154
|
+
* @param {{width: number, height: number}} [options.viewport] - Viewport dimensions
|
|
155
|
+
* @returns {Promise<import('playwright').Page>} Playwright page instance
|
|
156
|
+
* @throws {Error} If browser is null or disconnected
|
|
157
|
+
*/
|
|
158
|
+
export async function getPage(browser, options = {}) {
|
|
159
|
+
if (!browser || !browser.isConnected()) {
|
|
160
|
+
throw new Error('Browser not connected. Call getBrowser() first.');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (pageInstance && !pageInstance.isClosed()) {
|
|
164
|
+
return pageInstance;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Get existing pages or create new context + page
|
|
168
|
+
const contexts = browser.contexts();
|
|
169
|
+
if (contexts.length > 0) {
|
|
170
|
+
const pages = contexts[0].pages();
|
|
171
|
+
if (pages.length > 0) {
|
|
172
|
+
pageInstance = pages[0];
|
|
173
|
+
return pageInstance;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Create new context with default viewport
|
|
178
|
+
const contextOptions = {
|
|
179
|
+
viewport: options.viewport || DEFAULT_VIEWPORT
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const context = await browser.newContext(contextOptions);
|
|
183
|
+
pageInstance = await context.newPage();
|
|
184
|
+
|
|
185
|
+
return pageInstance;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Close browser
|
|
190
|
+
* Use when completely done with browser
|
|
191
|
+
*/
|
|
192
|
+
export async function closeBrowser() {
|
|
193
|
+
if (browserInstance) {
|
|
194
|
+
try {
|
|
195
|
+
await browserInstance.close();
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.error(`[browser] Error closing browser: ${err.message}`);
|
|
198
|
+
}
|
|
199
|
+
browserInstance = null;
|
|
200
|
+
pageInstance = null;
|
|
201
|
+
console.error('[browser] Closed browser');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Disconnect from browser (alias for closeBrowser in Playwright)
|
|
207
|
+
* Playwright doesn't support disconnect without close, so this is an alias
|
|
208
|
+
*/
|
|
209
|
+
export async function disconnectBrowser() {
|
|
210
|
+
// Playwright doesn't have disconnect concept like Puppeteer
|
|
211
|
+
// Just close the browser for API compatibility
|
|
212
|
+
return closeBrowser();
|
|
213
|
+
}
|