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.
Files changed (66) hide show
  1. package/README.md +26 -12
  2. package/bin/commands/clone-site.js +75 -10
  3. package/bin/commands/init.js +33 -1
  4. package/bin/commands/verify.js +5 -3
  5. package/bin/utils/validate.js +24 -8
  6. package/docs/cli-reference.md +200 -2
  7. package/docs/codebase-summary.md +309 -0
  8. package/docs/design-clone-architecture.md +259 -42
  9. package/docs/pixel-perfect.md +35 -4
  10. package/docs/project-roadmap.md +382 -0
  11. package/docs/troubleshooting.md +5 -4
  12. package/package.json +10 -8
  13. package/src/ai/__pycache__/analyze-structure.cpython-313.pyc +0 -0
  14. package/src/ai/__pycache__/extract-design-tokens.cpython-313.pyc +0 -0
  15. package/src/ai/analyze-structure.py +73 -3
  16. package/src/ai/extract-design-tokens.py +356 -13
  17. package/src/ai/prompts/__pycache__/design_tokens.cpython-313.pyc +0 -0
  18. package/src/ai/prompts/__pycache__/structure_analysis.cpython-313.pyc +0 -0
  19. package/src/ai/prompts/__pycache__/ux_audit.cpython-313.pyc +0 -0
  20. package/src/ai/prompts/design_tokens.py +133 -0
  21. package/src/ai/prompts/structure_analysis.py +329 -10
  22. package/src/ai/prompts/ux_audit.py +198 -0
  23. package/src/ai/ux-audit.js +596 -0
  24. package/src/core/app-state-snapshot.js +511 -0
  25. package/src/core/content-counter.js +342 -0
  26. package/src/core/cookie-handler.js +1 -1
  27. package/src/core/css-extractor.js +4 -4
  28. package/src/core/dimension-extractor.js +93 -21
  29. package/src/core/dimension-output.js +103 -6
  30. package/src/core/discover-pages.js +242 -14
  31. package/src/core/dom-tree-analyzer.js +298 -0
  32. package/src/core/extract-assets.js +1 -1
  33. package/src/core/framework-detector.js +538 -0
  34. package/src/core/html-extractor.js +45 -4
  35. package/src/core/lazy-loader.js +7 -7
  36. package/src/core/multi-page-screenshot.js +9 -6
  37. package/src/core/page-readiness.js +8 -8
  38. package/src/core/screenshot.js +138 -9
  39. package/src/core/section-cropper.js +209 -0
  40. package/src/core/section-detector.js +386 -0
  41. package/src/core/semantic-enhancer.js +492 -0
  42. package/src/core/state-capture.js +18 -22
  43. package/src/core/tests/test-section-cropper.js +177 -0
  44. package/src/core/tests/test-section-detector.js +55 -0
  45. package/src/core/video-capture.js +152 -146
  46. package/src/route-discoverers/angular-discoverer.js +157 -0
  47. package/src/route-discoverers/astro-discoverer.js +123 -0
  48. package/src/route-discoverers/base-discoverer.js +242 -0
  49. package/src/route-discoverers/index.js +106 -0
  50. package/src/route-discoverers/next-discoverer.js +130 -0
  51. package/src/route-discoverers/nuxt-discoverer.js +138 -0
  52. package/src/route-discoverers/react-discoverer.js +139 -0
  53. package/src/route-discoverers/svelte-discoverer.js +109 -0
  54. package/src/route-discoverers/universal-discoverer.js +227 -0
  55. package/src/route-discoverers/vue-discoverer.js +118 -0
  56. package/src/utils/__init__.py +1 -1
  57. package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
  58. package/src/utils/browser.js +11 -37
  59. package/src/utils/playwright.js +213 -0
  60. package/src/verification/generate-audit-report.js +398 -0
  61. package/src/verification/verify-footer.js +493 -0
  62. package/src/verification/verify-header.js +486 -0
  63. package/src/verification/verify-layout.js +2 -2
  64. package/src/verification/verify-menu.js +4 -20
  65. package/src/verification/verify-slider.js +533 -0
  66. 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;
@@ -3,7 +3,7 @@ Design Clone skill library modules.
3
3
 
4
4
  JavaScript modules:
5
5
  - browser.js: Browser abstraction facade
6
- - puppeteer.js: Standalone Puppeteer wrapper
6
+ - playwright.js: Playwright browser wrapper
7
7
  - utils.js: CLI utilities
8
8
  - env.js: Environment variable resolution
9
9
 
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * Browser abstraction facade for design-clone scripts
3
3
  *
4
- * Auto-detects and uses:
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 regardless of provider:
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
- // Check for chrome-devtools skill
40
- if (fs.existsSync(CHROME_DEVTOOLS_PATH)) {
41
- try {
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} 'chrome-devtools' or 'standalone'
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 and clear session
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 without closing
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
+ }