design-clone 1.2.0 → 2.3.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 +32 -39
- package/SKILL.md +69 -45
- package/bin/cli.js +22 -4
- package/bin/commands/clone-site.js +31 -106
- package/bin/commands/help.js +19 -6
- package/bin/commands/init.js +11 -56
- package/bin/commands/uninstall.js +105 -0
- package/bin/commands/update.js +70 -0
- package/bin/commands/verify.js +11 -16
- package/bin/utils/paths.js +28 -0
- package/bin/utils/validate.js +24 -28
- package/bin/utils/version.js +23 -0
- package/docs/code-standards.md +789 -0
- package/docs/codebase-summary.md +556 -0
- package/docs/index.md +74 -0
- package/docs/project-overview-pdr.md +797 -0
- package/docs/system-architecture.md +718 -0
- package/package.json +20 -21
- package/src/ai/prompts/design-tokens/basic.md +80 -0
- package/src/ai/prompts/design-tokens/section-with-css.md +41 -0
- package/src/ai/prompts/design-tokens/section.md +48 -0
- package/src/ai/prompts/design-tokens/with-css.md +87 -0
- package/src/ai/prompts/structure-analysis/basic.md +55 -0
- package/src/ai/prompts/structure-analysis/with-context.md +59 -0
- package/src/ai/prompts/structure-analysis/with-dimensions.md +63 -0
- package/src/ai/prompts/structure-analysis/with-hierarchy.md +73 -0
- package/src/ai/prompts/ux-audit/aggregation.md +42 -0
- package/src/ai/prompts/ux-audit/desktop.md +92 -0
- package/src/ai/prompts/ux-audit/mobile.md +93 -0
- package/src/ai/prompts/ux-audit/tablet.md +92 -0
- package/src/core/animation/animation-extractor-ast.js +183 -0
- package/src/core/animation/animation-extractor-output.js +152 -0
- package/src/core/animation/animation-extractor.js +178 -0
- package/src/core/animation/state-capture-detection.js +200 -0
- package/src/core/animation/state-capture.js +193 -0
- package/src/core/capture/browser-context-pool.js +96 -0
- package/src/core/capture/multi-page-screenshot-page.js +110 -0
- package/src/core/capture/multi-page-screenshot.js +208 -0
- package/src/core/capture/screenshot-extraction.js +186 -0
- package/src/core/capture/screenshot-helpers.js +175 -0
- package/src/core/capture/screenshot-orchestrator.js +174 -0
- package/src/core/capture/screenshot-viewport.js +93 -0
- package/src/core/capture/screenshot.js +192 -0
- package/src/core/content/content-counter-dom.js +191 -0
- package/src/core/content/content-counter.js +76 -0
- package/src/core/css/breakpoint-detector.js +66 -0
- package/src/core/css/chromium-defaults.json +23 -0
- package/src/core/css/computed-style-extractor.js +102 -0
- package/src/core/css/css-chunker.js +103 -0
- package/src/core/{css-extractor.js → css/css-extractor.js} +4 -4
- package/src/core/css/filter-css-dead-code.js +120 -0
- package/src/core/css/filter-css-html-analyzer.js +110 -0
- package/src/core/css/filter-css-selector-matcher.js +172 -0
- package/src/core/css/filter-css.js +206 -0
- package/src/core/css/merge-css-atrule-processor.js +158 -0
- package/src/core/css/merge-css-file-io.js +68 -0
- package/src/core/css/merge-css.js +148 -0
- package/src/core/detection/framework-detector-routing.js +68 -0
- package/src/core/detection/framework-detector-signals.js +65 -0
- package/src/core/detection/framework-detector.js +198 -0
- package/src/core/dimension/dimension-extractor-card-detector.js +82 -0
- package/src/core/dimension/dimension-extractor.js +317 -0
- package/src/core/dimension/dimension-output-ai-summary.js +111 -0
- package/src/core/dimension/dimension-output.js +173 -0
- package/src/core/dimension/dom-tree-analyzer-tree-builders.js +95 -0
- package/src/core/dimension/dom-tree-analyzer.js +191 -0
- package/src/core/discovery/app-state-snapshot-capture.js +195 -0
- package/src/core/discovery/app-state-snapshot-utils.js +178 -0
- package/src/core/discovery/app-state-snapshot.js +131 -0
- package/src/core/discovery/discover-pages-routes.js +84 -0
- package/src/core/discovery/discover-pages-utils.js +177 -0
- package/src/core/discovery/discover-pages.js +191 -0
- package/src/core/html/html-extractor-inline-styler.js +70 -0
- package/src/core/html/html-extractor.js +147 -0
- package/src/core/html/semantic-enhancer-mappings.js +200 -0
- package/src/core/html/semantic-enhancer-page.js +148 -0
- package/src/core/html/semantic-enhancer.js +135 -0
- package/src/core/links/rewrite-links-css-rewriter.js +53 -0
- package/src/core/links/rewrite-links.js +173 -0
- package/src/core/media/asset-validator.js +118 -0
- package/src/core/media/extract-assets-downloader.js +187 -0
- package/src/core/media/extract-assets-page-scraper.js +115 -0
- package/src/core/media/extract-assets.js +159 -0
- package/src/core/media/video-capture-convert.js +200 -0
- package/src/core/media/video-capture.js +201 -0
- package/src/core/{cookie-handler.js → page-prep/cookie-handler.js} +1 -1
- package/src/core/{lazy-loader.js → page-prep/lazy-loader.js} +44 -46
- package/src/core/{page-readiness.js → page-prep/page-readiness.js} +8 -8
- package/src/core/section/section-cropper-helpers.js +43 -0
- package/src/core/section/section-cropper.js +132 -0
- package/src/core/section/section-detector-strategies.js +139 -0
- package/src/core/section/section-detector-utils.js +100 -0
- package/src/core/section/section-detector.js +88 -0
- package/src/core/tests/test-section-cropper.js +177 -0
- package/src/core/tests/test-section-detector.js +55 -0
- package/src/post-process/enhance-assets.js +29 -4
- package/src/post-process/fetch-images-unsplash-client.js +123 -0
- package/src/post-process/fetch-images.js +60 -263
- package/src/post-process/inject-gosnap.js +88 -0
- package/src/post-process/inject-icons-svg-replacer.js +76 -0
- package/src/post-process/inject-icons.js +47 -200
- package/src/route-discoverers/angular-discoverer.js +157 -0
- package/src/route-discoverers/astro-discoverer.js +123 -0
- package/src/route-discoverers/base-discoverer-utils.js +137 -0
- package/src/route-discoverers/base-discoverer.js +153 -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/shared/config.js +38 -0
- package/src/shared/error-codes.js +31 -0
- package/src/shared/viewports.js +46 -0
- package/src/utils/browser.js +11 -44
- package/src/utils/helpers.js +4 -0
- package/src/utils/log.js +12 -0
- package/src/utils/playwright-loader.js +76 -0
- package/src/utils/playwright.js +147 -0
- package/src/utils/progress.js +32 -0
- package/src/verification/generate-audit-report-css-fixes.js +52 -0
- package/src/verification/generate-audit-report-sections.js +158 -0
- package/src/verification/generate-audit-report.js +122 -0
- package/src/verification/quality-scorer.js +92 -0
- package/src/verification/verify-footer-checks.js +103 -0
- package/src/verification/verify-footer-helpers.js +178 -0
- package/src/verification/verify-footer.js +135 -0
- package/src/verification/verify-header-checks.js +104 -0
- package/src/verification/verify-header-helpers.js +156 -0
- package/src/verification/verify-header.js +144 -0
- package/src/verification/verify-layout-report.js +101 -0
- package/src/verification/verify-layout.js +14 -260
- package/src/verification/verify-menu-checks.js +104 -0
- package/src/verification/verify-menu-helpers.js +112 -0
- package/src/verification/verify-menu.js +18 -302
- package/src/verification/verify-slider-checks.js +115 -0
- package/src/verification/verify-slider-constants.js +65 -0
- package/src/verification/verify-slider-helpers.js +164 -0
- package/src/verification/verify-slider.js +142 -0
- package/.env.example +0 -14
- package/docs/basic-clone.md +0 -63
- package/docs/cli-reference.md +0 -118
- package/docs/design-clone-architecture.md +0 -275
- package/docs/pixel-perfect.md +0 -86
- package/docs/troubleshooting.md +0 -169
- package/requirements.txt +0 -5
- package/src/ai/analyze-structure.py +0 -305
- package/src/ai/extract-design-tokens.py +0 -439
- package/src/ai/prompts/__init__.py +0 -2
- package/src/ai/prompts/__pycache__/__init__.cpython-313.pyc +0 -0
- 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/design_tokens.py +0 -183
- package/src/ai/prompts/structure_analysis.py +0 -273
- package/src/core/animation-extractor.js +0 -526
- package/src/core/design-tokens.js +0 -103
- package/src/core/dimension-extractor.js +0 -366
- package/src/core/dimension-output.js +0 -208
- package/src/core/discover-pages.js +0 -314
- package/src/core/extract-assets.js +0 -468
- package/src/core/filter-css.js +0 -499
- package/src/core/html-extractor.js +0 -171
- package/src/core/merge-css.js +0 -407
- package/src/core/multi-page-screenshot.js +0 -377
- package/src/core/rewrite-links.js +0 -226
- package/src/core/screenshot.js +0 -572
- package/src/core/state-capture.js +0 -602
- package/src/core/video-capture.js +0 -540
- package/src/utils/__init__.py +0 -16
- package/src/utils/__pycache__/__init__.cpython-313.pyc +0 -0
- package/src/utils/__pycache__/env.cpython-313.pyc +0 -0
- package/src/utils/env.py +0 -134
- package/src/utils/puppeteer.js +0 -281
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM Tree Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Traverse DOM tree hierarchically to capture structure,
|
|
5
|
+
* semantic landmarks, and parent-child relationships.
|
|
6
|
+
*
|
|
7
|
+
* Key features:
|
|
8
|
+
* - PreOrder traversal (parent before children)
|
|
9
|
+
* - W3C landmark detection (header, main, footer, nav, aside)
|
|
10
|
+
* - Section context mapping (hero, content, sidebar, footer)
|
|
11
|
+
* - Bidirectional parent-child refs
|
|
12
|
+
* - Configurable max depth (default: 8)
|
|
13
|
+
*
|
|
14
|
+
* Post-processing (landmarks map, heading tree, stats) lives in
|
|
15
|
+
* dom-tree-analyzer-tree-builders.js and runs in Node context.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { buildLandmarksMap, buildHeadingTree, countTreeStats } from './dom-tree-analyzer-tree-builders.js';
|
|
19
|
+
|
|
20
|
+
// Constants
|
|
21
|
+
export const MAX_DEPTH = 8;
|
|
22
|
+
export const LANDMARK_TAGS = ['header', 'main', 'footer', 'nav', 'aside', 'section', 'article'];
|
|
23
|
+
export const HEADING_TAGS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
|
|
24
|
+
|
|
25
|
+
// Section detection thresholds
|
|
26
|
+
const HERO_THRESHOLD = 0.15;
|
|
27
|
+
const FOOTER_THRESHOLD = 0.85;
|
|
28
|
+
const SIDEBAR_MAX_WIDTH = 400;
|
|
29
|
+
const Y_POSITION_TOLERANCE = 5;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract DOM tree hierarchy from page.
|
|
33
|
+
* @param {import('playwright').Page} page - Playwright page
|
|
34
|
+
* @param {Object} options
|
|
35
|
+
* @param {number} [options.maxDepth=8] - Maximum traversal depth
|
|
36
|
+
* @param {boolean} [options.includeHidden=false] - Include hidden elements
|
|
37
|
+
* @returns {Promise<Object>} DOMHierarchy with root, landmarks, headingTree, stats
|
|
38
|
+
*/
|
|
39
|
+
export async function extractDOMHierarchy(page, options = {}) {
|
|
40
|
+
const { maxDepth = MAX_DEPTH, includeHidden = false } = options;
|
|
41
|
+
const startTime = Date.now();
|
|
42
|
+
|
|
43
|
+
// Run DOM traversal inside browser context
|
|
44
|
+
const result = await page.evaluate(
|
|
45
|
+
({ maxDepth, includeHidden, LANDMARK_TAGS, HEADING_TAGS, HERO_THRESHOLD, FOOTER_THRESHOLD, SIDEBAR_MAX_WIDTH }) => {
|
|
46
|
+
const pageHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
|
|
47
|
+
const pageWidth = document.documentElement.clientWidth;
|
|
48
|
+
|
|
49
|
+
function detectRole(el) {
|
|
50
|
+
const tag = el.tagName.toLowerCase();
|
|
51
|
+
const ariaRole = el.getAttribute('role');
|
|
52
|
+
if (ariaRole) return ariaRole;
|
|
53
|
+
|
|
54
|
+
if (LANDMARK_TAGS.includes(tag)) {
|
|
55
|
+
const isTopLevel = !el.closest('main, section, article, aside');
|
|
56
|
+
if (tag === 'header' || tag === 'footer') {
|
|
57
|
+
return isTopLevel ? `${tag}-landmark` : `${tag}-section`;
|
|
58
|
+
}
|
|
59
|
+
return tag;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (HEADING_TAGS.includes(tag)) return `heading-${tag.slice(1)}`;
|
|
63
|
+
|
|
64
|
+
if (tag === 'div' || tag === 'span') {
|
|
65
|
+
const cls = (el.className || '').toString().toLowerCase();
|
|
66
|
+
if (cls.includes('container')) return 'container';
|
|
67
|
+
if (cls.includes('wrapper')) return 'wrapper';
|
|
68
|
+
if (cls.includes('card')) return 'card';
|
|
69
|
+
if (cls.includes('grid')) return 'grid';
|
|
70
|
+
if (cls.includes('hero')) return 'hero';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function detectSectionContext(el, yPos) {
|
|
77
|
+
const tag = el.tagName.toLowerCase();
|
|
78
|
+
if (tag === 'header' || el.closest('header')) return 'header';
|
|
79
|
+
if (tag === 'footer' || el.closest('footer')) return 'footer';
|
|
80
|
+
if (tag === 'aside' || el.closest('aside')) return 'sidebar';
|
|
81
|
+
if (tag === 'nav' || el.closest('nav')) return 'nav';
|
|
82
|
+
|
|
83
|
+
const yRatio = yPos / pageHeight;
|
|
84
|
+
if (yRatio < HERO_THRESHOLD) return 'hero';
|
|
85
|
+
if (yRatio > FOOTER_THRESHOLD) return 'footer';
|
|
86
|
+
|
|
87
|
+
const computed = window.getComputedStyle(el);
|
|
88
|
+
const rect = el.getBoundingClientRect();
|
|
89
|
+
if ((computed.position === 'fixed' || computed.position === 'sticky') && rect.width < SIDEBAR_MAX_WIDTH) {
|
|
90
|
+
return 'sidebar';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return 'content';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function traverseDOM(el, depth, parentId, path) {
|
|
97
|
+
if (depth > maxDepth) return null;
|
|
98
|
+
const rect = el.getBoundingClientRect();
|
|
99
|
+
if (!includeHidden && rect.width === 0 && rect.height === 0) return null;
|
|
100
|
+
|
|
101
|
+
const id = path.join('-');
|
|
102
|
+
const computed = window.getComputedStyle(el);
|
|
103
|
+
const yPos = rect.y + window.scrollY;
|
|
104
|
+
|
|
105
|
+
const node = {
|
|
106
|
+
id,
|
|
107
|
+
tagName: el.tagName.toLowerCase(),
|
|
108
|
+
depth,
|
|
109
|
+
role: detectRole(el),
|
|
110
|
+
section: detectSectionContext(el, yPos),
|
|
111
|
+
attributes: {
|
|
112
|
+
id: el.id || null,
|
|
113
|
+
className: el.className ? el.className.toString().split(' ').slice(0, 3).join(' ') : null,
|
|
114
|
+
role: el.getAttribute('role')
|
|
115
|
+
},
|
|
116
|
+
dimensions: {
|
|
117
|
+
width: Math.round(rect.width),
|
|
118
|
+
height: Math.round(rect.height),
|
|
119
|
+
x: Math.round(rect.x),
|
|
120
|
+
y: Math.round(yPos)
|
|
121
|
+
},
|
|
122
|
+
layout: {
|
|
123
|
+
display: computed.display,
|
|
124
|
+
position: computed.position !== 'static' ? computed.position : undefined,
|
|
125
|
+
flexDirection: computed.flexDirection !== 'row' ? computed.flexDirection : undefined,
|
|
126
|
+
gridTemplateColumns: computed.gridTemplateColumns !== 'none' ? computed.gridTemplateColumns : undefined
|
|
127
|
+
},
|
|
128
|
+
children: [],
|
|
129
|
+
parentId
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
let childIdx = 0;
|
|
133
|
+
for (const child of el.children) {
|
|
134
|
+
const childNode = traverseDOM(child, depth + 1, id, [...path, childIdx]);
|
|
135
|
+
if (childNode) { node.children.push(childNode); childIdx++; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return node;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const root = traverseDOM(document.body, 0, null, [0]);
|
|
142
|
+
return { root, pageHeight, pageWidth };
|
|
143
|
+
},
|
|
144
|
+
{ maxDepth, includeHidden, LANDMARK_TAGS, HEADING_TAGS, HERO_THRESHOLD, FOOTER_THRESHOLD, SIDEBAR_MAX_WIDTH }
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Post-process in Node context using tree-builder helpers
|
|
148
|
+
const landmarks = buildLandmarksMap(result.root);
|
|
149
|
+
const headingTree = buildHeadingTree(result.root);
|
|
150
|
+
const { totalNodes, maxDepth: maxActualDepth } = countTreeStats(result.root);
|
|
151
|
+
|
|
152
|
+
// Enrich headings with text + fontSize (separate evaluate for perf)
|
|
153
|
+
const headingData = await page.evaluate(({ headingTree, yTolerance }) => {
|
|
154
|
+
return headingTree.map(h => {
|
|
155
|
+
const headings = document.querySelectorAll(`h${h.level}`);
|
|
156
|
+
for (const el of headings) {
|
|
157
|
+
const rect = el.getBoundingClientRect();
|
|
158
|
+
const yPos = Math.round(rect.y + window.scrollY);
|
|
159
|
+
if (Math.abs(yPos - h.y) < yTolerance) {
|
|
160
|
+
const computed = window.getComputedStyle(el);
|
|
161
|
+
return {
|
|
162
|
+
...h,
|
|
163
|
+
text: el.textContent?.trim().slice(0, 60) || null,
|
|
164
|
+
fontSize: parseFloat(computed.fontSize) || null
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return h;
|
|
169
|
+
});
|
|
170
|
+
}, { headingTree, yTolerance: Y_POSITION_TOLERANCE });
|
|
171
|
+
|
|
172
|
+
const duration = Date.now() - startTime;
|
|
173
|
+
if (duration > 500) {
|
|
174
|
+
console.error(`[WARN] DOM hierarchy extraction took ${duration}ms (>500ms target)`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
root: result.root,
|
|
179
|
+
landmarks,
|
|
180
|
+
headingTree: headingData,
|
|
181
|
+
stats: {
|
|
182
|
+
totalNodes,
|
|
183
|
+
maxDepth: maxActualDepth,
|
|
184
|
+
landmarkCount: [landmarks.header, landmarks.main, landmarks.footer].filter(Boolean).length +
|
|
185
|
+
landmarks.nav.length + landmarks.aside.length,
|
|
186
|
+
pageHeight: result.pageHeight,
|
|
187
|
+
pageWidth: result.pageWidth,
|
|
188
|
+
extractionTimeMs: duration
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side state capture for SPA app state snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Runs page.evaluate calls to capture framework-specific data
|
|
5
|
+
* (__NEXT_DATA__, __NUXT__, etc.) and state management store state
|
|
6
|
+
* (Redux, Vuex, Pinia, Zustand, MobX). Used by app-state-snapshot.js.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Capture framework-specific data from page
|
|
11
|
+
* @param {import('playwright').Page} page - Playwright page
|
|
12
|
+
* @param {string|null} framework - Detected framework name
|
|
13
|
+
* @returns {Promise<Object|null>}
|
|
14
|
+
*/
|
|
15
|
+
export async function captureFrameworkData(page, framework) {
|
|
16
|
+
try {
|
|
17
|
+
return await page.evaluate((fw) => {
|
|
18
|
+
switch (fw) {
|
|
19
|
+
case 'next':
|
|
20
|
+
if (!window.__NEXT_DATA__) return null;
|
|
21
|
+
return {
|
|
22
|
+
props: window.__NEXT_DATA__.props,
|
|
23
|
+
page: window.__NEXT_DATA__.page,
|
|
24
|
+
query: window.__NEXT_DATA__.query,
|
|
25
|
+
buildId: window.__NEXT_DATA__.buildId,
|
|
26
|
+
runtimeConfig: window.__NEXT_DATA__.runtimeConfig,
|
|
27
|
+
dynamicIds: window.__NEXT_DATA__.dynamicIds
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
case 'nuxt':
|
|
31
|
+
if (!window.__NUXT__) return null;
|
|
32
|
+
return {
|
|
33
|
+
data: window.__NUXT__.data,
|
|
34
|
+
state: window.__NUXT__.state,
|
|
35
|
+
serverRendered: window.__NUXT__.serverRendered,
|
|
36
|
+
routePath: window.__NUXT__.routePath,
|
|
37
|
+
config: window.__NUXT__.config
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
case 'vue': {
|
|
41
|
+
const vueApp = document.querySelector('[data-v-app]')?.__vue_app__;
|
|
42
|
+
if (vueApp?.config?.globalProperties) {
|
|
43
|
+
return {
|
|
44
|
+
routePath: window.location.pathname,
|
|
45
|
+
hasRouter: !!vueApp.config.globalProperties.$router,
|
|
46
|
+
hasStore: !!vueApp.config.globalProperties.$store ||
|
|
47
|
+
!!vueApp.config.globalProperties.$pinia
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case 'react': {
|
|
54
|
+
const reactRoot = document.getElementById('root') ||
|
|
55
|
+
document.querySelector('[data-reactroot]');
|
|
56
|
+
return reactRoot ? {
|
|
57
|
+
hasReactRoot: true,
|
|
58
|
+
rootId: reactRoot.id || null
|
|
59
|
+
} : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
case 'angular': {
|
|
63
|
+
const appRoot = document.querySelector('app-root');
|
|
64
|
+
if (appRoot && window.ng?.probe) {
|
|
65
|
+
try {
|
|
66
|
+
const component = window.ng.probe(appRoot);
|
|
67
|
+
return {
|
|
68
|
+
componentName: component?.componentInstance?.constructor?.name,
|
|
69
|
+
hasRouter: !!component?.injector?.get?.('Router', null)
|
|
70
|
+
};
|
|
71
|
+
} catch {
|
|
72
|
+
return { hasAppRoot: true };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return appRoot ? { hasAppRoot: true } : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case 'svelte':
|
|
79
|
+
if (window.__sveltekit_data__) {
|
|
80
|
+
return window.__sveltekit_data__;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
|
|
84
|
+
case 'astro': {
|
|
85
|
+
const islands = document.querySelectorAll('astro-island');
|
|
86
|
+
if (islands.length > 0) {
|
|
87
|
+
return {
|
|
88
|
+
islandCount: islands.length,
|
|
89
|
+
componentNames: Array.from(islands)
|
|
90
|
+
.map(i => i.getAttribute('component-export'))
|
|
91
|
+
.filter(Boolean)
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
default:
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}, framework);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Capture state management store state (Redux, Vuex, Pinia, Zustand, MobX)
|
|
108
|
+
* @param {import('playwright').Page} page - Playwright page
|
|
109
|
+
* @returns {Promise<{type: string, state: Object|null}>}
|
|
110
|
+
*/
|
|
111
|
+
export async function captureStoreState(page) {
|
|
112
|
+
try {
|
|
113
|
+
return await page.evaluate(() => {
|
|
114
|
+
// Redux - Method 1: Redux DevTools extension
|
|
115
|
+
if (window.__REDUX_DEVTOOLS_EXTENSION__) {
|
|
116
|
+
try {
|
|
117
|
+
const stores = window.__REDUX_DEVTOOLS_EXTENSION__.stores ||
|
|
118
|
+
window.__REDUX_DEVTOOLS_EXTENSION__.open?.() ||
|
|
119
|
+
null;
|
|
120
|
+
if (stores && typeof stores === 'object') {
|
|
121
|
+
const storeKeys = Object.keys(stores);
|
|
122
|
+
if (storeKeys.length > 0) {
|
|
123
|
+
const store = stores[storeKeys[0]];
|
|
124
|
+
if (store?.getState) {
|
|
125
|
+
return { type: 'redux', state: store.getState() };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// Continue to other methods
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Redux - Method 2: Direct store on window
|
|
135
|
+
if (window.store?.getState) {
|
|
136
|
+
return { type: 'redux', state: window.store.getState() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Redux - Method 3: __REDUX_STATE__ hydration
|
|
140
|
+
if (window.__REDUX_STATE__) {
|
|
141
|
+
return { type: 'redux', state: window.__REDUX_STATE__ };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Vuex - Nuxt 2 / Vue 2/3
|
|
145
|
+
if (window.$nuxt?.$store?.state) {
|
|
146
|
+
return { type: 'vuex', state: window.$nuxt.$store.state };
|
|
147
|
+
}
|
|
148
|
+
if (window.__VUEX__?.state) {
|
|
149
|
+
return { type: 'vuex', state: window.__VUEX__.state };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Vuex via Vue app
|
|
153
|
+
const vueApp = document.querySelector('[data-v-app]')?.__vue_app__;
|
|
154
|
+
if (vueApp?.config?.globalProperties?.$store?.state) {
|
|
155
|
+
return { type: 'vuex', state: vueApp.config.globalProperties.$store.state };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Pinia - Nuxt 3 / Vue 3
|
|
159
|
+
if (window.$nuxt?.$pinia?.state?.value) {
|
|
160
|
+
return { type: 'pinia', state: window.$nuxt.$pinia.state.value };
|
|
161
|
+
}
|
|
162
|
+
if (window.__PINIA__?.state?.value) {
|
|
163
|
+
return { type: 'pinia', state: window.__PINIA__.state.value };
|
|
164
|
+
}
|
|
165
|
+
if (vueApp?.config?.globalProperties?.$pinia?.state?.value) {
|
|
166
|
+
return { type: 'pinia', state: vueApp.config.globalProperties.$pinia.state.value };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Zustand - check common window-exposed store names
|
|
170
|
+
const zustandPatterns = ['useStore', 'useAppStore', 'useBearStore', 'store'];
|
|
171
|
+
for (const pattern of zustandPatterns) {
|
|
172
|
+
const potentialStore = window[pattern];
|
|
173
|
+
if (potentialStore?.getState && typeof potentialStore.getState === 'function') {
|
|
174
|
+
try {
|
|
175
|
+
const state = potentialStore.getState();
|
|
176
|
+
if (state && typeof state === 'object') {
|
|
177
|
+
return { type: 'zustand', state };
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Not a valid Zustand store
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// MobX
|
|
186
|
+
if (window.__MOBX_STATE__) {
|
|
187
|
+
return { type: 'mobx', state: window.__MOBX_STATE__ };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { type: 'none', state: null };
|
|
191
|
+
});
|
|
192
|
+
} catch {
|
|
193
|
+
return { type: 'none', state: null };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialization and filtering utilities for app state snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Provides safe object serialization (handles circular refs, functions, symbols),
|
|
5
|
+
* sensitive key filtering (tokens, passwords, secrets), and state size enforcement.
|
|
6
|
+
* Used by app-state-snapshot.js (main module).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { SIZE_LIMITS } from '../../shared/config.js';
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Constants
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/** Maximum state size in bytes (1MB) - sourced from centralized config */
|
|
16
|
+
export const MAX_STATE_SIZE = SIZE_LIMITS.MAX_STATE;
|
|
17
|
+
|
|
18
|
+
/** Maximum depth for recursive object traversal */
|
|
19
|
+
export const MAX_TRAVERSAL_DEPTH = 50;
|
|
20
|
+
|
|
21
|
+
/** Patterns to identify sensitive keys */
|
|
22
|
+
export const SENSITIVE_PATTERNS = [
|
|
23
|
+
/token/i,
|
|
24
|
+
/password/i,
|
|
25
|
+
/passwd/i,
|
|
26
|
+
/secret/i,
|
|
27
|
+
/auth/i,
|
|
28
|
+
/api[_-]?key/i,
|
|
29
|
+
/credential/i,
|
|
30
|
+
/private/i,
|
|
31
|
+
/session/i,
|
|
32
|
+
/cookie/i,
|
|
33
|
+
/bearer/i,
|
|
34
|
+
/jwt/i,
|
|
35
|
+
/access[_-]?key/i,
|
|
36
|
+
/refresh[_-]?token/i
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Marker for filtered sensitive values */
|
|
40
|
+
export const FILTERED_MARKER = '[FILTERED]';
|
|
41
|
+
|
|
42
|
+
/** Marker for circular references */
|
|
43
|
+
export const CIRCULAR_MARKER = '[Circular]';
|
|
44
|
+
|
|
45
|
+
/** Marker for unserializable values */
|
|
46
|
+
export const UNSERIALIZABLE_MARKER = '[Unserializable]';
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Utility Functions
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if a key matches sensitive patterns
|
|
54
|
+
* @param {string} key - Object key to check
|
|
55
|
+
* @returns {boolean}
|
|
56
|
+
*/
|
|
57
|
+
export function isSensitiveKey(key) {
|
|
58
|
+
if (typeof key !== 'string') return false;
|
|
59
|
+
return SENSITIVE_PATTERNS.some(pattern => pattern.test(key));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Filter sensitive keys from an object recursively
|
|
64
|
+
* @param {*} obj - Object to filter
|
|
65
|
+
* @param {string[]} warnings - Array to collect warnings
|
|
66
|
+
* @param {string} path - Current path for warning messages
|
|
67
|
+
* @param {number} depth - Current recursion depth
|
|
68
|
+
* @returns {*} Filtered object
|
|
69
|
+
*/
|
|
70
|
+
export function filterSensitive(obj, warnings = [], path = '', depth = 0) {
|
|
71
|
+
// Prevent infinite recursion
|
|
72
|
+
if (depth > MAX_TRAVERSAL_DEPTH) {
|
|
73
|
+
return '[Max Depth Exceeded]';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle primitives
|
|
77
|
+
if (obj === null || obj === undefined) return obj;
|
|
78
|
+
if (typeof obj !== 'object') return obj;
|
|
79
|
+
|
|
80
|
+
// Handle arrays
|
|
81
|
+
if (Array.isArray(obj)) {
|
|
82
|
+
return obj.map((item, i) =>
|
|
83
|
+
filterSensitive(item, warnings, `${path}[${i}]`, depth + 1)
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Handle objects
|
|
88
|
+
const filtered = {};
|
|
89
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
90
|
+
const fullPath = path ? `${path}.${key}` : key;
|
|
91
|
+
|
|
92
|
+
if (isSensitiveKey(key)) {
|
|
93
|
+
warnings.push(`Filtered sensitive key: ${fullPath}`);
|
|
94
|
+
filtered[key] = FILTERED_MARKER;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
filtered[key] = filterSensitive(value, warnings, fullPath, depth + 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return filtered;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Safely serialize an object handling circular refs, functions, symbols
|
|
106
|
+
* @param {*} obj - Object to serialize
|
|
107
|
+
* @param {WeakSet} seen - Set of seen objects for circular detection
|
|
108
|
+
* @param {number} depth - Current recursion depth
|
|
109
|
+
* @returns {*} Serializable version of object
|
|
110
|
+
*/
|
|
111
|
+
export function safeSerialize(obj, seen = new WeakSet(), depth = 0) {
|
|
112
|
+
if (depth > MAX_TRAVERSAL_DEPTH) return '[Max Depth Exceeded]';
|
|
113
|
+
|
|
114
|
+
if (obj === null || obj === undefined) return obj;
|
|
115
|
+
if (typeof obj === 'boolean' || typeof obj === 'number' || typeof obj === 'string') return obj;
|
|
116
|
+
|
|
117
|
+
if (typeof obj === 'function') return '[Function]';
|
|
118
|
+
if (typeof obj === 'symbol') return obj.toString();
|
|
119
|
+
if (typeof obj === 'bigint') return obj.toString();
|
|
120
|
+
if (obj instanceof Date) return obj.toISOString();
|
|
121
|
+
if (obj instanceof RegExp) return obj.toString();
|
|
122
|
+
if (obj instanceof Error) return { message: obj.message, name: obj.name };
|
|
123
|
+
if (obj instanceof Map) return Object.fromEntries(obj);
|
|
124
|
+
if (obj instanceof Set) return Array.from(obj);
|
|
125
|
+
|
|
126
|
+
if (typeof obj === 'object') {
|
|
127
|
+
if (seen.has(obj)) return CIRCULAR_MARKER;
|
|
128
|
+
seen.add(obj);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
if (Array.isArray(obj)) {
|
|
132
|
+
return obj.map(item => safeSerialize(item, seen, depth + 1));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const result = {};
|
|
136
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
137
|
+
try {
|
|
138
|
+
result[key] = safeSerialize(value, seen, depth + 1);
|
|
139
|
+
} catch {
|
|
140
|
+
result[key] = UNSERIALIZABLE_MARKER;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
} catch {
|
|
145
|
+
return UNSERIALIZABLE_MARKER;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return obj;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Enforce state size limit, truncating store state if exceeded
|
|
154
|
+
* @param {import('./app-state-snapshot.js').StateSnapshot} snapshot - State snapshot to check
|
|
155
|
+
* @param {string[]} warnings - Array to collect warnings
|
|
156
|
+
* @returns {import('./app-state-snapshot.js').StateSnapshot} Possibly truncated snapshot
|
|
157
|
+
*/
|
|
158
|
+
export function enforceStateLimit(snapshot, warnings) {
|
|
159
|
+
const serialized = JSON.stringify(snapshot);
|
|
160
|
+
const sizeBytes = Buffer.byteLength(serialized, 'utf8');
|
|
161
|
+
|
|
162
|
+
if (sizeBytes > MAX_STATE_SIZE) {
|
|
163
|
+
const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
|
|
164
|
+
warnings.push(`State exceeded 1MB limit (${sizeMB}MB), store state truncated`);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
...snapshot,
|
|
168
|
+
storeState: {
|
|
169
|
+
_truncated: true,
|
|
170
|
+
_reason: `exceeded 1MB limit (${sizeMB}MB)`,
|
|
171
|
+
_originalType: snapshot.storeType
|
|
172
|
+
},
|
|
173
|
+
sizeBytes: MAX_STATE_SIZE
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return { ...snapshot, sizeBytes };
|
|
178
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App State Snapshot Module
|
|
3
|
+
*
|
|
4
|
+
* Captures application state from SPAs including:
|
|
5
|
+
* - Framework data (__NEXT_DATA__, __NUXT__)
|
|
6
|
+
* - State management stores (Redux, Vuex, Pinia, Zustand)
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Sensitive data filtering (tokens, passwords, secrets)
|
|
10
|
+
* - Safe serialization (handles circular refs, functions, symbols)
|
|
11
|
+
* - Size limit enforcement (1MB max)
|
|
12
|
+
*
|
|
13
|
+
* @module app-state-snapshot
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
MAX_STATE_SIZE,
|
|
18
|
+
SENSITIVE_PATTERNS,
|
|
19
|
+
FILTERED_MARKER,
|
|
20
|
+
CIRCULAR_MARKER,
|
|
21
|
+
isSensitiveKey,
|
|
22
|
+
filterSensitive,
|
|
23
|
+
safeSerialize,
|
|
24
|
+
enforceStateLimit
|
|
25
|
+
} from './app-state-snapshot-utils.js';
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
captureFrameworkData,
|
|
29
|
+
captureStoreState
|
|
30
|
+
} from './app-state-snapshot-capture.js';
|
|
31
|
+
|
|
32
|
+
// Re-export constants and utilities for backward compatibility
|
|
33
|
+
export {
|
|
34
|
+
MAX_STATE_SIZE,
|
|
35
|
+
SENSITIVE_PATTERNS,
|
|
36
|
+
FILTERED_MARKER,
|
|
37
|
+
CIRCULAR_MARKER,
|
|
38
|
+
isSensitiveKey,
|
|
39
|
+
filterSensitive,
|
|
40
|
+
safeSerialize,
|
|
41
|
+
enforceStateLimit,
|
|
42
|
+
captureFrameworkData,
|
|
43
|
+
captureStoreState
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ============================================================================
|
|
47
|
+
// Type Definitions (JSDoc)
|
|
48
|
+
// ============================================================================
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} StateSnapshot
|
|
52
|
+
* @property {Object|null} frameworkData - __NEXT_DATA__, __NUXT__, etc.
|
|
53
|
+
* @property {Object|null} storeState - Redux/Vuex/Pinia/Zustand state
|
|
54
|
+
* @property {string|null} framework - Detected framework name
|
|
55
|
+
* @property {string} storeType - 'redux'|'vuex'|'pinia'|'zustand'|'none'
|
|
56
|
+
* @property {string[]} warnings - Serialization/filtering warnings
|
|
57
|
+
* @property {number} capturedAt - Unix timestamp
|
|
58
|
+
* @property {number} sizeBytes - Serialized size in bytes
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Main Export
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Capture application state from page
|
|
67
|
+
* @param {import('playwright').Page} page - Playwright page instance
|
|
68
|
+
* @param {Object|null} [frameworkInfo] - Framework detection result
|
|
69
|
+
* @returns {Promise<StateSnapshot>}
|
|
70
|
+
*/
|
|
71
|
+
export async function captureAppState(page, frameworkInfo = null) {
|
|
72
|
+
const warnings = [];
|
|
73
|
+
const framework = frameworkInfo?.framework || null;
|
|
74
|
+
|
|
75
|
+
let snapshot = {
|
|
76
|
+
frameworkData: null,
|
|
77
|
+
storeState: null,
|
|
78
|
+
framework,
|
|
79
|
+
storeType: 'none',
|
|
80
|
+
warnings,
|
|
81
|
+
capturedAt: Date.now(),
|
|
82
|
+
sizeBytes: 0
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const rawFrameworkData = await captureFrameworkData(page, framework);
|
|
87
|
+
if (rawFrameworkData) {
|
|
88
|
+
const serialized = safeSerialize(rawFrameworkData);
|
|
89
|
+
snapshot.frameworkData = filterSensitive(serialized, warnings);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const storeResult = await captureStoreState(page);
|
|
93
|
+
if (storeResult.state) {
|
|
94
|
+
const serialized = safeSerialize(storeResult.state);
|
|
95
|
+
snapshot.storeState = filterSensitive(serialized, warnings);
|
|
96
|
+
snapshot.storeType = storeResult.type;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
snapshot = enforceStateLimit(snapshot, warnings);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
warnings.push(`State capture error: ${error.message}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return snapshot;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Format state snapshot for logging
|
|
109
|
+
* @param {StateSnapshot} snapshot - Captured state
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function formatStateSnapshot(snapshot) {
|
|
113
|
+
const lines = [
|
|
114
|
+
'\n=== App State Snapshot ===',
|
|
115
|
+
`Framework: ${snapshot.framework || 'unknown'}`,
|
|
116
|
+
`Store Type: ${snapshot.storeType}`,
|
|
117
|
+
`Framework Data: ${snapshot.frameworkData ? 'captured' : 'none'}`,
|
|
118
|
+
`Store State: ${snapshot.storeState ? 'captured' : 'none'}`,
|
|
119
|
+
`Size: ${(snapshot.sizeBytes / 1024).toFixed(2)} KB`
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
if (snapshot.warnings.length > 0) {
|
|
123
|
+
lines.push(`Warnings (${snapshot.warnings.length}):`);
|
|
124
|
+
snapshot.warnings.slice(0, 5).forEach(w => lines.push(` - ${w}`));
|
|
125
|
+
if (snapshot.warnings.length > 5) {
|
|
126
|
+
lines.push(` ... and ${snapshot.warnings.length - 5} more`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return lines.join('\n');
|
|
131
|
+
}
|