@agent-scope/cli 1.10.0 → 1.12.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/dist/cli.js +1305 -179
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1341 -241
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +264 -1
- package/dist/index.d.ts +264 -1
- package/dist/index.js +1340 -243
- package/dist/index.js.map +1 -1
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -2,13 +2,13 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, rea
|
|
|
2
2
|
import { join, resolve, dirname } from 'path';
|
|
3
3
|
import * as readline from 'readline';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
+
import { safeRender, ALL_CONTEXT_IDS, contextAxis, stressAxis, ALL_STRESS_IDS, RenderMatrix, BrowserPool, SatoriRenderer } from '@agent-scope/render';
|
|
6
|
+
import * as esbuild from 'esbuild';
|
|
5
7
|
import { generateManifest } from '@agent-scope/manifest';
|
|
6
|
-
import { loadTrace, generateTest, getBrowserEntryScript } from '@agent-scope/playwright';
|
|
7
8
|
import { chromium } from 'playwright';
|
|
8
|
-
import {
|
|
9
|
-
import * as esbuild from 'esbuild';
|
|
9
|
+
import { loadTrace, generateTest, getBrowserEntryScript } from '@agent-scope/playwright';
|
|
10
10
|
import { createRequire } from 'module';
|
|
11
|
-
import { TokenResolver, validateTokenFile, TokenValidationError,
|
|
11
|
+
import { parseTokenFileSync, TokenResolver, ThemeResolver, exportTokens, validateTokenFile, TokenValidationError, TokenParseError, ComplianceEngine } from '@agent-scope/tokens';
|
|
12
12
|
|
|
13
13
|
// src/init/index.ts
|
|
14
14
|
function hasConfigFile(dir, stem) {
|
|
@@ -205,9 +205,9 @@ function createRL() {
|
|
|
205
205
|
});
|
|
206
206
|
}
|
|
207
207
|
async function ask(rl, question) {
|
|
208
|
-
return new Promise((
|
|
208
|
+
return new Promise((resolve11) => {
|
|
209
209
|
rl.question(question, (answer) => {
|
|
210
|
-
|
|
210
|
+
resolve11(answer.trim());
|
|
211
211
|
});
|
|
212
212
|
});
|
|
213
213
|
}
|
|
@@ -354,6 +354,114 @@ function createInitCommand() {
|
|
|
354
354
|
}
|
|
355
355
|
});
|
|
356
356
|
}
|
|
357
|
+
async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss) {
|
|
358
|
+
const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
|
|
359
|
+
return wrapInHtml(bundledScript, viewportWidth);
|
|
360
|
+
}
|
|
361
|
+
async function bundleComponentToIIFE(filePath, componentName, props) {
|
|
362
|
+
const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
|
|
363
|
+
const wrapperCode = (
|
|
364
|
+
/* ts */
|
|
365
|
+
`
|
|
366
|
+
import * as __scopeMod from ${JSON.stringify(filePath)};
|
|
367
|
+
import { createRoot } from "react-dom/client";
|
|
368
|
+
import { createElement } from "react";
|
|
369
|
+
|
|
370
|
+
(function scopeRenderHarness() {
|
|
371
|
+
var Component =
|
|
372
|
+
__scopeMod["default"] ||
|
|
373
|
+
__scopeMod[${JSON.stringify(componentName)}] ||
|
|
374
|
+
(Object.values(__scopeMod).find(
|
|
375
|
+
function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
|
|
376
|
+
));
|
|
377
|
+
|
|
378
|
+
if (!Component) {
|
|
379
|
+
window.__SCOPE_RENDER_ERROR__ =
|
|
380
|
+
"No renderable component found. Checked: default, " +
|
|
381
|
+
${JSON.stringify(componentName)} + ", and PascalCase named exports. " +
|
|
382
|
+
"Available exports: " + Object.keys(__scopeMod).join(", ");
|
|
383
|
+
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
var props = ${propsJson};
|
|
389
|
+
var rootEl = document.getElementById("scope-root");
|
|
390
|
+
if (!rootEl) {
|
|
391
|
+
window.__SCOPE_RENDER_ERROR__ = "#scope-root element not found";
|
|
392
|
+
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
createRoot(rootEl).render(createElement(Component, props));
|
|
396
|
+
// Use requestAnimationFrame to let React flush the render
|
|
397
|
+
requestAnimationFrame(function() {
|
|
398
|
+
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
399
|
+
});
|
|
400
|
+
} catch (err) {
|
|
401
|
+
window.__SCOPE_RENDER_ERROR__ = err instanceof Error ? err.message : String(err);
|
|
402
|
+
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
403
|
+
}
|
|
404
|
+
})();
|
|
405
|
+
`
|
|
406
|
+
);
|
|
407
|
+
const result = await esbuild.build({
|
|
408
|
+
stdin: {
|
|
409
|
+
contents: wrapperCode,
|
|
410
|
+
// Resolve relative imports (within the component's dir)
|
|
411
|
+
resolveDir: dirname(filePath),
|
|
412
|
+
loader: "tsx",
|
|
413
|
+
sourcefile: "__scope_harness__.tsx"
|
|
414
|
+
},
|
|
415
|
+
bundle: true,
|
|
416
|
+
format: "iife",
|
|
417
|
+
write: false,
|
|
418
|
+
platform: "browser",
|
|
419
|
+
jsx: "automatic",
|
|
420
|
+
jsxImportSource: "react",
|
|
421
|
+
target: "es2020",
|
|
422
|
+
// Bundle everything — no externals
|
|
423
|
+
external: [],
|
|
424
|
+
define: {
|
|
425
|
+
"process.env.NODE_ENV": '"development"',
|
|
426
|
+
global: "globalThis"
|
|
427
|
+
},
|
|
428
|
+
logLevel: "silent",
|
|
429
|
+
// Suppress "React must be in scope" warnings from old JSX (we use automatic)
|
|
430
|
+
banner: {
|
|
431
|
+
js: "/* @agent-scope/cli component harness */"
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
if (result.errors.length > 0) {
|
|
435
|
+
const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
|
|
436
|
+
throw new Error(`esbuild failed to bundle component:
|
|
437
|
+
${msg}`);
|
|
438
|
+
}
|
|
439
|
+
const outputFile = result.outputFiles?.[0];
|
|
440
|
+
if (outputFile === void 0 || outputFile.text.length === 0) {
|
|
441
|
+
throw new Error("esbuild produced no output");
|
|
442
|
+
}
|
|
443
|
+
return outputFile.text;
|
|
444
|
+
}
|
|
445
|
+
function wrapInHtml(bundledScript, viewportWidth, projectCss) {
|
|
446
|
+
const projectStyleBlock = "";
|
|
447
|
+
return `<!DOCTYPE html>
|
|
448
|
+
<html lang="en">
|
|
449
|
+
<head>
|
|
450
|
+
<meta charset="UTF-8" />
|
|
451
|
+
<meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
|
|
452
|
+
<style>
|
|
453
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
454
|
+
html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
|
|
455
|
+
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
|
|
456
|
+
</style>
|
|
457
|
+
${projectStyleBlock}
|
|
458
|
+
</head>
|
|
459
|
+
<body>
|
|
460
|
+
<div id="scope-root" data-reactscope-root></div>
|
|
461
|
+
<script>${bundledScript}</script>
|
|
462
|
+
</body>
|
|
463
|
+
</html>`;
|
|
464
|
+
}
|
|
357
465
|
|
|
358
466
|
// src/manifest-formatter.ts
|
|
359
467
|
function isTTY() {
|
|
@@ -621,168 +729,20 @@ function createManifestCommand() {
|
|
|
621
729
|
registerGenerate(manifestCmd);
|
|
622
730
|
return manifestCmd;
|
|
623
731
|
}
|
|
624
|
-
async function browserCapture(options) {
|
|
625
|
-
const { url, timeout = 1e4, wait = 0 } = options;
|
|
626
|
-
const browser = await chromium.launch({ headless: true });
|
|
627
|
-
try {
|
|
628
|
-
const context = await browser.newContext();
|
|
629
|
-
const page = await context.newPage();
|
|
630
|
-
await page.addInitScript({ content: getBrowserEntryScript() });
|
|
631
|
-
await page.goto(url, {
|
|
632
|
-
waitUntil: "networkidle",
|
|
633
|
-
timeout: timeout + 5e3
|
|
634
|
-
});
|
|
635
|
-
await page.waitForFunction(
|
|
636
|
-
() => {
|
|
637
|
-
return typeof window.__SCOPE_CAPTURE__ === "function" && (document.readyState === "complete" || document.readyState === "interactive");
|
|
638
|
-
},
|
|
639
|
-
{ timeout }
|
|
640
|
-
);
|
|
641
|
-
if (wait > 0) {
|
|
642
|
-
await page.waitForTimeout(wait);
|
|
643
|
-
}
|
|
644
|
-
const raw = await page.evaluate(async () => {
|
|
645
|
-
const win = window;
|
|
646
|
-
if (typeof win.__SCOPE_CAPTURE__ !== "function") {
|
|
647
|
-
throw new Error("Scope runtime not injected");
|
|
648
|
-
}
|
|
649
|
-
return win.__SCOPE_CAPTURE__();
|
|
650
|
-
});
|
|
651
|
-
if (raw !== null && typeof raw === "object" && "error" in raw && typeof raw.error === "string") {
|
|
652
|
-
throw new Error(`Scope capture failed: ${raw.error}`);
|
|
653
|
-
}
|
|
654
|
-
const report = { ...raw, route: null };
|
|
655
|
-
return { report };
|
|
656
|
-
} finally {
|
|
657
|
-
await browser.close();
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
function writeReportToFile(report, outputPath, pretty) {
|
|
661
|
-
const json = pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
|
|
662
|
-
writeFileSync(outputPath, json, "utf-8");
|
|
663
|
-
}
|
|
664
|
-
async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss) {
|
|
665
|
-
const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
|
|
666
|
-
return wrapInHtml(bundledScript, viewportWidth);
|
|
667
|
-
}
|
|
668
|
-
async function bundleComponentToIIFE(filePath, componentName, props) {
|
|
669
|
-
const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
|
|
670
|
-
const wrapperCode = (
|
|
671
|
-
/* ts */
|
|
672
|
-
`
|
|
673
|
-
import * as __scopeMod from ${JSON.stringify(filePath)};
|
|
674
|
-
import { createRoot } from "react-dom/client";
|
|
675
|
-
import { createElement } from "react";
|
|
676
|
-
|
|
677
|
-
(function scopeRenderHarness() {
|
|
678
|
-
var Component =
|
|
679
|
-
__scopeMod["default"] ||
|
|
680
|
-
__scopeMod[${JSON.stringify(componentName)}] ||
|
|
681
|
-
(Object.values(__scopeMod).find(
|
|
682
|
-
function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
|
|
683
|
-
));
|
|
684
|
-
|
|
685
|
-
if (!Component) {
|
|
686
|
-
window.__SCOPE_RENDER_ERROR__ =
|
|
687
|
-
"No renderable component found. Checked: default, " +
|
|
688
|
-
${JSON.stringify(componentName)} + ", and PascalCase named exports. " +
|
|
689
|
-
"Available exports: " + Object.keys(__scopeMod).join(", ");
|
|
690
|
-
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
732
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
createRoot(rootEl).render(createElement(Component, props));
|
|
703
|
-
// Use requestAnimationFrame to let React flush the render
|
|
704
|
-
requestAnimationFrame(function() {
|
|
705
|
-
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
706
|
-
});
|
|
707
|
-
} catch (err) {
|
|
708
|
-
window.__SCOPE_RENDER_ERROR__ = err instanceof Error ? err.message : String(err);
|
|
709
|
-
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
710
|
-
}
|
|
711
|
-
})();
|
|
712
|
-
`
|
|
713
|
-
);
|
|
714
|
-
const result = await esbuild.build({
|
|
715
|
-
stdin: {
|
|
716
|
-
contents: wrapperCode,
|
|
717
|
-
// Resolve relative imports (within the component's dir)
|
|
718
|
-
resolveDir: dirname(filePath),
|
|
719
|
-
loader: "tsx",
|
|
720
|
-
sourcefile: "__scope_harness__.tsx"
|
|
721
|
-
},
|
|
722
|
-
bundle: true,
|
|
723
|
-
format: "iife",
|
|
724
|
-
write: false,
|
|
725
|
-
platform: "browser",
|
|
726
|
-
jsx: "automatic",
|
|
727
|
-
jsxImportSource: "react",
|
|
728
|
-
target: "es2020",
|
|
729
|
-
// Bundle everything — no externals
|
|
730
|
-
external: [],
|
|
731
|
-
define: {
|
|
732
|
-
"process.env.NODE_ENV": '"development"',
|
|
733
|
-
global: "globalThis"
|
|
734
|
-
},
|
|
735
|
-
logLevel: "silent",
|
|
736
|
-
// Suppress "React must be in scope" warnings from old JSX (we use automatic)
|
|
737
|
-
banner: {
|
|
738
|
-
js: "/* @agent-scope/cli component harness */"
|
|
739
|
-
}
|
|
740
|
-
});
|
|
741
|
-
if (result.errors.length > 0) {
|
|
742
|
-
const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
|
|
743
|
-
throw new Error(`esbuild failed to bundle component:
|
|
744
|
-
${msg}`);
|
|
733
|
+
// src/render-formatter.ts
|
|
734
|
+
function parseViewport(spec) {
|
|
735
|
+
const lower = spec.toLowerCase();
|
|
736
|
+
const match = /^(\d+)[x×](\d+)$/.exec(lower);
|
|
737
|
+
if (!match) {
|
|
738
|
+
throw new Error(`Invalid viewport "${spec}". Expected format: WIDTHxHEIGHT (e.g. 1280x720)`);
|
|
745
739
|
}
|
|
746
|
-
const
|
|
747
|
-
|
|
748
|
-
|
|
740
|
+
const width = parseInt(match[1] ?? "0", 10);
|
|
741
|
+
const height = parseInt(match[2] ?? "0", 10);
|
|
742
|
+
if (width <= 0 || height <= 0) {
|
|
743
|
+
throw new Error(`Viewport dimensions must be positive integers, got: ${spec}`);
|
|
749
744
|
}
|
|
750
|
-
return
|
|
751
|
-
}
|
|
752
|
-
function wrapInHtml(bundledScript, viewportWidth, projectCss) {
|
|
753
|
-
const projectStyleBlock = "";
|
|
754
|
-
return `<!DOCTYPE html>
|
|
755
|
-
<html lang="en">
|
|
756
|
-
<head>
|
|
757
|
-
<meta charset="UTF-8" />
|
|
758
|
-
<meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
|
|
759
|
-
<style>
|
|
760
|
-
*, *::before, *::after { box-sizing: border-box; }
|
|
761
|
-
html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
|
|
762
|
-
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
|
|
763
|
-
</style>
|
|
764
|
-
${projectStyleBlock}
|
|
765
|
-
</head>
|
|
766
|
-
<body>
|
|
767
|
-
<div id="scope-root" data-reactscope-root></div>
|
|
768
|
-
<script>${bundledScript}</script>
|
|
769
|
-
</body>
|
|
770
|
-
</html>`;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// src/render-formatter.ts
|
|
774
|
-
function parseViewport(spec) {
|
|
775
|
-
const lower = spec.toLowerCase();
|
|
776
|
-
const match = /^(\d+)[x×](\d+)$/.exec(lower);
|
|
777
|
-
if (!match) {
|
|
778
|
-
throw new Error(`Invalid viewport "${spec}". Expected format: WIDTHxHEIGHT (e.g. 1280x720)`);
|
|
779
|
-
}
|
|
780
|
-
const width = parseInt(match[1] ?? "0", 10);
|
|
781
|
-
const height = parseInt(match[2] ?? "0", 10);
|
|
782
|
-
if (width <= 0 || height <= 0) {
|
|
783
|
-
throw new Error(`Viewport dimensions must be positive integers, got: ${spec}`);
|
|
784
|
-
}
|
|
785
|
-
return { width, height };
|
|
745
|
+
return { width, height };
|
|
786
746
|
}
|
|
787
747
|
function formatRenderJson(componentName, props, result) {
|
|
788
748
|
return {
|
|
@@ -917,21 +877,1011 @@ function formatSummaryText(results, outputDir) {
|
|
|
917
877
|
}
|
|
918
878
|
}
|
|
919
879
|
}
|
|
920
|
-
lines.push("\u2500".repeat(60));
|
|
880
|
+
lines.push("\u2500".repeat(60));
|
|
881
|
+
return lines.join("\n");
|
|
882
|
+
}
|
|
883
|
+
function escapeHtml(str) {
|
|
884
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
885
|
+
}
|
|
886
|
+
function csvEscape(value) {
|
|
887
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
888
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
889
|
+
}
|
|
890
|
+
return value;
|
|
891
|
+
}
|
|
892
|
+
var MANIFEST_PATH2 = ".reactscope/manifest.json";
|
|
893
|
+
function buildHookInstrumentationScript() {
|
|
894
|
+
return `
|
|
895
|
+
(function __scopeHooksInstrument() {
|
|
896
|
+
// Locate the React DevTools hook installed by the browser-entry bundle.
|
|
897
|
+
// We use a lighter approach: walk __REACT_DEVTOOLS_GLOBAL_HOOK__ renderers.
|
|
898
|
+
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
899
|
+
if (!hook) return { error: "No React DevTools hook found" };
|
|
900
|
+
|
|
901
|
+
var renderers = hook._renderers || hook.renderers;
|
|
902
|
+
if (!renderers || renderers.size === 0) return { error: "No React renderers registered" };
|
|
903
|
+
|
|
904
|
+
// Get the first renderer's fiber roots
|
|
905
|
+
var renderer = null;
|
|
906
|
+
if (renderers.forEach) {
|
|
907
|
+
renderers.forEach(function(r) { if (!renderer) renderer = r; });
|
|
908
|
+
} else {
|
|
909
|
+
renderer = Object.values(renderers)[0];
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Handle both our wrapped format {id, renderer, fiberRoots} and raw renderer
|
|
913
|
+
var fiberRoots;
|
|
914
|
+
if (renderer && renderer.fiberRoots) {
|
|
915
|
+
fiberRoots = renderer.fiberRoots;
|
|
916
|
+
} else {
|
|
917
|
+
return { error: "No fiber roots found" };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
var rootFiber = null;
|
|
921
|
+
fiberRoots.forEach(function(r) { if (!rootFiber) rootFiber = r; });
|
|
922
|
+
if (!rootFiber) return { error: "No fiber root" };
|
|
923
|
+
|
|
924
|
+
var current = rootFiber.current;
|
|
925
|
+
if (!current) return { error: "No current fiber" };
|
|
926
|
+
|
|
927
|
+
// Walk fiber tree and collect hook data per component
|
|
928
|
+
var components = [];
|
|
929
|
+
|
|
930
|
+
function serializeValue(v) {
|
|
931
|
+
if (v === null) return null;
|
|
932
|
+
if (v === undefined) return undefined;
|
|
933
|
+
var t = typeof v;
|
|
934
|
+
if (t === "string" || t === "number" || t === "boolean") return v;
|
|
935
|
+
if (t === "function") return "[function " + (v.name || "anonymous") + "]";
|
|
936
|
+
if (Array.isArray(v)) {
|
|
937
|
+
try { return v.map(serializeValue); } catch(e) { return "[Array]"; }
|
|
938
|
+
}
|
|
939
|
+
if (t === "object") {
|
|
940
|
+
try {
|
|
941
|
+
var keys = Object.keys(v).slice(0, 5);
|
|
942
|
+
var out = {};
|
|
943
|
+
for (var k of keys) { out[k] = serializeValue(v[k]); }
|
|
944
|
+
if (Object.keys(v).length > 5) out["..."] = "(truncated)";
|
|
945
|
+
return out;
|
|
946
|
+
} catch(e) { return "[Object]"; }
|
|
947
|
+
}
|
|
948
|
+
return String(v);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Hook node classifiers (mirrors hooks-extractor.ts logic)
|
|
952
|
+
var HookLayout = 0b00100;
|
|
953
|
+
|
|
954
|
+
function isEffectNode(node) {
|
|
955
|
+
var ms = node.memoizedState;
|
|
956
|
+
if (!ms || typeof ms !== "object") return false;
|
|
957
|
+
return typeof ms.create === "function" && "deps" in ms && typeof ms.tag === "number";
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function isRefNode(node) {
|
|
961
|
+
if (node.queue != null) return false;
|
|
962
|
+
var ms = node.memoizedState;
|
|
963
|
+
if (!ms || typeof ms !== "object" || Array.isArray(ms)) return false;
|
|
964
|
+
var keys = Object.keys(ms);
|
|
965
|
+
return keys.length === 1 && keys[0] === "current";
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function isMemoTuple(node) {
|
|
969
|
+
if (node.queue != null) return false;
|
|
970
|
+
var ms = node.memoizedState;
|
|
971
|
+
if (!Array.isArray(ms) || ms.length !== 2) return false;
|
|
972
|
+
return ms[1] === null || Array.isArray(ms[1]);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function isStateOrReducer(node) {
|
|
976
|
+
return node.queue != null &&
|
|
977
|
+
typeof node.queue === "object" &&
|
|
978
|
+
typeof node.queue.dispatch === "function";
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function isReducer(node) {
|
|
982
|
+
if (!isStateOrReducer(node)) return false;
|
|
983
|
+
var q = node.queue;
|
|
984
|
+
if (typeof q.reducer === "function") return true;
|
|
985
|
+
var lrr = q.lastRenderedReducer;
|
|
986
|
+
if (typeof lrr !== "function") return false;
|
|
987
|
+
var name = lrr.name || "";
|
|
988
|
+
return name !== "basicStateReducer" && name !== "";
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function classifyHookNode(node, index) {
|
|
992
|
+
var profile = { index: index, type: "custom" };
|
|
993
|
+
|
|
994
|
+
if (isEffectNode(node)) {
|
|
995
|
+
var effect = node.memoizedState;
|
|
996
|
+
profile.type = (effect.tag & HookLayout) ? "useLayoutEffect" : "useEffect";
|
|
997
|
+
profile.dependencyValues = effect.deps ? effect.deps.map(serializeValue) : null;
|
|
998
|
+
profile.cleanupPresence = typeof effect.destroy === "function";
|
|
999
|
+
profile.fireCount = 1; // We can only observe the mount; runtime tracking would need injection
|
|
1000
|
+
profile.lastDuration = null;
|
|
1001
|
+
return profile;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
if (isRefNode(node)) {
|
|
1005
|
+
var ref = node.memoizedState;
|
|
1006
|
+
profile.type = "useRef";
|
|
1007
|
+
profile.currentRefValue = serializeValue(ref.current);
|
|
1008
|
+
profile.readCountDuringRender = 0; // static snapshot; read count requires instrumented wrapper
|
|
1009
|
+
return profile;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (isMemoTuple(node)) {
|
|
1013
|
+
var tuple = node.memoizedState;
|
|
1014
|
+
var val = tuple[0];
|
|
1015
|
+
var deps = tuple[1];
|
|
1016
|
+
profile.type = typeof val === "function" ? "useCallback" : "useMemo";
|
|
1017
|
+
profile.currentValue = serializeValue(val);
|
|
1018
|
+
profile.dependencyValues = deps ? deps.map(serializeValue) : null;
|
|
1019
|
+
// recomputeCount cannot be known from a single snapshot; set to 0 for mount
|
|
1020
|
+
profile.recomputeCount = 0;
|
|
1021
|
+
// On mount, first render always computes \u2192 cacheHitRate is 0 (no prior hits)
|
|
1022
|
+
profile.cacheHitRate = 0;
|
|
1023
|
+
return profile;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (isStateOrReducer(node)) {
|
|
1027
|
+
if (isReducer(node)) {
|
|
1028
|
+
profile.type = "useReducer";
|
|
1029
|
+
profile.currentValue = serializeValue(node.memoizedState);
|
|
1030
|
+
profile.updateCount = 0;
|
|
1031
|
+
profile.actionTypesDispatched = [];
|
|
1032
|
+
// stateTransitions: we record current state as first entry
|
|
1033
|
+
profile.stateTransitions = [serializeValue(node.memoizedState)];
|
|
1034
|
+
} else {
|
|
1035
|
+
profile.type = "useState";
|
|
1036
|
+
profile.currentValue = serializeValue(node.memoizedState);
|
|
1037
|
+
profile.updateCount = 0;
|
|
1038
|
+
profile.updateOrigins = [];
|
|
1039
|
+
}
|
|
1040
|
+
return profile;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// useContext: detected via _currentValue / _currentValue2 property (React context internals)
|
|
1044
|
+
// Context consumers in React store the context VALUE in memoizedState directly, not
|
|
1045
|
+
// via a queue. Context objects themselves have _currentValue.
|
|
1046
|
+
// We check if memoizedState could be a context value by seeing if node has no queue
|
|
1047
|
+
// and memoizedState is not an array and not an effect.
|
|
1048
|
+
if (!node.queue && !isRefNode(node) && !isMemoTuple(node) && !isEffectNode(node)) {
|
|
1049
|
+
profile.type = "custom";
|
|
1050
|
+
profile.currentValue = serializeValue(node.memoizedState);
|
|
1051
|
+
return profile;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return profile;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Walk the fiber tree
|
|
1058
|
+
var FunctionComponent = 0;
|
|
1059
|
+
var ClassComponent = 1;
|
|
1060
|
+
var ForwardRef = 11;
|
|
1061
|
+
var MemoComponent = 14;
|
|
1062
|
+
var SimpleMemoComponent = 15;
|
|
1063
|
+
var HostRoot = 3;
|
|
1064
|
+
|
|
1065
|
+
function getFiberName(fiber) {
|
|
1066
|
+
if (!fiber.type) return null;
|
|
1067
|
+
if (typeof fiber.type === "string") return fiber.type;
|
|
1068
|
+
if (typeof fiber.type === "function") return fiber.type.displayName || fiber.type.name || "Anonymous";
|
|
1069
|
+
if (fiber.type.displayName) return fiber.type.displayName;
|
|
1070
|
+
if (fiber.type.render) {
|
|
1071
|
+
return fiber.type.render.displayName || fiber.type.render.name || "ForwardRef";
|
|
1072
|
+
}
|
|
1073
|
+
if (fiber.type.type) {
|
|
1074
|
+
return (fiber.type.type.displayName || fiber.type.type.name || "Memo");
|
|
1075
|
+
}
|
|
1076
|
+
return "Unknown";
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function isComponentFiber(fiber) {
|
|
1080
|
+
var tag = fiber.tag;
|
|
1081
|
+
return tag === FunctionComponent || tag === ClassComponent ||
|
|
1082
|
+
tag === ForwardRef || tag === MemoComponent || tag === SimpleMemoComponent;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function walkFiber(fiber) {
|
|
1086
|
+
if (!fiber) return;
|
|
1087
|
+
|
|
1088
|
+
if (isComponentFiber(fiber)) {
|
|
1089
|
+
var name = getFiberName(fiber);
|
|
1090
|
+
if (name) {
|
|
1091
|
+
var hooks = [];
|
|
1092
|
+
var hookNode = fiber.memoizedState;
|
|
1093
|
+
var idx = 0;
|
|
1094
|
+
while (hookNode !== null && hookNode !== undefined) {
|
|
1095
|
+
hooks.push(classifyHookNode(hookNode, idx));
|
|
1096
|
+
hookNode = hookNode.next;
|
|
1097
|
+
idx++;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
var source = null;
|
|
1101
|
+
if (fiber._debugSource) {
|
|
1102
|
+
source = { file: fiber._debugSource.fileName, line: fiber._debugSource.lineNumber };
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (hooks.length > 0) {
|
|
1106
|
+
components.push({ name: name, source: source, hooks: hooks });
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
walkFiber(fiber.child);
|
|
1112
|
+
walkFiber(fiber.sibling);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
walkFiber(current.child);
|
|
1116
|
+
|
|
1117
|
+
return { components: components };
|
|
1118
|
+
})();
|
|
1119
|
+
`;
|
|
1120
|
+
}
|
|
1121
|
+
function analyzeHookFlags(hooks) {
|
|
1122
|
+
const flags = /* @__PURE__ */ new Set();
|
|
1123
|
+
for (const hook of hooks) {
|
|
1124
|
+
if ((hook.type === "useEffect" || hook.type === "useLayoutEffect") && hook.dependencyValues !== void 0 && hook.dependencyValues === null) {
|
|
1125
|
+
flags.add("EFFECT_EVERY_RENDER");
|
|
1126
|
+
}
|
|
1127
|
+
if ((hook.type === "useEffect" || hook.type === "useLayoutEffect") && hook.cleanupPresence === false && hook.dependencyValues === null) {
|
|
1128
|
+
flags.add("MISSING_CLEANUP");
|
|
1129
|
+
}
|
|
1130
|
+
if ((hook.type === "useMemo" || hook.type === "useCallback") && hook.dependencyValues === null) {
|
|
1131
|
+
flags.add("MEMO_INEFFECTIVE");
|
|
1132
|
+
}
|
|
1133
|
+
if ((hook.type === "useMemo" || hook.type === "useCallback") && hook.cacheHitRate !== void 0 && hook.cacheHitRate === 0 && hook.recomputeCount !== void 0 && hook.recomputeCount > 1) {
|
|
1134
|
+
flags.add("MEMO_INEFFECTIVE");
|
|
1135
|
+
}
|
|
1136
|
+
if ((hook.type === "useState" || hook.type === "useReducer") && hook.updateCount !== void 0 && hook.updateCount > 10) {
|
|
1137
|
+
flags.add("STATE_UPDATE_LOOP");
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
return [...flags];
|
|
1141
|
+
}
|
|
1142
|
+
async function runHooksProfiling(componentName, filePath, props) {
|
|
1143
|
+
const browser = await chromium.launch({ headless: true });
|
|
1144
|
+
try {
|
|
1145
|
+
const context = await browser.newContext();
|
|
1146
|
+
const page = await context.newPage();
|
|
1147
|
+
const htmlHarness = await buildComponentHarness(filePath, componentName, props, 1280);
|
|
1148
|
+
await page.setContent(htmlHarness, { waitUntil: "load" });
|
|
1149
|
+
await page.waitForFunction(
|
|
1150
|
+
() => {
|
|
1151
|
+
const w = window;
|
|
1152
|
+
return w.__SCOPE_RENDER_COMPLETE__ === true;
|
|
1153
|
+
},
|
|
1154
|
+
{ timeout: 15e3 }
|
|
1155
|
+
);
|
|
1156
|
+
const renderError = await page.evaluate(() => {
|
|
1157
|
+
return window.__SCOPE_RENDER_ERROR__ ?? null;
|
|
1158
|
+
});
|
|
1159
|
+
if (renderError !== null) {
|
|
1160
|
+
throw new Error(`Component render error: ${renderError}`);
|
|
1161
|
+
}
|
|
1162
|
+
const instrumentScript = buildHookInstrumentationScript();
|
|
1163
|
+
const raw = await page.evaluate(instrumentScript);
|
|
1164
|
+
const result = raw;
|
|
1165
|
+
if (result.error) {
|
|
1166
|
+
throw new Error(`Hook instrumentation failed: ${result.error}`);
|
|
1167
|
+
}
|
|
1168
|
+
const rawComponents = result.components ?? [];
|
|
1169
|
+
const components = rawComponents.map((c) => ({
|
|
1170
|
+
name: c.name,
|
|
1171
|
+
source: c.source,
|
|
1172
|
+
hooks: c.hooks,
|
|
1173
|
+
flags: analyzeHookFlags(c.hooks)
|
|
1174
|
+
}));
|
|
1175
|
+
const allFlags = /* @__PURE__ */ new Set();
|
|
1176
|
+
for (const comp of components) {
|
|
1177
|
+
for (const flag of comp.flags) {
|
|
1178
|
+
allFlags.add(flag);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return {
|
|
1182
|
+
component: componentName,
|
|
1183
|
+
components,
|
|
1184
|
+
flags: [...allFlags]
|
|
1185
|
+
};
|
|
1186
|
+
} finally {
|
|
1187
|
+
await browser.close();
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
function createInstrumentHooksCommand() {
|
|
1191
|
+
const cmd = new Command("hooks").description(
|
|
1192
|
+
"Profile per-hook-instance data for a component: update counts, cache hit rates, effect counts, and more"
|
|
1193
|
+
).argument("<component>", "Component name (must exist in the manifest)").option("--props <json>", "Inline props JSON passed to the component", "{}").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH2).option("--format <fmt>", "Output format: json|text (default: auto)", "json").option("--show-flags", "Show heuristic flags only (useful for CI checks)", false).action(
|
|
1194
|
+
async (componentName, opts) => {
|
|
1195
|
+
try {
|
|
1196
|
+
const manifest = loadManifest(opts.manifest);
|
|
1197
|
+
const descriptor = manifest.components[componentName];
|
|
1198
|
+
if (descriptor === void 0) {
|
|
1199
|
+
const available = Object.keys(manifest.components).slice(0, 5).join(", ");
|
|
1200
|
+
throw new Error(
|
|
1201
|
+
`Component "${componentName}" not found in manifest.
|
|
1202
|
+
Available: ${available}`
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
let props = {};
|
|
1206
|
+
try {
|
|
1207
|
+
props = JSON.parse(opts.props);
|
|
1208
|
+
} catch {
|
|
1209
|
+
throw new Error(`Invalid props JSON: ${opts.props}`);
|
|
1210
|
+
}
|
|
1211
|
+
const rootDir = process.cwd();
|
|
1212
|
+
const filePath = resolve(rootDir, descriptor.filePath);
|
|
1213
|
+
process.stderr.write(`Instrumenting hooks for ${componentName}\u2026
|
|
1214
|
+
`);
|
|
1215
|
+
const result = await runHooksProfiling(componentName, filePath, props);
|
|
1216
|
+
if (opts.showFlags) {
|
|
1217
|
+
if (result.flags.length === 0) {
|
|
1218
|
+
process.stdout.write("No heuristic flags detected.\n");
|
|
1219
|
+
} else {
|
|
1220
|
+
for (const flag of result.flags) {
|
|
1221
|
+
process.stdout.write(`${flag}
|
|
1222
|
+
`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1228
|
+
`);
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1231
|
+
`);
|
|
1232
|
+
process.exit(1);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
);
|
|
1236
|
+
return cmd;
|
|
1237
|
+
}
|
|
1238
|
+
var MANIFEST_PATH3 = ".reactscope/manifest.json";
|
|
1239
|
+
function buildProfilingSetupScript() {
|
|
1240
|
+
return `
|
|
1241
|
+
(function __scopeProfileSetup() {
|
|
1242
|
+
window.__scopeProfileData = {
|
|
1243
|
+
commitCount: 0,
|
|
1244
|
+
componentNames: new Set(),
|
|
1245
|
+
fiberSnapshots: []
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
1249
|
+
if (!hook) return { error: "No DevTools hook" };
|
|
1250
|
+
|
|
1251
|
+
// Wrap onCommitFiberRoot to count commits and collect component names
|
|
1252
|
+
var origCommit = hook.onCommitFiberRoot.bind(hook);
|
|
1253
|
+
hook.onCommitFiberRoot = function(rendererID, root, priorityLevel) {
|
|
1254
|
+
origCommit(rendererID, root, priorityLevel);
|
|
1255
|
+
|
|
1256
|
+
window.__scopeProfileData.commitCount++;
|
|
1257
|
+
|
|
1258
|
+
// Walk the committed tree to collect re-rendered component names
|
|
1259
|
+
var current = root && root.current;
|
|
1260
|
+
if (!current) return;
|
|
1261
|
+
|
|
1262
|
+
function walkFiber(fiber) {
|
|
1263
|
+
if (!fiber) return;
|
|
1264
|
+
var tag = fiber.tag;
|
|
1265
|
+
// FunctionComponent=0, ClassComponent=1, ForwardRef=11, Memo=14, SimpleMemo=15
|
|
1266
|
+
if (tag === 0 || tag === 1 || tag === 11 || tag === 14 || tag === 15) {
|
|
1267
|
+
var name = null;
|
|
1268
|
+
if (fiber.type) {
|
|
1269
|
+
if (typeof fiber.type === "function") name = fiber.type.displayName || fiber.type.name;
|
|
1270
|
+
else if (fiber.type.displayName) name = fiber.type.displayName;
|
|
1271
|
+
else if (fiber.type.render) name = fiber.type.render.displayName || fiber.type.render.name;
|
|
1272
|
+
else if (fiber.type.type) name = fiber.type.type.displayName || fiber.type.type.name;
|
|
1273
|
+
}
|
|
1274
|
+
// Only count fibers with a positive actualDuration (actually re-rendered this commit)
|
|
1275
|
+
if (name && typeof fiber.actualDuration === "number" && fiber.actualDuration >= 0) {
|
|
1276
|
+
window.__scopeProfileData.componentNames.add(name);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
walkFiber(fiber.child);
|
|
1280
|
+
walkFiber(fiber.sibling);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
var wip = root.current.alternate || root.current;
|
|
1284
|
+
walkFiber(wip.child);
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
// Install PerformanceObserver for layout and paint timing
|
|
1288
|
+
window.__scopeLayoutTime = 0;
|
|
1289
|
+
window.__scopePaintTime = 0;
|
|
1290
|
+
window.__scopeLayoutShifts = { count: 0, score: 0 };
|
|
1291
|
+
|
|
1292
|
+
try {
|
|
1293
|
+
var observer = new PerformanceObserver(function(list) {
|
|
1294
|
+
for (var entry of list.getEntries()) {
|
|
1295
|
+
if (entry.entryType === "layout-shift") {
|
|
1296
|
+
window.__scopeLayoutShifts.count++;
|
|
1297
|
+
window.__scopeLayoutShifts.score += entry.value || 0;
|
|
1298
|
+
}
|
|
1299
|
+
if (entry.entryType === "longtask") {
|
|
1300
|
+
window.__scopeLayoutTime += entry.duration || 0;
|
|
1301
|
+
}
|
|
1302
|
+
if (entry.entryType === "paint") {
|
|
1303
|
+
window.__scopePaintTime += entry.startTime || 0;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
});
|
|
1307
|
+
observer.observe({ entryTypes: ["layout-shift", "longtask", "paint"] });
|
|
1308
|
+
} catch(e) {
|
|
1309
|
+
// PerformanceObserver may not be available in all Playwright contexts
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
return { ok: true };
|
|
1313
|
+
})();
|
|
1314
|
+
`;
|
|
1315
|
+
}
|
|
1316
|
+
function buildProfilingCollectScript() {
|
|
1317
|
+
return `
|
|
1318
|
+
(function __scopeProfileCollect() {
|
|
1319
|
+
var data = window.__scopeProfileData;
|
|
1320
|
+
if (!data) return { error: "Profiling not set up" };
|
|
1321
|
+
|
|
1322
|
+
// Estimate wasted renders: use paint entries count as a heuristic for
|
|
1323
|
+
// "components that re-rendered but their subtree output was likely unchanged".
|
|
1324
|
+
// A more accurate method would require React's own wasted-render detection,
|
|
1325
|
+
// which requires React Profiler API. We use a conservative estimate here.
|
|
1326
|
+
var totalCommits = data.commitCount;
|
|
1327
|
+
var uniqueNames = Array.from(data.componentNames);
|
|
1328
|
+
|
|
1329
|
+
// Wasted renders heuristic: if a component is in a subsequent commit (not the initial
|
|
1330
|
+
// mount commit), it *may* have been wasted if it didn't actually need to re-render.
|
|
1331
|
+
// For the initial snapshot we approximate: wastedRenders = max(0, totalCommits - 1) * 0.3
|
|
1332
|
+
// This is a heuristic \u2014 real wasted render detection needs shouldComponentUpdate/React.memo tracing.
|
|
1333
|
+
var wastedRenders = Math.max(0, Math.round((totalCommits - 1) * uniqueNames.length * 0.3));
|
|
1334
|
+
|
|
1335
|
+
return {
|
|
1336
|
+
commitCount: totalCommits,
|
|
1337
|
+
uniqueComponents: uniqueNames.length,
|
|
1338
|
+
componentNames: uniqueNames,
|
|
1339
|
+
wastedRenders: wastedRenders,
|
|
1340
|
+
layoutTime: window.__scopeLayoutTime || 0,
|
|
1341
|
+
paintTime: window.__scopePaintTime || 0,
|
|
1342
|
+
layoutShifts: window.__scopeLayoutShifts || { count: 0, score: 0 }
|
|
1343
|
+
};
|
|
1344
|
+
})();
|
|
1345
|
+
`;
|
|
1346
|
+
}
|
|
1347
|
+
async function replayInteraction(page, steps) {
|
|
1348
|
+
for (const step of steps) {
|
|
1349
|
+
switch (step.action) {
|
|
1350
|
+
case "click":
|
|
1351
|
+
if (step.target) {
|
|
1352
|
+
await page.click(step.target, { timeout: 5e3 }).catch(() => {
|
|
1353
|
+
process.stderr.write(` \u26A0 click target "${step.target}" not found, skipping
|
|
1354
|
+
`);
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
break;
|
|
1358
|
+
case "fill":
|
|
1359
|
+
if (step.target && step.value !== void 0) {
|
|
1360
|
+
await page.fill(step.target, step.value, { timeout: 5e3 }).catch(() => {
|
|
1361
|
+
process.stderr.write(` \u26A0 fill target "${step.target}" not found, skipping
|
|
1362
|
+
`);
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
break;
|
|
1366
|
+
case "hover":
|
|
1367
|
+
if (step.target) {
|
|
1368
|
+
await page.hover(step.target, { timeout: 5e3 }).catch(() => {
|
|
1369
|
+
process.stderr.write(` \u26A0 hover target "${step.target}" not found, skipping
|
|
1370
|
+
`);
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
break;
|
|
1374
|
+
case "press":
|
|
1375
|
+
if (step.value) {
|
|
1376
|
+
await page.keyboard.press(step.value);
|
|
1377
|
+
}
|
|
1378
|
+
break;
|
|
1379
|
+
case "wait":
|
|
1380
|
+
await page.waitForTimeout(step.delay ?? 500);
|
|
1381
|
+
break;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
function analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts) {
|
|
1386
|
+
const flags = /* @__PURE__ */ new Set();
|
|
1387
|
+
if (wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
|
|
1388
|
+
flags.add("WASTED_RENDER");
|
|
1389
|
+
}
|
|
1390
|
+
if (totalRenders > 10) {
|
|
1391
|
+
flags.add("HIGH_RENDER_COUNT");
|
|
1392
|
+
}
|
|
1393
|
+
if (layoutShifts.cumulativeScore > 0.1) {
|
|
1394
|
+
flags.add("LAYOUT_SHIFT_DETECTED");
|
|
1395
|
+
}
|
|
1396
|
+
if (timing.js > 100) {
|
|
1397
|
+
flags.add("SLOW_INTERACTION");
|
|
1398
|
+
}
|
|
1399
|
+
return [...flags];
|
|
1400
|
+
}
|
|
1401
|
+
async function runInteractionProfile(componentName, filePath, props, interaction) {
|
|
1402
|
+
const browser = await chromium.launch({ headless: true });
|
|
1403
|
+
try {
|
|
1404
|
+
const context = await browser.newContext();
|
|
1405
|
+
const page = await context.newPage();
|
|
1406
|
+
const htmlHarness = await buildComponentHarness(filePath, componentName, props, 1280);
|
|
1407
|
+
await page.setContent(htmlHarness, { waitUntil: "load" });
|
|
1408
|
+
await page.waitForFunction(
|
|
1409
|
+
() => {
|
|
1410
|
+
const w = window;
|
|
1411
|
+
return w.__SCOPE_RENDER_COMPLETE__ === true;
|
|
1412
|
+
},
|
|
1413
|
+
{ timeout: 15e3 }
|
|
1414
|
+
);
|
|
1415
|
+
const renderError = await page.evaluate(() => {
|
|
1416
|
+
return window.__SCOPE_RENDER_ERROR__ ?? null;
|
|
1417
|
+
});
|
|
1418
|
+
if (renderError !== null) {
|
|
1419
|
+
throw new Error(`Component render error: ${renderError}`);
|
|
1420
|
+
}
|
|
1421
|
+
const setupResult = await page.evaluate(buildProfilingSetupScript());
|
|
1422
|
+
const setupData = setupResult;
|
|
1423
|
+
if (setupData.error) {
|
|
1424
|
+
throw new Error(`Profiling setup failed: ${setupData.error}`);
|
|
1425
|
+
}
|
|
1426
|
+
const jsStart = Date.now();
|
|
1427
|
+
if (interaction.length > 0) {
|
|
1428
|
+
process.stderr.write(` Replaying ${interaction.length} interaction step(s)\u2026
|
|
1429
|
+
`);
|
|
1430
|
+
await replayInteraction(page, interaction);
|
|
1431
|
+
await page.waitForTimeout(300);
|
|
1432
|
+
}
|
|
1433
|
+
const jsDuration = Date.now() - jsStart;
|
|
1434
|
+
const collected = await page.evaluate(buildProfilingCollectScript());
|
|
1435
|
+
const profileData = collected;
|
|
1436
|
+
if (profileData.error) {
|
|
1437
|
+
throw new Error(`Profile collection failed: ${profileData.error}`);
|
|
1438
|
+
}
|
|
1439
|
+
const timing = {
|
|
1440
|
+
js: jsDuration,
|
|
1441
|
+
layout: profileData.layoutTime ?? 0,
|
|
1442
|
+
paint: profileData.paintTime ?? 0
|
|
1443
|
+
};
|
|
1444
|
+
const layoutShifts = {
|
|
1445
|
+
count: profileData.layoutShifts?.count ?? 0,
|
|
1446
|
+
cumulativeScore: profileData.layoutShifts?.score ?? 0
|
|
1447
|
+
};
|
|
1448
|
+
const totalRenders = profileData.commitCount ?? 0;
|
|
1449
|
+
const uniqueComponents = profileData.uniqueComponents ?? 0;
|
|
1450
|
+
const wastedRenders = profileData.wastedRenders ?? 0;
|
|
1451
|
+
const flags = analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts);
|
|
1452
|
+
return {
|
|
1453
|
+
component: componentName,
|
|
1454
|
+
totalRenders,
|
|
1455
|
+
uniqueComponents,
|
|
1456
|
+
wastedRenders,
|
|
1457
|
+
timing,
|
|
1458
|
+
layoutShifts,
|
|
1459
|
+
flags,
|
|
1460
|
+
interaction
|
|
1461
|
+
};
|
|
1462
|
+
} finally {
|
|
1463
|
+
await browser.close();
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
function createInstrumentProfileCommand() {
|
|
1467
|
+
const cmd = new Command("profile").description(
|
|
1468
|
+
"Capture a full interaction-scoped performance profile: renders, timing, layout shifts"
|
|
1469
|
+
).argument("<component>", "Component name (must exist in the manifest)").option(
|
|
1470
|
+
"--interaction <json>",
|
|
1471
|
+
`Interaction steps JSON, e.g. '[{"action":"click","target":"button.primary"}]'`,
|
|
1472
|
+
"[]"
|
|
1473
|
+
).option("--props <json>", "Inline props JSON passed to the component", "{}").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH3).option("--format <fmt>", "Output format: json|text (default: json)", "json").option("--show-flags", "Show heuristic flags only (useful for CI checks)", false).action(
|
|
1474
|
+
async (componentName, opts) => {
|
|
1475
|
+
try {
|
|
1476
|
+
const manifest = loadManifest(opts.manifest);
|
|
1477
|
+
const descriptor = manifest.components[componentName];
|
|
1478
|
+
if (descriptor === void 0) {
|
|
1479
|
+
const available = Object.keys(manifest.components).slice(0, 5).join(", ");
|
|
1480
|
+
throw new Error(
|
|
1481
|
+
`Component "${componentName}" not found in manifest.
|
|
1482
|
+
Available: ${available}`
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
let props = {};
|
|
1486
|
+
try {
|
|
1487
|
+
props = JSON.parse(opts.props);
|
|
1488
|
+
} catch {
|
|
1489
|
+
throw new Error(`Invalid props JSON: ${opts.props}`);
|
|
1490
|
+
}
|
|
1491
|
+
let interaction = [];
|
|
1492
|
+
try {
|
|
1493
|
+
interaction = JSON.parse(opts.interaction);
|
|
1494
|
+
if (!Array.isArray(interaction)) {
|
|
1495
|
+
throw new Error("Interaction must be a JSON array");
|
|
1496
|
+
}
|
|
1497
|
+
} catch {
|
|
1498
|
+
throw new Error(`Invalid interaction JSON: ${opts.interaction}`);
|
|
1499
|
+
}
|
|
1500
|
+
const rootDir = process.cwd();
|
|
1501
|
+
const filePath = resolve(rootDir, descriptor.filePath);
|
|
1502
|
+
process.stderr.write(`Profiling interaction for ${componentName}\u2026
|
|
1503
|
+
`);
|
|
1504
|
+
const result = await runInteractionProfile(componentName, filePath, props, interaction);
|
|
1505
|
+
if (opts.showFlags) {
|
|
1506
|
+
if (result.flags.length === 0) {
|
|
1507
|
+
process.stdout.write("No heuristic flags detected.\n");
|
|
1508
|
+
} else {
|
|
1509
|
+
for (const flag of result.flags) {
|
|
1510
|
+
process.stdout.write(`${flag}
|
|
1511
|
+
`);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1516
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1517
|
+
`);
|
|
1518
|
+
} catch (err) {
|
|
1519
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1520
|
+
`);
|
|
1521
|
+
process.exit(1);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
);
|
|
1525
|
+
return cmd;
|
|
1526
|
+
}
|
|
1527
|
+
var MANIFEST_PATH4 = ".reactscope/manifest.json";
|
|
1528
|
+
var DEFAULT_VIEWPORT_WIDTH = 375;
|
|
1529
|
+
var DEFAULT_VIEWPORT_HEIGHT = 812;
|
|
1530
|
+
var _pool = null;
|
|
1531
|
+
async function getPool() {
|
|
1532
|
+
if (_pool === null) {
|
|
1533
|
+
_pool = new BrowserPool({
|
|
1534
|
+
size: { browsers: 1, pagesPerBrowser: 1 },
|
|
1535
|
+
viewportWidth: DEFAULT_VIEWPORT_WIDTH,
|
|
1536
|
+
viewportHeight: DEFAULT_VIEWPORT_HEIGHT
|
|
1537
|
+
});
|
|
1538
|
+
await _pool.init();
|
|
1539
|
+
}
|
|
1540
|
+
return _pool;
|
|
1541
|
+
}
|
|
1542
|
+
async function shutdownPool() {
|
|
1543
|
+
if (_pool !== null) {
|
|
1544
|
+
await _pool.close();
|
|
1545
|
+
_pool = null;
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
function mapNodeType(node) {
|
|
1549
|
+
if (node.type === "forward_ref") return "forwardRef";
|
|
1550
|
+
if (node.type === "host") return "host";
|
|
1551
|
+
const name = node.name;
|
|
1552
|
+
if (name.endsWith(".Provider") || name === "Provider") return "context.provider";
|
|
1553
|
+
if (name.endsWith(".Consumer") || name === "Consumer") return "context.consumer";
|
|
1554
|
+
return node.type;
|
|
1555
|
+
}
|
|
1556
|
+
function flattenSerializedValue(sv) {
|
|
1557
|
+
if (sv === null || sv === void 0) return null;
|
|
1558
|
+
const v = sv;
|
|
1559
|
+
switch (v.type) {
|
|
1560
|
+
case "null":
|
|
1561
|
+
case "undefined":
|
|
1562
|
+
return null;
|
|
1563
|
+
case "string":
|
|
1564
|
+
case "number":
|
|
1565
|
+
case "boolean":
|
|
1566
|
+
return v.value;
|
|
1567
|
+
case "object": {
|
|
1568
|
+
if (!Array.isArray(v.entries)) return {};
|
|
1569
|
+
const result = {};
|
|
1570
|
+
for (const entry of v.entries) {
|
|
1571
|
+
result[entry.key] = flattenSerializedValue(entry.value);
|
|
1572
|
+
}
|
|
1573
|
+
return result;
|
|
1574
|
+
}
|
|
1575
|
+
case "array": {
|
|
1576
|
+
if (!Array.isArray(v.items)) return [];
|
|
1577
|
+
return v.items.map(flattenSerializedValue);
|
|
1578
|
+
}
|
|
1579
|
+
case "function":
|
|
1580
|
+
return "[Function]";
|
|
1581
|
+
case "symbol":
|
|
1582
|
+
return `[Symbol: ${v.description ?? ""}]`;
|
|
1583
|
+
case "circular":
|
|
1584
|
+
return "[Circular]";
|
|
1585
|
+
case "truncated":
|
|
1586
|
+
return `[Truncated: ${v.preview ?? ""}]`;
|
|
1587
|
+
default:
|
|
1588
|
+
return v.preview ?? null;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
function flattenHookState(hooks) {
|
|
1592
|
+
const result = {};
|
|
1593
|
+
for (let i = 0; i < hooks.length; i++) {
|
|
1594
|
+
const hook = hooks[i];
|
|
1595
|
+
if (hook === void 0) continue;
|
|
1596
|
+
const key = hook.name !== null && hook.name !== void 0 ? hook.name : `${hook.type}[${i}]`;
|
|
1597
|
+
result[key] = flattenSerializedValue(hook.value);
|
|
1598
|
+
}
|
|
1599
|
+
return result;
|
|
1600
|
+
}
|
|
1601
|
+
function extractContextNames(contexts) {
|
|
1602
|
+
const names = contexts.map((c) => c.contextName ?? "Unknown").filter((name, idx, arr) => arr.indexOf(name) === idx);
|
|
1603
|
+
return names;
|
|
1604
|
+
}
|
|
1605
|
+
function anyContextChanged(contexts) {
|
|
1606
|
+
return contexts.some((c) => c.didTriggerRender);
|
|
1607
|
+
}
|
|
1608
|
+
function convertToInstrumentNode(node, depth = 0) {
|
|
1609
|
+
const contexts = extractContextNames(node.context);
|
|
1610
|
+
const contextChanged = anyContextChanged(node.context);
|
|
1611
|
+
const state = flattenHookState(node.state);
|
|
1612
|
+
const propsFlat = flattenSerializedValue(node.props);
|
|
1613
|
+
const props = propsFlat !== null && typeof propsFlat === "object" && !Array.isArray(propsFlat) ? propsFlat : {};
|
|
1614
|
+
return {
|
|
1615
|
+
component: node.name,
|
|
1616
|
+
type: mapNodeType(node),
|
|
1617
|
+
renderCount: node.renderCount,
|
|
1618
|
+
lastRenderDuration: node.renderDuration,
|
|
1619
|
+
memoized: node.type === "memo",
|
|
1620
|
+
// memoSkipped requires tracking bail-outs across commits — not available from
|
|
1621
|
+
// a single-shot capture. Defaulted to 0.
|
|
1622
|
+
memoSkipped: 0,
|
|
1623
|
+
props,
|
|
1624
|
+
// propsChanged is not tracked in a single-shot capture — would need a diff
|
|
1625
|
+
// between two renders. Defaulted to false.
|
|
1626
|
+
propsChanged: false,
|
|
1627
|
+
state,
|
|
1628
|
+
stateChanged: false,
|
|
1629
|
+
contextChanged,
|
|
1630
|
+
contexts,
|
|
1631
|
+
depth,
|
|
1632
|
+
children: node.children.map((child) => convertToInstrumentNode(child, depth + 1))
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
function filterByContext(node, contextName) {
|
|
1636
|
+
const filteredChildren = node.children.map((child) => filterByContext(child, contextName)).filter((c) => c !== null);
|
|
1637
|
+
const selfMatches = node.contexts.some((c) => c.toLowerCase() === contextName.toLowerCase());
|
|
1638
|
+
if (!selfMatches && filteredChildren.length === 0) return null;
|
|
1639
|
+
return { ...node, children: filteredChildren };
|
|
1640
|
+
}
|
|
1641
|
+
function filterWastedRenders(node) {
|
|
1642
|
+
const filteredChildren = node.children.map((child) => filterWastedRenders(child)).filter((c) => c !== null);
|
|
1643
|
+
const isWasted = !node.propsChanged && !node.stateChanged && !node.contextChanged && !node.memoized && node.renderCount > 1;
|
|
1644
|
+
if (!isWasted && filteredChildren.length === 0) return null;
|
|
1645
|
+
return { ...node, children: filteredChildren };
|
|
1646
|
+
}
|
|
1647
|
+
function sortTree(node, sortBy) {
|
|
1648
|
+
const sortedChildren = node.children.map((child) => sortTree(child, sortBy)).sort((a, b) => {
|
|
1649
|
+
if (sortBy === "renderCount") return b.renderCount - a.renderCount;
|
|
1650
|
+
return a.depth - b.depth;
|
|
1651
|
+
});
|
|
1652
|
+
return { ...node, children: sortedChildren };
|
|
1653
|
+
}
|
|
1654
|
+
function annotateProviderDepth(node, providerDepth = 0) {
|
|
1655
|
+
const isProvider = node.type === "context.provider";
|
|
1656
|
+
const childProviderDepth = isProvider ? providerDepth + 1 : providerDepth;
|
|
1657
|
+
return {
|
|
1658
|
+
...node,
|
|
1659
|
+
_providerDepth: providerDepth,
|
|
1660
|
+
children: node.children.map((child) => annotateProviderDepth(child, childProviderDepth))
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
function limitNodes(root, limit) {
|
|
1664
|
+
let remaining = limit;
|
|
1665
|
+
const clip = (node) => {
|
|
1666
|
+
if (remaining <= 0) return null;
|
|
1667
|
+
remaining--;
|
|
1668
|
+
const clippedChildren = [];
|
|
1669
|
+
for (const child of node.children) {
|
|
1670
|
+
const clipped = clip(child);
|
|
1671
|
+
if (clipped !== null) clippedChildren.push(clipped);
|
|
1672
|
+
}
|
|
1673
|
+
return { ...node, children: clippedChildren };
|
|
1674
|
+
};
|
|
1675
|
+
return clip(root) ?? root;
|
|
1676
|
+
}
|
|
1677
|
+
var BRANCH = "\u251C\u2500\u2500 ";
|
|
1678
|
+
var LAST_BRANCH = "\u2514\u2500\u2500 ";
|
|
1679
|
+
var VERTICAL = "\u2502 ";
|
|
1680
|
+
var EMPTY = " ";
|
|
1681
|
+
function buildTTYLabel(node, showProviderDepth) {
|
|
1682
|
+
const parts = [node.component];
|
|
1683
|
+
switch (node.type) {
|
|
1684
|
+
case "memo":
|
|
1685
|
+
parts.push("[memo]");
|
|
1686
|
+
break;
|
|
1687
|
+
case "forwardRef":
|
|
1688
|
+
parts.push("[forwardRef]");
|
|
1689
|
+
break;
|
|
1690
|
+
case "class":
|
|
1691
|
+
parts.push("[class]");
|
|
1692
|
+
break;
|
|
1693
|
+
case "context.provider":
|
|
1694
|
+
parts.push("[provider]");
|
|
1695
|
+
break;
|
|
1696
|
+
case "context.consumer":
|
|
1697
|
+
parts.push("[consumer]");
|
|
1698
|
+
break;
|
|
1699
|
+
}
|
|
1700
|
+
if (node.renderCount > 0) {
|
|
1701
|
+
const durationStr = node.lastRenderDuration > 0 ? ` ${node.lastRenderDuration.toFixed(2)}ms` : "";
|
|
1702
|
+
parts.push(`(renders:${node.renderCount}${durationStr})`);
|
|
1703
|
+
}
|
|
1704
|
+
if (node.contexts.length > 0) {
|
|
1705
|
+
parts.push(`[ctx:${node.contexts.join(",")}]`);
|
|
1706
|
+
}
|
|
1707
|
+
if (showProviderDepth) {
|
|
1708
|
+
const pd = node._providerDepth;
|
|
1709
|
+
if (pd !== void 0 && pd > 0) {
|
|
1710
|
+
parts.push(`[pd:${pd}]`);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return parts.join(" ");
|
|
1714
|
+
}
|
|
1715
|
+
function renderTTYNode(node, prefix, isLast, showProviderDepth, lines) {
|
|
1716
|
+
if (node.type === "host") {
|
|
1717
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
1718
|
+
const child = node.children[i];
|
|
1719
|
+
if (child !== void 0) {
|
|
1720
|
+
renderTTYNode(child, prefix, i === node.children.length - 1, showProviderDepth, lines);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
return;
|
|
1724
|
+
}
|
|
1725
|
+
const connector = isLast ? LAST_BRANCH : BRANCH;
|
|
1726
|
+
lines.push(`${prefix}${connector}${buildTTYLabel(node, showProviderDepth)}`);
|
|
1727
|
+
const nextPrefix = prefix + (isLast ? EMPTY : VERTICAL);
|
|
1728
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
1729
|
+
const child = node.children[i];
|
|
1730
|
+
if (child !== void 0) {
|
|
1731
|
+
renderTTYNode(child, nextPrefix, i === node.children.length - 1, showProviderDepth, lines);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
function formatInstrumentTree(root, showProviderDepth = false) {
|
|
1736
|
+
const lines = [];
|
|
1737
|
+
if (root.type !== "host") {
|
|
1738
|
+
lines.push(buildTTYLabel(root, showProviderDepth));
|
|
1739
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
1740
|
+
const child = root.children[i];
|
|
1741
|
+
if (child !== void 0) {
|
|
1742
|
+
renderTTYNode(child, "", i === root.children.length - 1, showProviderDepth, lines);
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
} else {
|
|
1746
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
1747
|
+
const child = root.children[i];
|
|
1748
|
+
if (child !== void 0) {
|
|
1749
|
+
renderTTYNode(child, "", i === root.children.length - 1, showProviderDepth, lines);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
921
1753
|
return lines.join("\n");
|
|
922
1754
|
}
|
|
923
|
-
function
|
|
924
|
-
|
|
1755
|
+
async function runInstrumentTree(options) {
|
|
1756
|
+
const { componentName, filePath } = options;
|
|
1757
|
+
const pool = await getPool();
|
|
1758
|
+
const slot = await pool.acquire();
|
|
1759
|
+
const { page } = slot;
|
|
1760
|
+
try {
|
|
1761
|
+
await page.addInitScript({ content: getBrowserEntryScript() });
|
|
1762
|
+
const htmlHarness = await buildComponentHarness(
|
|
1763
|
+
filePath,
|
|
1764
|
+
componentName,
|
|
1765
|
+
{},
|
|
1766
|
+
DEFAULT_VIEWPORT_WIDTH
|
|
1767
|
+
);
|
|
1768
|
+
await page.setContent(htmlHarness, { waitUntil: "load" });
|
|
1769
|
+
await page.waitForFunction(
|
|
1770
|
+
() => {
|
|
1771
|
+
const w = window;
|
|
1772
|
+
return w.__SCOPE_RENDER_COMPLETE__ === true;
|
|
1773
|
+
},
|
|
1774
|
+
{ timeout: 15e3 }
|
|
1775
|
+
);
|
|
1776
|
+
const renderError = await page.evaluate(
|
|
1777
|
+
() => window.__SCOPE_RENDER_ERROR__ ?? null
|
|
1778
|
+
);
|
|
1779
|
+
if (renderError !== null) {
|
|
1780
|
+
throw new Error(`Component render error: ${renderError}`);
|
|
1781
|
+
}
|
|
1782
|
+
const captureJson = await page.evaluate(async () => {
|
|
1783
|
+
const w = window;
|
|
1784
|
+
if (typeof w.__SCOPE_CAPTURE_JSON__ !== "function") {
|
|
1785
|
+
throw new Error("__SCOPE_CAPTURE_JSON__ not available \u2014 Scope runtime not injected");
|
|
1786
|
+
}
|
|
1787
|
+
return w.__SCOPE_CAPTURE_JSON__({ lightweight: false });
|
|
1788
|
+
});
|
|
1789
|
+
const captureResult = JSON.parse(captureJson);
|
|
1790
|
+
const componentTree = captureResult.tree;
|
|
1791
|
+
if (componentTree === void 0 || componentTree === null) {
|
|
1792
|
+
throw new Error(`No component tree found for "${componentName}"`);
|
|
1793
|
+
}
|
|
1794
|
+
let instrumentRoot = convertToInstrumentNode(componentTree, 0);
|
|
1795
|
+
if (options.usesContext !== void 0) {
|
|
1796
|
+
const filtered = filterByContext(instrumentRoot, options.usesContext);
|
|
1797
|
+
instrumentRoot = filtered !== null ? filtered : { ...instrumentRoot, children: [] };
|
|
1798
|
+
}
|
|
1799
|
+
if (options.wastedRenders === true) {
|
|
1800
|
+
const filtered = filterWastedRenders(instrumentRoot);
|
|
1801
|
+
instrumentRoot = filtered !== null ? filtered : { ...instrumentRoot, children: [] };
|
|
1802
|
+
}
|
|
1803
|
+
if (options.sortBy !== void 0) {
|
|
1804
|
+
instrumentRoot = sortTree(instrumentRoot, options.sortBy);
|
|
1805
|
+
}
|
|
1806
|
+
if (options.providerDepth === true) {
|
|
1807
|
+
instrumentRoot = annotateProviderDepth(instrumentRoot, 0);
|
|
1808
|
+
}
|
|
1809
|
+
if (options.limit !== void 0 && options.limit > 0) {
|
|
1810
|
+
instrumentRoot = limitNodes(instrumentRoot, options.limit);
|
|
1811
|
+
}
|
|
1812
|
+
return instrumentRoot;
|
|
1813
|
+
} finally {
|
|
1814
|
+
pool.release(slot);
|
|
1815
|
+
}
|
|
925
1816
|
}
|
|
926
|
-
function
|
|
927
|
-
|
|
928
|
-
|
|
1817
|
+
function createInstrumentTreeCommand() {
|
|
1818
|
+
return new Command("tree").description("Render a component via BrowserPool and output a structured instrumentation tree").argument("<component>", "Component name to instrument (must exist in the manifest)").option("--sort-by <field>", "Sort nodes by field: renderCount | depth").option("--limit <n>", "Limit output to the first N nodes (depth-first)").option("--uses-context <name>", "Filter to components that use a specific context").option("--provider-depth", "Annotate each node with its context-provider nesting depth", false).option(
|
|
1819
|
+
"--wasted-renders",
|
|
1820
|
+
"Filter to components with wasted renders (no prop/state/context changes, not memoized)",
|
|
1821
|
+
false
|
|
1822
|
+
).option("--format <fmt>", "Output format: json | tree (default: auto)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH4).action(async (componentName, opts) => {
|
|
1823
|
+
try {
|
|
1824
|
+
const manifest = loadManifest(opts.manifest);
|
|
1825
|
+
const descriptor = manifest.components[componentName];
|
|
1826
|
+
if (descriptor === void 0) {
|
|
1827
|
+
const available = Object.keys(manifest.components).slice(0, 5).join(", ");
|
|
1828
|
+
throw new Error(
|
|
1829
|
+
`Component "${componentName}" not found in manifest.
|
|
1830
|
+
Available: ${available}`
|
|
1831
|
+
);
|
|
1832
|
+
}
|
|
1833
|
+
if (opts.sortBy !== void 0) {
|
|
1834
|
+
const allowed = ["renderCount", "depth"];
|
|
1835
|
+
if (!allowed.includes(opts.sortBy)) {
|
|
1836
|
+
throw new Error(
|
|
1837
|
+
`Unknown --sort-by value "${opts.sortBy}". Allowed: ${allowed.join(", ")}`
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const rootDir = process.cwd();
|
|
1842
|
+
const filePath = resolve(rootDir, descriptor.filePath);
|
|
1843
|
+
process.stderr.write(`Instrumenting ${componentName}\u2026
|
|
1844
|
+
`);
|
|
1845
|
+
const instrumentRoot = await runInstrumentTree({
|
|
1846
|
+
componentName,
|
|
1847
|
+
filePath,
|
|
1848
|
+
sortBy: opts.sortBy,
|
|
1849
|
+
limit: opts.limit !== void 0 ? Math.max(1, parseInt(opts.limit, 10)) : void 0,
|
|
1850
|
+
usesContext: opts.usesContext,
|
|
1851
|
+
providerDepth: opts.providerDepth,
|
|
1852
|
+
wastedRenders: opts.wastedRenders
|
|
1853
|
+
});
|
|
1854
|
+
await shutdownPool();
|
|
1855
|
+
const fmt = resolveFormat2(opts.format);
|
|
1856
|
+
if (fmt === "json") {
|
|
1857
|
+
process.stdout.write(`${JSON.stringify(instrumentRoot, null, 2)}
|
|
1858
|
+
`);
|
|
1859
|
+
} else {
|
|
1860
|
+
const tree = formatInstrumentTree(instrumentRoot, opts.providerDepth ?? false);
|
|
1861
|
+
process.stdout.write(`${tree}
|
|
1862
|
+
`);
|
|
1863
|
+
}
|
|
1864
|
+
} catch (err) {
|
|
1865
|
+
await shutdownPool();
|
|
1866
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1867
|
+
`);
|
|
1868
|
+
process.exit(1);
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
function resolveFormat2(formatFlag) {
|
|
1873
|
+
if (formatFlag !== void 0) {
|
|
1874
|
+
const lower = formatFlag.toLowerCase();
|
|
1875
|
+
if (lower !== "json" && lower !== "tree") {
|
|
1876
|
+
throw new Error(`Unknown format "${formatFlag}". Allowed: json, tree`);
|
|
1877
|
+
}
|
|
1878
|
+
return lower;
|
|
929
1879
|
}
|
|
930
|
-
return
|
|
1880
|
+
return isTTY() ? "tree" : "json";
|
|
931
1881
|
}
|
|
932
1882
|
|
|
933
1883
|
// src/instrument/renders.ts
|
|
934
|
-
var
|
|
1884
|
+
var MANIFEST_PATH5 = ".reactscope/manifest.json";
|
|
935
1885
|
function determineTrigger(event) {
|
|
936
1886
|
if (event.forceUpdate) return "force_update";
|
|
937
1887
|
if (event.stateChanged) return "state_change";
|
|
@@ -1176,7 +2126,7 @@ function buildInstrumentationScript() {
|
|
|
1176
2126
|
`
|
|
1177
2127
|
);
|
|
1178
2128
|
}
|
|
1179
|
-
async function
|
|
2129
|
+
async function replayInteraction2(page, steps) {
|
|
1180
2130
|
for (const step of steps) {
|
|
1181
2131
|
switch (step.action) {
|
|
1182
2132
|
case "click":
|
|
@@ -1230,26 +2180,26 @@ async function replayInteraction(page, steps) {
|
|
|
1230
2180
|
}
|
|
1231
2181
|
}
|
|
1232
2182
|
}
|
|
1233
|
-
var
|
|
1234
|
-
async function
|
|
1235
|
-
if (
|
|
1236
|
-
|
|
2183
|
+
var _pool2 = null;
|
|
2184
|
+
async function getPool2() {
|
|
2185
|
+
if (_pool2 === null) {
|
|
2186
|
+
_pool2 = new BrowserPool({
|
|
1237
2187
|
size: { browsers: 1, pagesPerBrowser: 2 },
|
|
1238
2188
|
viewportWidth: 1280,
|
|
1239
2189
|
viewportHeight: 800
|
|
1240
2190
|
});
|
|
1241
|
-
await
|
|
2191
|
+
await _pool2.init();
|
|
1242
2192
|
}
|
|
1243
|
-
return
|
|
2193
|
+
return _pool2;
|
|
1244
2194
|
}
|
|
1245
|
-
async function
|
|
1246
|
-
if (
|
|
1247
|
-
await
|
|
1248
|
-
|
|
2195
|
+
async function shutdownPool2() {
|
|
2196
|
+
if (_pool2 !== null) {
|
|
2197
|
+
await _pool2.close();
|
|
2198
|
+
_pool2 = null;
|
|
1249
2199
|
}
|
|
1250
2200
|
}
|
|
1251
2201
|
async function analyzeRenders(options) {
|
|
1252
|
-
const manifestPath = options.manifestPath ??
|
|
2202
|
+
const manifestPath = options.manifestPath ?? MANIFEST_PATH5;
|
|
1253
2203
|
const manifest = loadManifest(manifestPath);
|
|
1254
2204
|
const descriptor = manifest.components[options.componentName];
|
|
1255
2205
|
if (descriptor === void 0) {
|
|
@@ -1262,7 +2212,7 @@ Available: ${available}`
|
|
|
1262
2212
|
const rootDir = process.cwd();
|
|
1263
2213
|
const filePath = resolve(rootDir, descriptor.filePath);
|
|
1264
2214
|
const htmlHarness = await buildComponentHarness(filePath, options.componentName, {}, 1280);
|
|
1265
|
-
const pool = await
|
|
2215
|
+
const pool = await getPool2();
|
|
1266
2216
|
const slot = await pool.acquire();
|
|
1267
2217
|
const { page } = slot;
|
|
1268
2218
|
const startMs = performance.now();
|
|
@@ -1278,7 +2228,7 @@ Available: ${available}`
|
|
|
1278
2228
|
window.__SCOPE_RENDER_EVENTS__ = [];
|
|
1279
2229
|
window.__SCOPE_RENDER_INDEX__ = 0;
|
|
1280
2230
|
});
|
|
1281
|
-
await
|
|
2231
|
+
await replayInteraction2(page, options.interaction);
|
|
1282
2232
|
await page.waitForTimeout(200);
|
|
1283
2233
|
const interactionDurationMs = performance.now() - startMs;
|
|
1284
2234
|
const rawEvents = await page.evaluate(() => {
|
|
@@ -1347,7 +2297,7 @@ function createInstrumentRendersCommand() {
|
|
|
1347
2297
|
"--interaction <json>",
|
|
1348
2298
|
`Interaction sequence JSON, e.g. '[{"action":"click","target":"button"}]'`,
|
|
1349
2299
|
"[]"
|
|
1350
|
-
).option("--json", "Output as JSON regardless of TTY", false).option("--manifest <path>", "Path to manifest.json",
|
|
2300
|
+
).option("--json", "Output as JSON regardless of TTY", false).option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH5).action(
|
|
1351
2301
|
async (componentName, opts) => {
|
|
1352
2302
|
let interaction = [];
|
|
1353
2303
|
try {
|
|
@@ -1370,7 +2320,7 @@ function createInstrumentRendersCommand() {
|
|
|
1370
2320
|
interaction,
|
|
1371
2321
|
manifestPath: opts.manifest
|
|
1372
2322
|
});
|
|
1373
|
-
await
|
|
2323
|
+
await shutdownPool2();
|
|
1374
2324
|
if (opts.json || !isTTY()) {
|
|
1375
2325
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1376
2326
|
`);
|
|
@@ -1379,7 +2329,7 @@ function createInstrumentRendersCommand() {
|
|
|
1379
2329
|
`);
|
|
1380
2330
|
}
|
|
1381
2331
|
} catch (err) {
|
|
1382
|
-
await
|
|
2332
|
+
await shutdownPool2();
|
|
1383
2333
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1384
2334
|
`);
|
|
1385
2335
|
process.exit(1);
|
|
@@ -1392,8 +2342,51 @@ function createInstrumentCommand() {
|
|
|
1392
2342
|
"Structured instrumentation commands for React component analysis"
|
|
1393
2343
|
);
|
|
1394
2344
|
instrumentCmd.addCommand(createInstrumentRendersCommand());
|
|
2345
|
+
instrumentCmd.addCommand(createInstrumentHooksCommand());
|
|
2346
|
+
instrumentCmd.addCommand(createInstrumentProfileCommand());
|
|
2347
|
+
instrumentCmd.addCommand(createInstrumentTreeCommand());
|
|
1395
2348
|
return instrumentCmd;
|
|
1396
2349
|
}
|
|
2350
|
+
async function browserCapture(options) {
|
|
2351
|
+
const { url, timeout = 1e4, wait = 0 } = options;
|
|
2352
|
+
const browser = await chromium.launch({ headless: true });
|
|
2353
|
+
try {
|
|
2354
|
+
const context = await browser.newContext();
|
|
2355
|
+
const page = await context.newPage();
|
|
2356
|
+
await page.addInitScript({ content: getBrowserEntryScript() });
|
|
2357
|
+
await page.goto(url, {
|
|
2358
|
+
waitUntil: "networkidle",
|
|
2359
|
+
timeout: timeout + 5e3
|
|
2360
|
+
});
|
|
2361
|
+
await page.waitForFunction(
|
|
2362
|
+
() => {
|
|
2363
|
+
return typeof window.__SCOPE_CAPTURE__ === "function" && (document.readyState === "complete" || document.readyState === "interactive");
|
|
2364
|
+
},
|
|
2365
|
+
{ timeout }
|
|
2366
|
+
);
|
|
2367
|
+
if (wait > 0) {
|
|
2368
|
+
await page.waitForTimeout(wait);
|
|
2369
|
+
}
|
|
2370
|
+
const raw = await page.evaluate(async () => {
|
|
2371
|
+
const win = window;
|
|
2372
|
+
if (typeof win.__SCOPE_CAPTURE__ !== "function") {
|
|
2373
|
+
throw new Error("Scope runtime not injected");
|
|
2374
|
+
}
|
|
2375
|
+
return win.__SCOPE_CAPTURE__();
|
|
2376
|
+
});
|
|
2377
|
+
if (raw !== null && typeof raw === "object" && "error" in raw && typeof raw.error === "string") {
|
|
2378
|
+
throw new Error(`Scope capture failed: ${raw.error}`);
|
|
2379
|
+
}
|
|
2380
|
+
const report = { ...raw, route: null };
|
|
2381
|
+
return { report };
|
|
2382
|
+
} finally {
|
|
2383
|
+
await browser.close();
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
function writeReportToFile(report, outputPath, pretty) {
|
|
2387
|
+
const json = pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
|
|
2388
|
+
writeFileSync(outputPath, json, "utf-8");
|
|
2389
|
+
}
|
|
1397
2390
|
var CONFIG_FILENAMES = [
|
|
1398
2391
|
".reactscope/config.json",
|
|
1399
2392
|
".reactscope/config.js",
|
|
@@ -1511,24 +2504,24 @@ async function getCompiledCssForClasses(cwd, classes) {
|
|
|
1511
2504
|
}
|
|
1512
2505
|
|
|
1513
2506
|
// src/render-commands.ts
|
|
1514
|
-
var
|
|
2507
|
+
var MANIFEST_PATH6 = ".reactscope/manifest.json";
|
|
1515
2508
|
var DEFAULT_OUTPUT_DIR = ".reactscope/renders";
|
|
1516
|
-
var
|
|
1517
|
-
async function
|
|
1518
|
-
if (
|
|
1519
|
-
|
|
2509
|
+
var _pool3 = null;
|
|
2510
|
+
async function getPool3(viewportWidth, viewportHeight) {
|
|
2511
|
+
if (_pool3 === null) {
|
|
2512
|
+
_pool3 = new BrowserPool({
|
|
1520
2513
|
size: { browsers: 1, pagesPerBrowser: 4 },
|
|
1521
2514
|
viewportWidth,
|
|
1522
2515
|
viewportHeight
|
|
1523
2516
|
});
|
|
1524
|
-
await
|
|
2517
|
+
await _pool3.init();
|
|
1525
2518
|
}
|
|
1526
|
-
return
|
|
2519
|
+
return _pool3;
|
|
1527
2520
|
}
|
|
1528
|
-
async function
|
|
1529
|
-
if (
|
|
1530
|
-
await
|
|
1531
|
-
|
|
2521
|
+
async function shutdownPool3() {
|
|
2522
|
+
if (_pool3 !== null) {
|
|
2523
|
+
await _pool3.close();
|
|
2524
|
+
_pool3 = null;
|
|
1532
2525
|
}
|
|
1533
2526
|
}
|
|
1534
2527
|
function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
|
|
@@ -1539,7 +2532,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
|
|
|
1539
2532
|
_satori: satori,
|
|
1540
2533
|
async renderCell(props, _complexityClass) {
|
|
1541
2534
|
const startMs = performance.now();
|
|
1542
|
-
const pool = await
|
|
2535
|
+
const pool = await getPool3(viewportWidth, viewportHeight);
|
|
1543
2536
|
const htmlHarness = await buildComponentHarness(
|
|
1544
2537
|
filePath,
|
|
1545
2538
|
componentName,
|
|
@@ -1636,7 +2629,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
|
|
|
1636
2629
|
};
|
|
1637
2630
|
}
|
|
1638
2631
|
function registerRenderSingle(renderCmd) {
|
|
1639
|
-
renderCmd.command("component <component>", { isDefault: true }).description("Render a single component to PNG or JSON").option("--props <json>", `Inline props JSON, e.g. '{"variant":"primary"}'`).option("--viewport <WxH>", "Viewport size e.g. 1280x720", "375x812").option("--theme <name>", "Theme name from the token system").option("-o, --output <path>", "Write PNG to file instead of stdout").option("--format <fmt>", "Output format: png or json (default: auto)").option("--manifest <path>", "Path to manifest.json",
|
|
2632
|
+
renderCmd.command("component <component>", { isDefault: true }).description("Render a single component to PNG or JSON").option("--props <json>", `Inline props JSON, e.g. '{"variant":"primary"}'`).option("--viewport <WxH>", "Viewport size e.g. 1280x720", "375x812").option("--theme <name>", "Theme name from the token system").option("-o, --output <path>", "Write PNG to file instead of stdout").option("--format <fmt>", "Output format: png or json (default: auto)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).action(
|
|
1640
2633
|
async (componentName, opts) => {
|
|
1641
2634
|
try {
|
|
1642
2635
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -1675,7 +2668,7 @@ Available: ${available}`
|
|
|
1675
2668
|
}
|
|
1676
2669
|
}
|
|
1677
2670
|
);
|
|
1678
|
-
await
|
|
2671
|
+
await shutdownPool3();
|
|
1679
2672
|
if (outcome.crashed) {
|
|
1680
2673
|
process.stderr.write(`\u2717 Render failed: ${outcome.error.message}
|
|
1681
2674
|
`);
|
|
@@ -1723,7 +2716,7 @@ Available: ${available}`
|
|
|
1723
2716
|
);
|
|
1724
2717
|
}
|
|
1725
2718
|
} catch (err) {
|
|
1726
|
-
await
|
|
2719
|
+
await shutdownPool3();
|
|
1727
2720
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1728
2721
|
`);
|
|
1729
2722
|
process.exit(1);
|
|
@@ -1735,7 +2728,7 @@ function registerRenderMatrix(renderCmd) {
|
|
|
1735
2728
|
renderCmd.command("matrix <component>").description("Render a component across a matrix of prop axes").option("--axes <spec>", "Axis definitions e.g. 'variant:primary,secondary size:sm,md,lg'").option(
|
|
1736
2729
|
"--contexts <ids>",
|
|
1737
2730
|
"Composition context IDs, comma-separated (e.g. centered,rtl,sidebar)"
|
|
1738
|
-
).option("--stress <ids>", "Stress preset IDs, comma-separated (e.g. text.long,text.unicode)").option("--sprite <path>", "Write sprite sheet PNG to file").option("--format <fmt>", "Output format: json|png|html|csv (default: auto)").option("--concurrency <n>", "Max parallel renders", "8").option("--manifest <path>", "Path to manifest.json",
|
|
2731
|
+
).option("--stress <ids>", "Stress preset IDs, comma-separated (e.g. text.long,text.unicode)").option("--sprite <path>", "Write sprite sheet PNG to file").option("--format <fmt>", "Output format: json|png|html|csv (default: auto)").option("--concurrency <n>", "Max parallel renders", "8").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).action(
|
|
1739
2732
|
async (componentName, opts) => {
|
|
1740
2733
|
try {
|
|
1741
2734
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -1808,7 +2801,7 @@ Available: ${available}`
|
|
|
1808
2801
|
concurrency
|
|
1809
2802
|
});
|
|
1810
2803
|
const result = await matrix.render();
|
|
1811
|
-
await
|
|
2804
|
+
await shutdownPool3();
|
|
1812
2805
|
process.stderr.write(
|
|
1813
2806
|
`Done. ${result.stats.totalCells} cells, avg ${result.stats.avgRenderTimeMs.toFixed(1)}ms
|
|
1814
2807
|
`
|
|
@@ -1853,7 +2846,7 @@ Available: ${available}`
|
|
|
1853
2846
|
process.stdout.write(formatMatrixCsv(componentName, result));
|
|
1854
2847
|
}
|
|
1855
2848
|
} catch (err) {
|
|
1856
|
-
await
|
|
2849
|
+
await shutdownPool3();
|
|
1857
2850
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1858
2851
|
`);
|
|
1859
2852
|
process.exit(1);
|
|
@@ -1862,7 +2855,7 @@ Available: ${available}`
|
|
|
1862
2855
|
);
|
|
1863
2856
|
}
|
|
1864
2857
|
function registerRenderAll(renderCmd) {
|
|
1865
|
-
renderCmd.command("all").description("Render all components from the manifest").option("--concurrency <n>", "Max parallel renders", "4").option("--output-dir <dir>", "Output directory for renders", DEFAULT_OUTPUT_DIR).option("--manifest <path>", "Path to manifest.json",
|
|
2858
|
+
renderCmd.command("all").description("Render all components from the manifest").option("--concurrency <n>", "Max parallel renders", "4").option("--output-dir <dir>", "Output directory for renders", DEFAULT_OUTPUT_DIR).option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).option("--format <fmt>", "Output format: json|png (default: png)", "png").action(
|
|
1866
2859
|
async (opts) => {
|
|
1867
2860
|
try {
|
|
1868
2861
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -1950,13 +2943,13 @@ function registerRenderAll(renderCmd) {
|
|
|
1950
2943
|
workers.push(worker());
|
|
1951
2944
|
}
|
|
1952
2945
|
await Promise.all(workers);
|
|
1953
|
-
await
|
|
2946
|
+
await shutdownPool3();
|
|
1954
2947
|
process.stderr.write("\n");
|
|
1955
2948
|
const summary = formatSummaryText(results, outputDir);
|
|
1956
2949
|
process.stderr.write(`${summary}
|
|
1957
2950
|
`);
|
|
1958
2951
|
} catch (err) {
|
|
1959
|
-
await
|
|
2952
|
+
await shutdownPool3();
|
|
1960
2953
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1961
2954
|
`);
|
|
1962
2955
|
process.exit(1);
|
|
@@ -1998,26 +2991,26 @@ function createRenderCommand() {
|
|
|
1998
2991
|
return renderCmd;
|
|
1999
2992
|
}
|
|
2000
2993
|
var DEFAULT_BASELINE_DIR = ".reactscope/baseline";
|
|
2001
|
-
var
|
|
2002
|
-
async function
|
|
2003
|
-
if (
|
|
2004
|
-
|
|
2994
|
+
var _pool4 = null;
|
|
2995
|
+
async function getPool4(viewportWidth, viewportHeight) {
|
|
2996
|
+
if (_pool4 === null) {
|
|
2997
|
+
_pool4 = new BrowserPool({
|
|
2005
2998
|
size: { browsers: 1, pagesPerBrowser: 4 },
|
|
2006
2999
|
viewportWidth,
|
|
2007
3000
|
viewportHeight
|
|
2008
3001
|
});
|
|
2009
|
-
await
|
|
3002
|
+
await _pool4.init();
|
|
2010
3003
|
}
|
|
2011
|
-
return
|
|
3004
|
+
return _pool4;
|
|
2012
3005
|
}
|
|
2013
|
-
async function
|
|
2014
|
-
if (
|
|
2015
|
-
await
|
|
2016
|
-
|
|
3006
|
+
async function shutdownPool4() {
|
|
3007
|
+
if (_pool4 !== null) {
|
|
3008
|
+
await _pool4.close();
|
|
3009
|
+
_pool4 = null;
|
|
2017
3010
|
}
|
|
2018
3011
|
}
|
|
2019
3012
|
async function renderComponent(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
2020
|
-
const pool = await
|
|
3013
|
+
const pool = await getPool4(viewportWidth, viewportHeight);
|
|
2021
3014
|
const htmlHarness = await buildComponentHarness(filePath, componentName, props, viewportWidth);
|
|
2022
3015
|
const slot = await pool.acquire();
|
|
2023
3016
|
const { page } = slot;
|
|
@@ -2150,12 +3143,12 @@ async function runBaseline(options = {}) {
|
|
|
2150
3143
|
mkdirSync(rendersDir, { recursive: true });
|
|
2151
3144
|
let manifest;
|
|
2152
3145
|
if (manifestPath !== void 0) {
|
|
2153
|
-
const { readFileSync:
|
|
3146
|
+
const { readFileSync: readFileSync8 } = await import('fs');
|
|
2154
3147
|
const absPath = resolve(rootDir, manifestPath);
|
|
2155
3148
|
if (!existsSync(absPath)) {
|
|
2156
3149
|
throw new Error(`Manifest not found at ${absPath}.`);
|
|
2157
3150
|
}
|
|
2158
|
-
manifest = JSON.parse(
|
|
3151
|
+
manifest = JSON.parse(readFileSync8(absPath, "utf-8"));
|
|
2159
3152
|
process.stderr.write(`Loaded manifest from ${manifestPath}
|
|
2160
3153
|
`);
|
|
2161
3154
|
} else {
|
|
@@ -2267,7 +3260,7 @@ async function runBaseline(options = {}) {
|
|
|
2267
3260
|
workers.push(worker());
|
|
2268
3261
|
}
|
|
2269
3262
|
await Promise.all(workers);
|
|
2270
|
-
await
|
|
3263
|
+
await shutdownPool4();
|
|
2271
3264
|
if (isTTY()) {
|
|
2272
3265
|
process.stderr.write("\n");
|
|
2273
3266
|
}
|
|
@@ -2318,10 +3311,10 @@ function registerBaselineSubCommand(reportCmd) {
|
|
|
2318
3311
|
}
|
|
2319
3312
|
|
|
2320
3313
|
// src/tree-formatter.ts
|
|
2321
|
-
var
|
|
2322
|
-
var
|
|
2323
|
-
var
|
|
2324
|
-
var
|
|
3314
|
+
var BRANCH2 = "\u251C\u2500\u2500 ";
|
|
3315
|
+
var LAST_BRANCH2 = "\u2514\u2500\u2500 ";
|
|
3316
|
+
var VERTICAL2 = "\u2502 ";
|
|
3317
|
+
var EMPTY2 = " ";
|
|
2325
3318
|
function buildLabel(node, options) {
|
|
2326
3319
|
const parts = [node.name];
|
|
2327
3320
|
if (node.type === "memo") {
|
|
@@ -2368,19 +3361,19 @@ function renderNode(node, prefix, isLast, depth, options, lines) {
|
|
|
2368
3361
|
}
|
|
2369
3362
|
return;
|
|
2370
3363
|
}
|
|
2371
|
-
const connector = isLast ?
|
|
3364
|
+
const connector = isLast ? LAST_BRANCH2 : BRANCH2;
|
|
2372
3365
|
const label = buildLabel(node, options);
|
|
2373
3366
|
lines.push(`${prefix}${connector}${label}`);
|
|
2374
3367
|
if (options.maxDepth !== void 0 && depth >= options.maxDepth) {
|
|
2375
3368
|
const childCount = countVisibleDescendants(node, options);
|
|
2376
3369
|
if (childCount > 0) {
|
|
2377
|
-
const nextPrefix2 = prefix + (isLast ?
|
|
2378
|
-
lines.push(`${nextPrefix2}${
|
|
3370
|
+
const nextPrefix2 = prefix + (isLast ? EMPTY2 : VERTICAL2);
|
|
3371
|
+
lines.push(`${nextPrefix2}${LAST_BRANCH2}\u2026 (${childCount} more)`);
|
|
2379
3372
|
}
|
|
2380
3373
|
return;
|
|
2381
3374
|
}
|
|
2382
3375
|
const visibleChildren = getVisibleChildren(node, options);
|
|
2383
|
-
const nextPrefix = prefix + (isLast ?
|
|
3376
|
+
const nextPrefix = prefix + (isLast ? EMPTY2 : VERTICAL2);
|
|
2384
3377
|
for (let i = 0; i < visibleChildren.length; i++) {
|
|
2385
3378
|
const child = visibleChildren[i];
|
|
2386
3379
|
if (child !== void 0) {
|
|
@@ -2422,7 +3415,7 @@ function formatTree(root, options = {}) {
|
|
|
2422
3415
|
if (options.maxDepth === 0) {
|
|
2423
3416
|
const childCount = countVisibleDescendants(root, options);
|
|
2424
3417
|
if (childCount > 0) {
|
|
2425
|
-
lines.push(`${
|
|
3418
|
+
lines.push(`${LAST_BRANCH2}\u2026 (${childCount} more)`);
|
|
2426
3419
|
}
|
|
2427
3420
|
} else {
|
|
2428
3421
|
const visibleChildren = getVisibleChildren(root, options);
|
|
@@ -2596,6 +3589,109 @@ function buildStructuredReport(report) {
|
|
|
2596
3589
|
}
|
|
2597
3590
|
var DEFAULT_TOKEN_FILE = "reactscope.tokens.json";
|
|
2598
3591
|
var CONFIG_FILE = "reactscope.config.json";
|
|
3592
|
+
var SUPPORTED_FORMATS = ["css", "ts", "scss", "tailwind", "flat-json", "figma"];
|
|
3593
|
+
function resolveTokenFilePath(fileFlag) {
|
|
3594
|
+
if (fileFlag !== void 0) {
|
|
3595
|
+
return resolve(process.cwd(), fileFlag);
|
|
3596
|
+
}
|
|
3597
|
+
const configPath = resolve(process.cwd(), CONFIG_FILE);
|
|
3598
|
+
if (existsSync(configPath)) {
|
|
3599
|
+
try {
|
|
3600
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
3601
|
+
const config = JSON.parse(raw);
|
|
3602
|
+
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
3603
|
+
const file = config.tokens.file;
|
|
3604
|
+
return resolve(process.cwd(), file);
|
|
3605
|
+
}
|
|
3606
|
+
} catch {
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
return resolve(process.cwd(), DEFAULT_TOKEN_FILE);
|
|
3610
|
+
}
|
|
3611
|
+
function createTokensExportCommand() {
|
|
3612
|
+
return new Command("export").description("Export design tokens to a downstream format").requiredOption("--format <fmt>", `Output format: ${SUPPORTED_FORMATS.join(", ")}`).option("--file <path>", "Path to token file (overrides config)").option("--out <path>", "Write output to file instead of stdout").option("--prefix <prefix>", "CSS/SCSS: prefix for variable names (e.g. 'scope')").option("--selector <selector>", "CSS: custom root selector (default: ':root')").option(
|
|
3613
|
+
"--theme <name>",
|
|
3614
|
+
"Include theme overrides for the named theme (applies to css, ts, scss, tailwind, figma)"
|
|
3615
|
+
).action(
|
|
3616
|
+
(opts) => {
|
|
3617
|
+
if (!SUPPORTED_FORMATS.includes(opts.format)) {
|
|
3618
|
+
process.stderr.write(
|
|
3619
|
+
`Error: unsupported format "${opts.format}".
|
|
3620
|
+
Supported formats: ${SUPPORTED_FORMATS.join(", ")}
|
|
3621
|
+
`
|
|
3622
|
+
);
|
|
3623
|
+
process.exit(1);
|
|
3624
|
+
}
|
|
3625
|
+
const format = opts.format;
|
|
3626
|
+
try {
|
|
3627
|
+
const filePath = resolveTokenFilePath(opts.file);
|
|
3628
|
+
if (!existsSync(filePath)) {
|
|
3629
|
+
throw new Error(
|
|
3630
|
+
`Token file not found at ${filePath}.
|
|
3631
|
+
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
3632
|
+
);
|
|
3633
|
+
}
|
|
3634
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
3635
|
+
const { tokens, rawFile } = parseTokenFileSync(raw);
|
|
3636
|
+
let themesMap;
|
|
3637
|
+
if (opts.theme !== void 0) {
|
|
3638
|
+
if (!rawFile.themes || !(opts.theme in rawFile.themes)) {
|
|
3639
|
+
const available = rawFile.themes ? Object.keys(rawFile.themes).join(", ") : "none";
|
|
3640
|
+
throw new Error(
|
|
3641
|
+
`Theme "${opts.theme}" not found in token file.
|
|
3642
|
+
Available themes: ${available}`
|
|
3643
|
+
);
|
|
3644
|
+
}
|
|
3645
|
+
const baseResolver = new TokenResolver(tokens);
|
|
3646
|
+
const themeResolver = ThemeResolver.fromTokenFile(
|
|
3647
|
+
baseResolver,
|
|
3648
|
+
rawFile
|
|
3649
|
+
);
|
|
3650
|
+
const themeNames = themeResolver.listThemes();
|
|
3651
|
+
if (!themeNames.includes(opts.theme)) {
|
|
3652
|
+
throw new Error(
|
|
3653
|
+
`Theme "${opts.theme}" could not be resolved.
|
|
3654
|
+
Available themes: ${themeNames.join(", ")}`
|
|
3655
|
+
);
|
|
3656
|
+
}
|
|
3657
|
+
const themedTokens = themeResolver.buildThemedTokens(opts.theme);
|
|
3658
|
+
const overrideMap = /* @__PURE__ */ new Map();
|
|
3659
|
+
for (const themedToken of themedTokens) {
|
|
3660
|
+
const baseToken = tokens.find((t) => t.path === themedToken.path);
|
|
3661
|
+
if (baseToken !== void 0 && themedToken.resolvedValue !== baseToken.resolvedValue) {
|
|
3662
|
+
overrideMap.set(themedToken.path, themedToken.resolvedValue);
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
themesMap = /* @__PURE__ */ new Map([[opts.theme, overrideMap]]);
|
|
3666
|
+
}
|
|
3667
|
+
const output = exportTokens(tokens, format, {
|
|
3668
|
+
prefix: opts.prefix,
|
|
3669
|
+
rootSelector: opts.selector,
|
|
3670
|
+
themes: themesMap
|
|
3671
|
+
});
|
|
3672
|
+
if (opts.out !== void 0) {
|
|
3673
|
+
const outPath = resolve(process.cwd(), opts.out);
|
|
3674
|
+
writeFileSync(outPath, output, "utf-8");
|
|
3675
|
+
process.stderr.write(`Exported ${tokens.length} tokens to ${outPath}
|
|
3676
|
+
`);
|
|
3677
|
+
} else {
|
|
3678
|
+
process.stdout.write(output);
|
|
3679
|
+
if (!output.endsWith("\n")) {
|
|
3680
|
+
process.stdout.write("\n");
|
|
3681
|
+
}
|
|
3682
|
+
}
|
|
3683
|
+
} catch (err) {
|
|
3684
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
3685
|
+
`);
|
|
3686
|
+
process.exit(1);
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
);
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
// src/tokens/commands.ts
|
|
3693
|
+
var DEFAULT_TOKEN_FILE2 = "reactscope.tokens.json";
|
|
3694
|
+
var CONFIG_FILE2 = "reactscope.config.json";
|
|
2599
3695
|
function isTTY2() {
|
|
2600
3696
|
return process.stdout.isTTY === true;
|
|
2601
3697
|
}
|
|
@@ -2613,11 +3709,11 @@ function buildTable2(headers, rows) {
|
|
|
2613
3709
|
);
|
|
2614
3710
|
return [headerRow, divider, ...dataRows].join("\n");
|
|
2615
3711
|
}
|
|
2616
|
-
function
|
|
3712
|
+
function resolveTokenFilePath2(fileFlag) {
|
|
2617
3713
|
if (fileFlag !== void 0) {
|
|
2618
3714
|
return resolve(process.cwd(), fileFlag);
|
|
2619
3715
|
}
|
|
2620
|
-
const configPath = resolve(process.cwd(),
|
|
3716
|
+
const configPath = resolve(process.cwd(), CONFIG_FILE2);
|
|
2621
3717
|
if (existsSync(configPath)) {
|
|
2622
3718
|
try {
|
|
2623
3719
|
const raw = readFileSync(configPath, "utf-8");
|
|
@@ -2629,7 +3725,7 @@ function resolveTokenFilePath(fileFlag) {
|
|
|
2629
3725
|
} catch {
|
|
2630
3726
|
}
|
|
2631
3727
|
}
|
|
2632
|
-
return resolve(process.cwd(),
|
|
3728
|
+
return resolve(process.cwd(), DEFAULT_TOKEN_FILE2);
|
|
2633
3729
|
}
|
|
2634
3730
|
function loadTokens(absPath) {
|
|
2635
3731
|
if (!existsSync(absPath)) {
|
|
@@ -2676,7 +3772,7 @@ function buildResolutionChain(startPath, rawTokens) {
|
|
|
2676
3772
|
function registerGet2(tokensCmd) {
|
|
2677
3773
|
tokensCmd.command("get <path>").description("Resolve a token path to its computed value").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
|
|
2678
3774
|
try {
|
|
2679
|
-
const filePath =
|
|
3775
|
+
const filePath = resolveTokenFilePath2(opts.file);
|
|
2680
3776
|
const { tokens } = loadTokens(filePath);
|
|
2681
3777
|
const resolver = new TokenResolver(tokens);
|
|
2682
3778
|
const resolvedValue = resolver.resolve(tokenPath);
|
|
@@ -2702,7 +3798,7 @@ function registerList2(tokensCmd) {
|
|
|
2702
3798
|
tokensCmd.command("list [category]").description("List tokens, optionally filtered by category or type").option("--type <type>", "Filter by token type (color, dimension, fontFamily, etc.)").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
|
|
2703
3799
|
(category, opts) => {
|
|
2704
3800
|
try {
|
|
2705
|
-
const filePath =
|
|
3801
|
+
const filePath = resolveTokenFilePath2(opts.file);
|
|
2706
3802
|
const { tokens } = loadTokens(filePath);
|
|
2707
3803
|
const resolver = new TokenResolver(tokens);
|
|
2708
3804
|
const filtered = resolver.list(opts.type, category);
|
|
@@ -2732,7 +3828,7 @@ function registerSearch(tokensCmd) {
|
|
|
2732
3828
|
tokensCmd.command("search <value>").description("Find which token(s) match a computed value (supports fuzzy color matching)").option("--type <type>", "Restrict search to a specific token type").option("--fuzzy", "Return nearest match even if no exact match exists", false).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
|
|
2733
3829
|
(value, opts) => {
|
|
2734
3830
|
try {
|
|
2735
|
-
const filePath =
|
|
3831
|
+
const filePath = resolveTokenFilePath2(opts.file);
|
|
2736
3832
|
const { tokens } = loadTokens(filePath);
|
|
2737
3833
|
const resolver = new TokenResolver(tokens);
|
|
2738
3834
|
const useJson = opts.format === "json" || opts.format !== "table" && !isTTY2();
|
|
@@ -2814,7 +3910,7 @@ Tip: use --fuzzy for nearest-match search.
|
|
|
2814
3910
|
function registerResolve(tokensCmd) {
|
|
2815
3911
|
tokensCmd.command("resolve <path>").description("Show the full resolution chain for a token").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
|
|
2816
3912
|
try {
|
|
2817
|
-
const filePath =
|
|
3913
|
+
const filePath = resolveTokenFilePath2(opts.file);
|
|
2818
3914
|
const absFilePath = filePath;
|
|
2819
3915
|
const { tokens, rawFile } = loadTokens(absFilePath);
|
|
2820
3916
|
const resolver = new TokenResolver(tokens);
|
|
@@ -2851,7 +3947,7 @@ function registerValidate(tokensCmd) {
|
|
|
2851
3947
|
"Validate the token file for errors (circular refs, missing refs, type mismatches)"
|
|
2852
3948
|
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((opts) => {
|
|
2853
3949
|
try {
|
|
2854
|
-
const filePath =
|
|
3950
|
+
const filePath = resolveTokenFilePath2(opts.file);
|
|
2855
3951
|
if (!existsSync(filePath)) {
|
|
2856
3952
|
throw new Error(
|
|
2857
3953
|
`Token file not found at ${filePath}.
|
|
@@ -2934,6 +4030,7 @@ function createTokensCommand() {
|
|
|
2934
4030
|
registerSearch(tokensCmd);
|
|
2935
4031
|
registerResolve(tokensCmd);
|
|
2936
4032
|
registerValidate(tokensCmd);
|
|
4033
|
+
tokensCmd.addCommand(createTokensExportCommand());
|
|
2937
4034
|
return tokensCmd;
|
|
2938
4035
|
}
|
|
2939
4036
|
|
|
@@ -3033,6 +4130,6 @@ function createProgram(options = {}) {
|
|
|
3033
4130
|
return program;
|
|
3034
4131
|
}
|
|
3035
4132
|
|
|
3036
|
-
export { createInitCommand, createManifestCommand, createProgram, createTokensCommand, isTTY, matchGlob, runInit };
|
|
4133
|
+
export { createInitCommand, createInstrumentCommand, createManifestCommand, createProgram, createTokensCommand, createTokensExportCommand, isTTY, matchGlob, resolveTokenFilePath, runInit };
|
|
3037
4134
|
//# sourceMappingURL=index.js.map
|
|
3038
4135
|
//# sourceMappingURL=index.js.map
|