Initial ecommerce standalone package
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const mediaExtensions = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov", ".webm", ".avif"]);
|
||||
const textExtensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json", ".html", ".css", ".md", ".env", ".example"]);
|
||||
|
||||
const scanRoots = ["src", "vite.config.ts", "index.html", "package.json", ".env.example"];
|
||||
const allowedFiles = new Set([
|
||||
normalizePath("src/data/ossAssets.ts"),
|
||||
normalizePath("src/utils/ossImageOptimize.ts"),
|
||||
]);
|
||||
|
||||
const forbiddenPatterns = [
|
||||
{ label: "frontend env config", pattern: /\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/ },
|
||||
{ label: "direct provider proxy", pattern: /\/dashscope-api\b|dashscope\.aliyuncs\.com/i },
|
||||
{ label: "third-party demo media host", pattern: /picsum\.photos|xiuxiu-pro(?:-new)?\.meitudata\.com|meitudata\.com/i },
|
||||
{ label: "hard-coded provider secret marker", pattern: /Bearer\s+sk-|DASHSCOPE_API_KEY|ACCESS_KEY_SECRET|SECRET_ACCESS_KEY/i },
|
||||
{ label: "local media import", pattern: /from\s+["'][^"']*\/assets\/[^"']*\.(?:png|jpe?g|webp|gif|mp4|mov|webm|avif|svg)["']/i },
|
||||
];
|
||||
|
||||
const failures = [];
|
||||
|
||||
function normalizePath(value) {
|
||||
return value.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function walk(targetPath, visitor) {
|
||||
if (!fs.existsSync(targetPath)) return;
|
||||
const stat = fs.statSync(targetPath);
|
||||
if (stat.isDirectory()) {
|
||||
for (const entry of fs.readdirSync(targetPath)) {
|
||||
if (entry === "node_modules" || entry === "dist" || entry === ".git") continue;
|
||||
walk(path.join(targetPath, entry), visitor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
visitor(targetPath, stat);
|
||||
}
|
||||
|
||||
function report(file, message) {
|
||||
failures.push(`${normalizePath(path.relative(repoRoot, file))}: ${message}`);
|
||||
}
|
||||
|
||||
walk(path.join(repoRoot, "src", "assets"), (file) => {
|
||||
if (mediaExtensions.has(path.extname(file).toLowerCase())) {
|
||||
report(file, "media files must live in OSS, not src/assets");
|
||||
}
|
||||
});
|
||||
|
||||
for (const root of scanRoots) {
|
||||
walk(path.join(repoRoot, root), (file) => {
|
||||
const relative = normalizePath(path.relative(repoRoot, file));
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (!textExtensions.has(ext) && !relative.endsWith(".env.example")) return;
|
||||
if (relative.startsWith("src/assets/")) return;
|
||||
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
const isAllowed = allowedFiles.has(relative);
|
||||
for (const rule of forbiddenPatterns) {
|
||||
if (isAllowed && (rule.label === "third-party demo media host" || rule.label === "hard-coded provider secret marker")) {
|
||||
continue;
|
||||
}
|
||||
if (rule.pattern.test(content)) {
|
||||
report(file, `forbidden ${rule.label}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("Governance check failed:");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Governance check passed.");
|
||||
@@ -0,0 +1 @@
|
||||
import "./check-governance.mjs";
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* Dynamic analysis without Playwright - uses Node.js to analyze module structure,
|
||||
* dependency graph, import patterns, and potential runtime costs.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join, relative, basename } from 'path';
|
||||
|
||||
const SRC = join(import.meta.dirname, '..', 'src');
|
||||
const DIST = join(import.meta.dirname, '..', 'dist');
|
||||
|
||||
const results = [];
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== 'node_modules') walk(full);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
||||
const content = readFileSync(full, 'utf-8');
|
||||
results.push({ file: relative(join(SRC, '..'), full), content, lines: content.split('\n').length });
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(SRC);
|
||||
|
||||
// ─── 1. Dependency Graph Analysis ───
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
console.log(' 1. MODULE DEPENDENCY GRAPH ANALYSIS');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
const importMap = new Map(); // file -> [imports]
|
||||
const importedBy = new Map(); // file -> [importers]
|
||||
|
||||
for (const r of results) {
|
||||
const imports = [];
|
||||
// Match import statements
|
||||
const importRe = /import\s+(?:.*?\s+from\s+)?['"]([^'"]+)['"]/g;
|
||||
let m;
|
||||
while ((m = importRe.exec(r.content)) !== null) {
|
||||
imports.push(m[1]);
|
||||
}
|
||||
// Match dynamic imports
|
||||
const dynRe = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
||||
while ((m = dynRe.exec(r.content)) !== null) {
|
||||
imports.push(`[dynamic]${m[1]}`);
|
||||
}
|
||||
importMap.set(r.file, imports);
|
||||
}
|
||||
|
||||
// Find circular dependencies
|
||||
console.log('\n--- Circular Dependency Detection ---');
|
||||
function findCircular(file, visited = new Set(), path = []) {
|
||||
if (visited.has(file)) {
|
||||
if (path.includes(file)) {
|
||||
console.log(` [CIRCULAR] ${path.slice(path.indexOf(file)).join(' -> ')} -> ${file}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
visited.add(file);
|
||||
path.push(file);
|
||||
const deps = importMap.get(file) || [];
|
||||
for (const dep of deps) {
|
||||
if (dep.startsWith('.') || dep.startsWith('/')) {
|
||||
// Resolve relative path
|
||||
const dir = file.split('/').slice(0, -1).join('/');
|
||||
const resolved = dep.replace(/^\.\//, dir + '/').replace(/^\.\.\//, '');
|
||||
// Find matching file
|
||||
for (const r of results) {
|
||||
if (r.file.includes(resolved) || r.file.includes(basename(resolved))) {
|
||||
findCircular(r.file, new Set(visited), [...path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check high-fanin files (imported by many)
|
||||
const fanIn = new Map();
|
||||
for (const [file, imports] of importMap) {
|
||||
for (const imp of imports) {
|
||||
const key = imp.replace(/\[dynamic\]/, '');
|
||||
fanIn.set(key, (fanIn.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n--- High Fan-In Modules (most imported) ---');
|
||||
const sortedFanIn = [...fanIn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15);
|
||||
for (const [mod, count] of sortedFanIn) {
|
||||
const bar = '█'.repeat(Math.min(30, count));
|
||||
console.log(` ${mod.padEnd(50)} ${String(count).padStart(3)}x ${bar}`);
|
||||
}
|
||||
|
||||
// Check high-fanout files (import many)
|
||||
console.log('\n--- High Fan-Out Modules (import most) ---');
|
||||
const sortedFanOut = [...importMap.entries()]
|
||||
.map(([f, imps]) => [f, imps.length])
|
||||
.sort((a, b) => b[1] - a[1]).slice(0, 15);
|
||||
for (const [file, count] of sortedFanOut) {
|
||||
const bar = '█'.repeat(Math.min(30, count));
|
||||
console.log(` ${file.padEnd(50)} ${String(count).padStart(3)} deps ${bar}`);
|
||||
}
|
||||
|
||||
// Dynamic imports analysis (lazy loading effectiveness)
|
||||
console.log('\n--- Lazy Loading (Dynamic Imports) ---');
|
||||
let dynamicImports = 0, staticImports = 0;
|
||||
for (const [file, imports] of importMap) {
|
||||
for (const imp of imports) {
|
||||
if (imp.startsWith('[dynamic]')) dynamicImports++;
|
||||
else staticImports++;
|
||||
}
|
||||
}
|
||||
console.log(` Static imports: ${staticImports}`);
|
||||
console.log(` Dynamic imports: ${dynamicImports}`);
|
||||
console.log(` Lazy load ratio: ${((dynamicImports / (staticImports + dynamicImports)) * 100).toFixed(1)}%`);
|
||||
|
||||
// Find files that should be lazy loaded but aren't
|
||||
const largePages = results.filter(r => r.lines > 500 && r.file.includes('Page'));
|
||||
for (const r of largePages) {
|
||||
const isLazyImported = [...importMap.values()].some(imps =>
|
||||
imps.some(i => i.startsWith('[dynamic]') && i.includes(basename(r.file, '.tsx')))
|
||||
);
|
||||
if (!isLazyImported && !r.file.includes('App')) {
|
||||
// Check if it's referenced in App.tsx
|
||||
const appContent = results.find(x => x.file === 'src/App.tsx')?.content || '';
|
||||
if (appContent.includes(basename(r.file, '.tsx'))) {
|
||||
console.log(` [INFO] ${r.file} (${r.lines} lines) - loaded via App.tsx`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 2. React Rendering Cost Analysis ───
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' 2. REACT RENDERING COST ANALYSIS');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
// Count useState/useReducer per component (state update triggers re-render)
|
||||
console.log('\n--- State Hook Density ---');
|
||||
for (const r of results) {
|
||||
if (!r.file.endsWith('.tsx')) continue;
|
||||
const stateHooks = (r.content.match(/useState\s*[<(]/g) || []).length;
|
||||
const reducers = (r.content.match(/useReducer\s*[<(]/g) || []).length;
|
||||
const effects = (r.content.match(/useEffect\s*\(/g) || []).length;
|
||||
const memos = (r.content.match(/useMemo\s*\(/g) || []).length;
|
||||
const callbacks = (r.content.match(/useCallback\s*[<(]/g) || []).length;
|
||||
const refs = (r.content.match(/useRef\s*[<(]/g) || []).length;
|
||||
|
||||
const totalHooks = stateHooks + reducers + effects + memos + callbacks + refs;
|
||||
if (totalHooks > 15) {
|
||||
const risk = totalHooks > 30 ? '🔴 HIGH' : totalHooks > 20 ? '🟡 MEDIUM' : '🟢 LOW';
|
||||
console.log(` ${risk} ${r.file}`);
|
||||
console.log(` useState:${stateHooks} useReducer:${reducers} useEffect:${effects} useMemo:${memos} useCallback:${callbacks} useRef:${refs} (total:${totalHooks})`);
|
||||
|
||||
// Check if there are many state updates that could be batched
|
||||
const setters = r.content.match(/set\w+\(/g) || [];
|
||||
if (setters.length > 20) {
|
||||
console.log(` ⚠️ ${setters.length} state setter calls — potential for excessive re-renders`);
|
||||
}
|
||||
|
||||
// Check for missing useMemo on expensive computations
|
||||
const expensiveInRender = (r.content.match(/\.map\(|\.filter\(|\.reduce\(|\.sort\(/g) || []).length;
|
||||
if (expensiveInRender > 5 && memos === 0) {
|
||||
console.log(` ⚠️ ${expensiveInRender} array operations in render body with 0 useMemo`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 3. useEffect Dependency Analysis ───
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' 3. useEffect DEPENDENCY ANALYSIS');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
for (const r of results) {
|
||||
if (!r.file.endsWith('.tsx')) continue;
|
||||
// Find useEffect with no dependency array (runs every render)
|
||||
const noDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*\)/g) || []).length;
|
||||
// Find useEffect with empty deps
|
||||
const emptyDeps = (r.content.match(/useEffect\s*\(\s*\(\)\s*=>\s*\{[\s\S]*?\}\s*,\s*\[\s*\]\s*\)/g) || []).length;
|
||||
|
||||
if (noDeps > 0) {
|
||||
console.log(` [RENDER-COST] ${r.file}: ${noDeps} useEffect(s) run EVERY render`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 4. Zustand Store Analysis ───
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' 4. ZUSTAND STORE ANALYSIS');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
const storeFiles = results.filter(r => r.file.includes('store') || r.file.includes('Store'));
|
||||
for (const r of storeFiles) {
|
||||
const stateFields = (r.content.match(/^\s+\w+:/gm) || []).length;
|
||||
const actions = (r.content.match(/^\s+\w+\s*:\s*(\(|function|\w+\s*=>)/gm) || []).length;
|
||||
const subscribers = (r.content.match(/subscribe\s*\(/g) || []).length;
|
||||
|
||||
console.log(`\n ${r.file}`);
|
||||
console.log(` State fields: ~${stateFields}`);
|
||||
console.log(` Actions: ~${actions}`);
|
||||
console.log(` Subscribers: ${subscribers}`);
|
||||
|
||||
// Check for selector usage patterns
|
||||
if (r.content.includes('set(') && !r.content.includes('useShallow')) {
|
||||
console.log(` ⚠️ Uses set() without useShallow — may cause unnecessary re-renders`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 5. Bundle Composition Analysis ───
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' 5. BUNDLE COMPOSITION ANALYSIS');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
if (readdirSync(DIST).includes('assets')) {
|
||||
const assets = readdirSync(join(DIST, 'assets'));
|
||||
const jsFiles = assets.filter(f => f.endsWith('.js') && !f.endsWith('.br'));
|
||||
const cssFiles = assets.filter(f => f.endsWith('.css') && !f.endsWith('.br'));
|
||||
|
||||
let totalJs = 0, totalCss = 0;
|
||||
const chunks = [];
|
||||
for (const f of jsFiles) {
|
||||
const size = statSync(join(DIST, 'assets', f)).size;
|
||||
totalJs += size;
|
||||
chunks.push({ name: f, sizeKB: size / 1024, type: 'js' });
|
||||
}
|
||||
for (const f of cssFiles) {
|
||||
const size = statSync(join(DIST, 'assets', f)).size;
|
||||
totalCss += size;
|
||||
chunks.push({ name: f, sizeKB: size / 1024, type: 'css' });
|
||||
}
|
||||
|
||||
chunks.sort((a, b) => b.sizeKB - a.sizeKB);
|
||||
|
||||
console.log(`\n Total JS: ${(totalJs / 1024).toFixed(1)} KB (${(totalJs / 1024 / 1024).toFixed(2)} MB)`);
|
||||
console.log(` Total CSS: ${(totalCss / 1024).toFixed(1)} KB (${(totalCss / 1024 / 1024).toFixed(2)} MB)`);
|
||||
console.log(` Total: ${((totalJs + totalCss) / 1024).toFixed(1)} KB`);
|
||||
|
||||
// CSS is suspiciously large
|
||||
const cssRatio = totalCss / totalJs;
|
||||
if (cssRatio > 0.5) {
|
||||
console.log(`\n ⚠️ CSS/JS ratio: ${(cssRatio * 100).toFixed(0)}% — CSS bundle may contain unused styles`);
|
||||
console.log(` Consider: PurgeCSS, CSS Modules, or extracting to separate loads`);
|
||||
}
|
||||
|
||||
// Check initial load budget
|
||||
const initialChunks = chunks.filter(c =>
|
||||
c.name.includes('index') || c.name.includes('vendor-react') || c.name.includes('vendor-antd')
|
||||
);
|
||||
const initialLoad = initialChunks.reduce((s, c) => s + c.sizeKB, 0);
|
||||
console.log(`\n Critical path (initial load):`);
|
||||
for (const c of initialChunks) {
|
||||
console.log(` ${c.name.padEnd(45)} ${c.sizeKB.toFixed(1).padStart(8)} KB`);
|
||||
}
|
||||
console.log(` ${'TOTAL'.padEnd(45)} ${initialLoad.toFixed(1).padStart(8)} KB`);
|
||||
|
||||
if (initialLoad > 300) {
|
||||
console.log(`\n ⚠️ Initial load > 300KB — consider code splitting or tree-shaking`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 6. API Client Efficiency ───
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' 6. API CLIENT EFFICIENCY');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
const apiFiles = results.filter(r => r.file.includes('src/api/'));
|
||||
for (const r of apiFiles) {
|
||||
const fetchCalls = (r.content.match(/fetch\s*\(/g) || []).length;
|
||||
const retries = (r.content.match(/retry|Retry|RETRY/g) || []).length;
|
||||
const abortSignals = (r.content.match(/AbortSignal|AbortController/g) || []).length;
|
||||
const timeouts = (r.content.match(/timeout|Timeout|setTimeout/g) || []).length;
|
||||
|
||||
if (fetchCalls > 0) {
|
||||
console.log(`\n ${r.file}`);
|
||||
console.log(` fetch calls: ${fetchCalls}`);
|
||||
console.log(` retry logic: ${retries > 0 ? '✅' : '❌ none'}`);
|
||||
console.log(` abort signals: ${abortSignals > 0 ? '✅' : '❌ none'}`);
|
||||
console.log(` timeouts: ${timeouts}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 7. TypeScript Compilation Metrics ───
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' 7. TYPE COMPLEXITY');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
let totalTypes = 0, totalInterfaces = 0, totalEnums = 0, totalGenerics = 0;
|
||||
for (const r of results) {
|
||||
totalTypes += (r.content.match(/^export\s+type\s+/gm) || []).length;
|
||||
totalTypes += (r.content.match(/^type\s+/gm) || []).length;
|
||||
totalInterfaces += (r.content.match(/^export\s+interface\s+/gm) || []).length;
|
||||
totalInterfaces += (r.content.match(/^interface\s+/gm) || []).length;
|
||||
totalEnums += (r.content.match(/enum\s+\w+/g) || []).length;
|
||||
totalGenerics += (r.content.match(/<\w+(?:\s*,\s*\w+)*>/g) || []).length;
|
||||
}
|
||||
|
||||
console.log(` Type aliases: ${totalTypes}`);
|
||||
console.log(` Interfaces: ${totalInterfaces}`);
|
||||
console.log(` Enums: ${totalEnums}`);
|
||||
console.log(` Generic usages: ${totalGenerics}`);
|
||||
console.log(` Total TS files: ${results.length}`);
|
||||
|
||||
// ─── Final Summary ───
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' ANALYSIS COMPLETE');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Dynamic performance analysis using Playwright.
|
||||
* Measures: page load, bundle sizes, memory, rendering, network.
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const DIST = join(import.meta.dirname, '..', 'dist');
|
||||
const PORT = 4174;
|
||||
|
||||
// ─── Bundle analysis from dist ───
|
||||
function analyzeBundles() {
|
||||
const assets = readdirSync(join(DIST, 'assets'));
|
||||
const jsFiles = assets.filter(f => f.endsWith('.js'));
|
||||
const cssFiles = assets.filter(f => f.endsWith('.css'));
|
||||
const brFiles = assets.filter(f => f.endsWith('.js.br'));
|
||||
|
||||
let totalJsSize = 0, totalCssSize = 0, totalBrSize = 0;
|
||||
const bundles = [];
|
||||
|
||||
for (const f of jsFiles) {
|
||||
const size = statSync(join(DIST, 'assets', f)).size;
|
||||
totalJsSize += size;
|
||||
bundles.push({ name: f, sizeKB: (size / 1024).toFixed(2), type: 'js' });
|
||||
}
|
||||
for (const f of cssFiles) {
|
||||
const size = statSync(join(DIST, 'assets', f)).size;
|
||||
totalCssSize += size;
|
||||
bundles.push({ name: f, sizeKB: (size / 1024).toFixed(2), type: 'css' });
|
||||
}
|
||||
for (const f of brFiles) {
|
||||
const size = statSync(join(DIST, 'assets', f)).size;
|
||||
totalBrSize += size;
|
||||
}
|
||||
|
||||
bundles.sort((a, b) => parseFloat(b.sizeKB) - parseFloat(a.sizeKB));
|
||||
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' BUNDLE SIZE ANALYSIS');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
console.log(`\nTotal JS (raw): ${(totalJsSize / 1024).toFixed(1)} KB`);
|
||||
console.log(`Total CSS (raw): ${(totalCssSize / 1024).toFixed(1)} KB`);
|
||||
console.log(`Total JS (brotli): ${(totalBrSize / 1024).toFixed(1)} KB`);
|
||||
console.log(`\nTop 15 bundles by raw size:`);
|
||||
for (const b of bundles.slice(0, 15)) {
|
||||
const bar = '█'.repeat(Math.min(40, Math.round(parseFloat(b.sizeKB) / 5)));
|
||||
console.log(` ${b.name.padEnd(45)} ${String(b.sizeKB).padStart(8)} KB ${bar}`);
|
||||
}
|
||||
|
||||
// Identify oversized chunks
|
||||
console.log('\n⚠️ Oversized chunks (>50KB raw):');
|
||||
for (const b of bundles.filter(b => parseFloat(b.sizeKB) > 50)) {
|
||||
console.log(` [WARN] ${b.name} = ${b.sizeKB} KB`);
|
||||
}
|
||||
|
||||
return { totalJsSize, totalCssSize, totalBrSize, bundles };
|
||||
}
|
||||
|
||||
// ─── Runtime performance with Playwright ───
|
||||
async function runtimeAnalysis() {
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' RUNTIME PERFORMANCE ANALYSIS');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Collect performance metrics
|
||||
const perfEntries = [];
|
||||
page.on('console', msg => {
|
||||
if (msg.type() === 'info' && msg.text().startsWith('[PERF]')) {
|
||||
perfEntries.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Track network requests
|
||||
const networkRequests = [];
|
||||
page.on('request', req => {
|
||||
networkRequests.push({
|
||||
url: req.url(),
|
||||
method: req.method(),
|
||||
startTime: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
const networkResponses = [];
|
||||
page.on('response', res => {
|
||||
networkResponses.push({
|
||||
url: res.url(),
|
||||
status: res.status(),
|
||||
size: res.headers()['content-length'] || '0',
|
||||
endTime: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Measure page load
|
||||
console.log('\n--- Page Load Performance ---');
|
||||
const navStart = Date.now();
|
||||
|
||||
try {
|
||||
const response = await page.goto(`http://127.0.0.1:${PORT}/`, {
|
||||
waitUntil: 'networkidle',
|
||||
timeout: 30000
|
||||
});
|
||||
const loadTime = Date.now() - navStart;
|
||||
console.log(` Initial page load: ${loadTime}ms`);
|
||||
console.log(` HTTP status: ${response.status()}`);
|
||||
|
||||
// Get navigation timing from the browser
|
||||
const navTiming = await page.evaluate(() => {
|
||||
const perf = performance.getEntriesByType('navigation')[0];
|
||||
if (!perf) return null;
|
||||
return {
|
||||
dns: Math.round(perf.domainLookupEnd - perf.domainLookupStart),
|
||||
tcp: Math.round(perf.connectEnd - perf.connectStart),
|
||||
ttfb: Math.round(perf.responseStart - perf.requestStart),
|
||||
download: Math.round(perf.responseEnd - perf.responseStart),
|
||||
domInteractive: Math.round(perf.domInteractive),
|
||||
domComplete: Math.round(perf.domComplete),
|
||||
loadEvent: Math.round(perf.loadEventEnd),
|
||||
transferSize: perf.transferSize,
|
||||
};
|
||||
});
|
||||
|
||||
if (navTiming) {
|
||||
console.log(`\n Navigation Timing:`);
|
||||
console.log(` DNS lookup: ${navTiming.dns}ms`);
|
||||
console.log(` TCP connect: ${navTiming.tcp}ms`);
|
||||
console.log(` TTFB: ${navTiming.ttfb}ms`);
|
||||
console.log(` Download: ${navTiming.download}ms`);
|
||||
console.log(` DOM interactive: ${navTiming.domInteractive}ms`);
|
||||
console.log(` DOM complete: ${navTiming.domComplete}ms`);
|
||||
console.log(` Load event end: ${navTiming.loadEvent}ms`);
|
||||
console.log(` Transfer size: ${(navTiming.transferSize / 1024).toFixed(1)} KB`);
|
||||
}
|
||||
|
||||
// Resource timing
|
||||
const resources = await page.evaluate(() => {
|
||||
return performance.getEntriesByType('resource').map(r => ({
|
||||
name: r.name.split('/').pop(),
|
||||
type: r.initiatorType,
|
||||
duration: Math.round(r.duration),
|
||||
size: r.transferSize,
|
||||
}));
|
||||
});
|
||||
|
||||
console.log(`\n--- Resource Loading ---`);
|
||||
console.log(` Total resources: ${resources.length}`);
|
||||
const totalTransfer = resources.reduce((s, r) => s + r.size, 0);
|
||||
console.log(` Total transfer: ${(totalTransfer / 1024).toFixed(1)} KB`);
|
||||
|
||||
const slowResources = resources.filter(r => r.duration > 100).sort((a, b) => b.duration - a.duration);
|
||||
if (slowResources.length > 0) {
|
||||
console.log(`\n Slow resources (>100ms):`);
|
||||
for (const r of slowResources.slice(0, 10)) {
|
||||
console.log(` [SLOW] ${r.name.padEnd(40)} ${r.duration}ms (${(r.size/1024).toFixed(1)}KB)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Memory analysis
|
||||
console.log(`\n--- Memory Analysis ---`);
|
||||
const memory = await page.evaluate(() => {
|
||||
if (performance.memory) {
|
||||
return {
|
||||
usedJSHeap: performance.memory.usedJSHeapSize,
|
||||
totalJSHeap: performance.memory.totalJSHeapSize,
|
||||
heapLimit: performance.memory.jsHeapSizeLimit,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (memory) {
|
||||
console.log(` Used JS heap: ${(memory.usedJSHeap / 1024 / 1024).toFixed(1)} MB`);
|
||||
console.log(` Total JS heap: ${(memory.totalJSHeap / 1024 / 1024).toFixed(1)} MB`);
|
||||
console.log(` Heap limit: ${(memory.heapLimit / 1024 / 1024).toFixed(1)} MB`);
|
||||
console.log(` Heap utilization: ${((memory.usedJSHeap / memory.heapLimit) * 100).toFixed(1)}%`);
|
||||
} else {
|
||||
console.log(' Memory API not available (Chromium flag needed: --enable-precise-memory-info)');
|
||||
}
|
||||
|
||||
// DOM complexity
|
||||
console.log(`\n--- DOM Complexity ---`);
|
||||
const domStats = await page.evaluate(() => {
|
||||
const allElements = document.querySelectorAll('*');
|
||||
const tagCounts = {};
|
||||
let maxDepth = 0;
|
||||
let totalNodes = allElements.length;
|
||||
|
||||
allElements.forEach(el => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
||||
let depth = 0, parent = el.parentElement;
|
||||
while (parent) { depth++; parent = parent.parentElement; }
|
||||
if (depth > maxDepth) maxDepth = depth;
|
||||
});
|
||||
|
||||
return { totalNodes, maxDepth, tagCounts };
|
||||
});
|
||||
|
||||
console.log(` Total DOM nodes: ${domStats.totalNodes}`);
|
||||
console.log(` Max DOM depth: ${domStats.maxDepth}`);
|
||||
console.log(` Top 10 tags:`);
|
||||
const sortedTags = Object.entries(domStats.tagCounts).sort((a, b) => b[1] - a[1]);
|
||||
for (const [tag, count] of sortedTags.slice(0, 10)) {
|
||||
console.log(` <${tag}>: ${count}`);
|
||||
}
|
||||
|
||||
// DOM warnings
|
||||
if (domStats.totalNodes > 1500) {
|
||||
console.log(` ⚠️ DOM nodes > 1500 — may cause sluggish rendering`);
|
||||
}
|
||||
if (domStats.maxDepth > 15) {
|
||||
console.log(` ⚠️ DOM depth > 15 — may slow style/layout calculations`);
|
||||
}
|
||||
|
||||
// React-specific analysis: check for unnecessary re-renders
|
||||
console.log(`\n--- Render Performance ---`);
|
||||
const renderMetrics = await page.evaluate(() => {
|
||||
// Check if React DevTools hook exists
|
||||
const hasReact = !!window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
||||
return {
|
||||
hasReact,
|
||||
eventListeners: typeof getEventListeners !== 'undefined' ? 'available' : 'not-in-page-context',
|
||||
};
|
||||
});
|
||||
console.log(` React DevTools: ${renderMetrics.hasReact ? 'detected' : 'not detected'}`);
|
||||
|
||||
// Measure interaction performance - simulate scroll
|
||||
console.log(`\n--- Interaction Performance ---`);
|
||||
const scrollStart = Date.now();
|
||||
await page.evaluate(() => {
|
||||
const container = document.querySelector('.app-shell-content') || document.documentElement;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
container.scrollTop = i * 100;
|
||||
}
|
||||
container.scrollTop = 0;
|
||||
});
|
||||
const scrollTime = Date.now() - scrollStart;
|
||||
console.log(` 10x scroll ops: ${scrollTime}ms`);
|
||||
|
||||
// Navigate to different routes to test lazy loading
|
||||
const routes = ['#/', '#/canvas', '#/workbench', '#/ecommerce', '#/image-workbench', '#/home'];
|
||||
console.log(`\n--- Route Navigation (Lazy Loading) ---`);
|
||||
for (const route of routes) {
|
||||
const routeStart = Date.now();
|
||||
await page.goto(`http://127.0.0.1:${PORT}/${route}`, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const routeTime = Date.now() - routeStart;
|
||||
console.log(` ${route.padEnd(30)} ${routeTime}ms`);
|
||||
}
|
||||
|
||||
// Memory after navigation
|
||||
const memoryAfter = await page.evaluate(() => {
|
||||
if (performance.memory) {
|
||||
return {
|
||||
usedJSHeap: performance.memory.usedJSHeapSize,
|
||||
totalJSHeap: performance.memory.totalJSHeapSize,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (memoryAfter) {
|
||||
console.log(`\n--- Memory After Route Navigation ---`);
|
||||
console.log(` Used JS heap: ${(memoryAfter.usedJSHeap / 1024 / 1024).toFixed(1)} MB`);
|
||||
console.log(` Total JS heap: ${(memoryAfter.totalJSHeap / 1024 / 1024).toFixed(1)} MB`);
|
||||
if (memory) {
|
||||
const delta = memoryAfter.usedJSHeap - memory.usedJSHeap;
|
||||
console.log(` Heap delta: ${(delta > 0 ? '+' : '')}${(delta / 1024 / 1024).toFixed(1)} MB`);
|
||||
}
|
||||
}
|
||||
|
||||
// Network summary
|
||||
console.log(`\n--- Network Summary ---`);
|
||||
console.log(` Total requests: ${networkResponses.length}`);
|
||||
const totalNetworkSize = networkResponses.reduce((s, r) => s + parseInt(r.size || '0'), 0);
|
||||
console.log(` Total response: ${(totalNetworkSize / 1024).toFixed(1)} KB`);
|
||||
const failedRequests = networkResponses.filter(r => r.status >= 400);
|
||||
if (failedRequests.length > 0) {
|
||||
console.log(` Failed requests: ${failedRequests.length}`);
|
||||
for (const r of failedRequests) {
|
||||
console.log(` [${r.status}] ${r.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.log(`\n ❌ Error during runtime analysis: ${err.message}`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ───
|
||||
console.log('╔═══════════════════════════════════════════════╗');
|
||||
console.log('║ OmniAI Web Preview - Performance Analysis ║');
|
||||
console.log('╚═══════════════════════════════════════════════╝');
|
||||
|
||||
const bundleResult = analyzeBundles();
|
||||
await runtimeAnalysis();
|
||||
|
||||
console.log('\n═══════════════════════════════════════════════');
|
||||
console.log(' ANALYSIS COMPLETE');
|
||||
console.log('═══════════════════════════════════════════════');
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const failures = [];
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function assertMatch(label, content, pattern) {
|
||||
if (!pattern.test(content)) {
|
||||
failures.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoMatch(label, content, pattern) {
|
||||
if (pattern.test(content)) {
|
||||
failures.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
const serverConnection = read("src/api/serverConnection.ts");
|
||||
const generationClient = read("src/api/aiGenerationClient.ts");
|
||||
const ecommerceVideoService = read("src/features/ecommerce/ecommerceVideoService.ts");
|
||||
const workbenchPersistence = read("src/features/workbench/workbenchResultPersistence.ts");
|
||||
|
||||
assertMatch(
|
||||
"serverConnection must build same-origin /api URLs",
|
||||
serverConnection,
|
||||
/return\s+`\/api\/\$\{cleanPath\}`;/,
|
||||
);
|
||||
assertNoMatch(
|
||||
"frontend generation flow must not use fixed VITE environment config",
|
||||
`${serverConnection}\n${generationClient}`,
|
||||
/\b(?:import\.meta\.env|VITE_[A-Z0-9_]+)\b/,
|
||||
);
|
||||
assertNoMatch(
|
||||
"frontend generation flow must not call provider hosts directly",
|
||||
generationClient,
|
||||
/dashscope\.aliyuncs\.com|\/dashscope-api\b|Bearer\s+sk-/i,
|
||||
);
|
||||
assertMatch("image generation must go through the app API", generationClient, /buildApiUrl\("ai\/image"\)/);
|
||||
assertMatch("video generation must go through the app API", generationClient, /serverRequest<\{ taskId: string \}>\("ai\/video"/);
|
||||
assertMatch("binary uploads must go through the app OSS API", generationClient, /buildApiUrl\("oss\/upload-binary"\)/);
|
||||
assertMatch("URL uploads must go through the app OSS API", generationClient, /serverRequest<\{ url: string; signedUrl\?: string; ossKey\?: string \}>\("oss\/upload-by-url"/);
|
||||
assertMatch(
|
||||
"ecommerce video history must durable-copy media before saving",
|
||||
ecommerceVideoService,
|
||||
/buildDurableVideoHistoryPayload\(payload\)/,
|
||||
);
|
||||
assertMatch(
|
||||
"ecommerce video history must filter temporary provider URLs on read",
|
||||
ecommerceVideoService,
|
||||
/items:\s*history\.items\.map\(removeTemporaryHistoryUrls\)/,
|
||||
);
|
||||
assertMatch(
|
||||
"workbench results must persist generated media through OSS",
|
||||
workbenchPersistence,
|
||||
/uploadAssetByUrl\(/,
|
||||
);
|
||||
|
||||
if (failures.length) {
|
||||
console.error("Mocked generation smoke check failed:");
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Mocked generation smoke check passed.");
|
||||
@@ -0,0 +1,148 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
const SRC = join(import.meta.dirname, '..', 'src');
|
||||
const results = [];
|
||||
|
||||
function walk(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory() && entry.name !== 'node_modules') walk(full);
|
||||
else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
||||
const content = readFileSync(full, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
results.push({ file: relative(join(SRC, '..'), full), lines, content });
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(SRC);
|
||||
results.sort((a, b) => b.lines - a.lines);
|
||||
|
||||
console.log('=== TOP 30 FILES BY LINE COUNT ===');
|
||||
for (const r of results.slice(0, 30)) {
|
||||
console.log(`${String(r.lines).padStart(5)} ${r.file}`);
|
||||
}
|
||||
|
||||
// Detect nested loops (3+ levels)
|
||||
console.log('\n=== NESTED LOOP DETECTION (3+ levels) ===');
|
||||
const loopPatterns = [
|
||||
/for\s*\(/g, /while\s*\(/g, /\.forEach\s*\(/g, /\.map\s*\(/g,
|
||||
/\.filter\s*\(/g, /\.reduce\s*\(/g, /\.some\s*\(/g, /\.every\s*\(/g,
|
||||
/\.flatMap\s*\(/g, /\.find\s*\(/g
|
||||
];
|
||||
|
||||
for (const r of results) {
|
||||
const lines = r.content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
let loopCount = 0;
|
||||
for (const p of loopPatterns) {
|
||||
p.lastIndex = 0;
|
||||
if (p.test(line)) loopCount++;
|
||||
}
|
||||
// Check surrounding context for nesting
|
||||
if (loopCount > 0 || /for\s*\(/.test(line) || /\.map\(/.test(line) || /\.forEach\(/.test(line) || /\.filter\(/.test(line) || /\.reduce\(/.test(line)) {
|
||||
// Count loop keywords on this single line
|
||||
let singleLineLoops = 0;
|
||||
for (const p of loopPatterns) {
|
||||
p.lastIndex = 0;
|
||||
const matches = line.match(new RegExp(p.source, 'g'));
|
||||
if (matches) singleLineLoops += matches.length;
|
||||
}
|
||||
if (singleLineLoops >= 2) {
|
||||
console.log(` [NESTED] ${r.file}:${i + 1} (${singleLineLoops} loops on one line)`);
|
||||
console.log(` ${line.trim().substring(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect missing cleanup in useEffect
|
||||
console.log('\n=== MEMORY LEAK RISK: useEffect without cleanup ===');
|
||||
for (const r of results) {
|
||||
if (!r.file.endsWith('.tsx') && !r.file.endsWith('.ts')) continue;
|
||||
const content = r.content;
|
||||
// Find useEffect blocks that contain setInterval/setTimeout/addEventListener but no return
|
||||
const useEffectRegex = /useEffect\s*\(\s*\(\)\s*=>\s*\{([\s\S]*?)\}\s*,/g;
|
||||
let match;
|
||||
while ((match = useEffectRegex.exec(content)) !== null) {
|
||||
const body = match[1];
|
||||
const hasTimer = /setInterval|setTimeout/.test(body);
|
||||
const hasListener = /addEventListener/.test(body);
|
||||
const hasSubscribe = /\.subscribe\(/.test(body);
|
||||
const hasCleanup = /return\s*\(\)\s*=>|return\s*function|return\s*\(\{/.test(body);
|
||||
|
||||
if ((hasTimer || hasListener || hasSubscribe) && !hasCleanup) {
|
||||
const lineNum = content.substring(0, match.index).split('\n').length;
|
||||
console.log(` [RISK] ${r.file}:${lineNum}`);
|
||||
if (hasTimer) console.log(` - Has setInterval/setTimeout without cleanup`);
|
||||
if (hasListener) console.log(` - Has addEventListener without cleanup`);
|
||||
if (hasSubscribe) console.log(` - Has subscribe without cleanup`);
|
||||
console.log(` ${body.trim().substring(0, 200)}`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect objects/arrays/functions created in render body (not memoized)
|
||||
console.log('\n=== REDUNDANT COMPUTATION: Non-memoized values in components ===');
|
||||
for (const r of results) {
|
||||
if (!r.file.endsWith('.tsx')) continue;
|
||||
const lines = r.content.split('\n');
|
||||
// Look for const x = [...], const x = {...}, const x = (...) => patterns outside useMemo
|
||||
let inUseMemo = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/useMemo\s*\(/.test(lines[i])) inUseMemo++;
|
||||
if (inUseMemo > 0 && /\)/.test(lines[i])) {
|
||||
// Rough heuristic - not perfect
|
||||
}
|
||||
if (inUseMemo === 0) {
|
||||
// Expensive operations in render
|
||||
if (/\.map\s*\(.*\.map\s*\(/.test(lines[i])) {
|
||||
console.log(` [PERF] ${r.file}:${i + 1} - Chained .map calls in render`);
|
||||
console.log(` ${lines[i].trim().substring(0, 120)}`);
|
||||
}
|
||||
if (/\.filter\s*\(.*\.map\s*\(/.test(lines[i]) || /\.map\s*\(.*\.filter\s*\(/.test(lines[i])) {
|
||||
console.log(` [PERF] ${r.file}:${i + 1} - filter+map chain in render`);
|
||||
console.log(` ${lines[i].trim().substring(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect deeply nested conditionals (4+ levels)
|
||||
console.log('\n=== HIGH COMPLEXITY: Deep nesting ===');
|
||||
for (const r of results) {
|
||||
const lines = r.content.split('\n');
|
||||
let maxIndent = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') continue;
|
||||
const indent = line.match(/^(\s*)/)[1].length;
|
||||
// Only flag if inside if/else/switch/ternary
|
||||
if (indent >= 16 && /if\s*\(|else|switch\s*\(|case\s+/.test(line.trim())) {
|
||||
console.log(` [DEEP] ${r.file}:${i + 1} (indent=${indent})`);
|
||||
console.log(` ${line.trim().substring(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect inline style objects in JSX (recreated every render)
|
||||
console.log('\n=== REDUNDANT: Inline style objects in JSX ===');
|
||||
for (const r of results) {
|
||||
if (!r.file.endsWith('.tsx')) continue;
|
||||
const lines = r.content.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/style\s*=\s*\{\s*\{/.test(lines[i]) && !/useMemo/.test(lines[i])) {
|
||||
console.log(` [INLINE] ${r.file}:${i + 1}`);
|
||||
console.log(` ${lines[i].trim().substring(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Total stats
|
||||
console.log('\n=== SUMMARY ===');
|
||||
console.log(`Total files: ${results.length}`);
|
||||
console.log(`Total lines: ${results.reduce((s, r) => s + r.lines, 0)}`);
|
||||
console.log(`Files > 500 lines: ${results.filter(r => r.lines > 500).length}`);
|
||||
console.log(`Files > 1000 lines: ${results.filter(r => r.lines > 1000).length}`);
|
||||
Reference in New Issue
Block a user