@agent-scope/cli 1.6.0 → 1.8.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 +1046 -218
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +832 -21
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +832 -22
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/program.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
5
5
|
import { generateTest, loadTrace } from "@agent-scope/playwright";
|
|
6
|
-
import { Command as
|
|
6
|
+
import { Command as Command5 } from "commander";
|
|
7
7
|
|
|
8
8
|
// src/browser.ts
|
|
9
9
|
import { writeFileSync } from "fs";
|
|
@@ -50,6 +50,125 @@ function writeReportToFile(report, outputPath, pretty) {
|
|
|
50
50
|
writeFileSync(outputPath, json, "utf-8");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// src/instrument/renders.ts
|
|
54
|
+
import { resolve as resolve2 } from "path";
|
|
55
|
+
import { BrowserPool } from "@agent-scope/render";
|
|
56
|
+
import { Command as Command2 } from "commander";
|
|
57
|
+
|
|
58
|
+
// src/component-bundler.ts
|
|
59
|
+
import { dirname } from "path";
|
|
60
|
+
import * as esbuild from "esbuild";
|
|
61
|
+
async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss) {
|
|
62
|
+
const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
|
|
63
|
+
return wrapInHtml(bundledScript, viewportWidth, projectCss);
|
|
64
|
+
}
|
|
65
|
+
async function bundleComponentToIIFE(filePath, componentName, props) {
|
|
66
|
+
const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
|
|
67
|
+
const wrapperCode = (
|
|
68
|
+
/* ts */
|
|
69
|
+
`
|
|
70
|
+
import * as __scopeMod from ${JSON.stringify(filePath)};
|
|
71
|
+
import { createRoot } from "react-dom/client";
|
|
72
|
+
import { createElement } from "react";
|
|
73
|
+
|
|
74
|
+
(function scopeRenderHarness() {
|
|
75
|
+
var Component =
|
|
76
|
+
__scopeMod["default"] ||
|
|
77
|
+
__scopeMod[${JSON.stringify(componentName)}] ||
|
|
78
|
+
(Object.values(__scopeMod).find(
|
|
79
|
+
function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
|
|
80
|
+
));
|
|
81
|
+
|
|
82
|
+
if (!Component) {
|
|
83
|
+
window.__SCOPE_RENDER_ERROR__ =
|
|
84
|
+
"No renderable component found. Checked: default, " +
|
|
85
|
+
${JSON.stringify(componentName)} + ", and PascalCase named exports. " +
|
|
86
|
+
"Available exports: " + Object.keys(__scopeMod).join(", ");
|
|
87
|
+
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
var props = ${propsJson};
|
|
93
|
+
var rootEl = document.getElementById("scope-root");
|
|
94
|
+
if (!rootEl) {
|
|
95
|
+
window.__SCOPE_RENDER_ERROR__ = "#scope-root element not found";
|
|
96
|
+
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
createRoot(rootEl).render(createElement(Component, props));
|
|
100
|
+
// Use requestAnimationFrame to let React flush the render
|
|
101
|
+
requestAnimationFrame(function() {
|
|
102
|
+
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
window.__SCOPE_RENDER_ERROR__ = err instanceof Error ? err.message : String(err);
|
|
106
|
+
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
`
|
|
110
|
+
);
|
|
111
|
+
const result = await esbuild.build({
|
|
112
|
+
stdin: {
|
|
113
|
+
contents: wrapperCode,
|
|
114
|
+
// Resolve relative imports (within the component's dir)
|
|
115
|
+
resolveDir: dirname(filePath),
|
|
116
|
+
loader: "tsx",
|
|
117
|
+
sourcefile: "__scope_harness__.tsx"
|
|
118
|
+
},
|
|
119
|
+
bundle: true,
|
|
120
|
+
format: "iife",
|
|
121
|
+
write: false,
|
|
122
|
+
platform: "browser",
|
|
123
|
+
jsx: "automatic",
|
|
124
|
+
jsxImportSource: "react",
|
|
125
|
+
target: "es2020",
|
|
126
|
+
// Bundle everything — no externals
|
|
127
|
+
external: [],
|
|
128
|
+
define: {
|
|
129
|
+
"process.env.NODE_ENV": '"development"',
|
|
130
|
+
global: "globalThis"
|
|
131
|
+
},
|
|
132
|
+
logLevel: "silent",
|
|
133
|
+
// Suppress "React must be in scope" warnings from old JSX (we use automatic)
|
|
134
|
+
banner: {
|
|
135
|
+
js: "/* @agent-scope/cli component harness */"
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
if (result.errors.length > 0) {
|
|
139
|
+
const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
|
|
140
|
+
throw new Error(`esbuild failed to bundle component:
|
|
141
|
+
${msg}`);
|
|
142
|
+
}
|
|
143
|
+
const outputFile = result.outputFiles?.[0];
|
|
144
|
+
if (outputFile === void 0 || outputFile.text.length === 0) {
|
|
145
|
+
throw new Error("esbuild produced no output");
|
|
146
|
+
}
|
|
147
|
+
return outputFile.text;
|
|
148
|
+
}
|
|
149
|
+
function wrapInHtml(bundledScript, viewportWidth, projectCss) {
|
|
150
|
+
const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
|
|
151
|
+
${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
152
|
+
</style>` : "";
|
|
153
|
+
return `<!DOCTYPE html>
|
|
154
|
+
<html lang="en">
|
|
155
|
+
<head>
|
|
156
|
+
<meta charset="UTF-8" />
|
|
157
|
+
<meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
|
|
158
|
+
<style>
|
|
159
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
160
|
+
html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
|
|
161
|
+
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
|
|
162
|
+
</style>
|
|
163
|
+
${projectStyleBlock}
|
|
164
|
+
</head>
|
|
165
|
+
<body>
|
|
166
|
+
<div id="scope-root" data-reactscope-root></div>
|
|
167
|
+
<script>${bundledScript}</script>
|
|
168
|
+
</body>
|
|
169
|
+
</html>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
53
172
|
// src/manifest-commands.ts
|
|
54
173
|
import { existsSync, mkdirSync, readFileSync, writeFileSync as writeFileSync2 } from "fs";
|
|
55
174
|
import { resolve } from "path";
|
|
@@ -323,135 +442,6 @@ function createManifestCommand() {
|
|
|
323
442
|
return manifestCmd;
|
|
324
443
|
}
|
|
325
444
|
|
|
326
|
-
// src/render-commands.ts
|
|
327
|
-
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
328
|
-
import { resolve as resolve3 } from "path";
|
|
329
|
-
import {
|
|
330
|
-
ALL_CONTEXT_IDS,
|
|
331
|
-
ALL_STRESS_IDS,
|
|
332
|
-
BrowserPool,
|
|
333
|
-
contextAxis,
|
|
334
|
-
RenderMatrix,
|
|
335
|
-
SatoriRenderer,
|
|
336
|
-
safeRender,
|
|
337
|
-
stressAxis
|
|
338
|
-
} from "@agent-scope/render";
|
|
339
|
-
import { Command as Command2 } from "commander";
|
|
340
|
-
|
|
341
|
-
// src/component-bundler.ts
|
|
342
|
-
import { dirname } from "path";
|
|
343
|
-
import * as esbuild from "esbuild";
|
|
344
|
-
async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss) {
|
|
345
|
-
const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
|
|
346
|
-
return wrapInHtml(bundledScript, viewportWidth, projectCss);
|
|
347
|
-
}
|
|
348
|
-
async function bundleComponentToIIFE(filePath, componentName, props) {
|
|
349
|
-
const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
|
|
350
|
-
const wrapperCode = (
|
|
351
|
-
/* ts */
|
|
352
|
-
`
|
|
353
|
-
import * as __scopeMod from ${JSON.stringify(filePath)};
|
|
354
|
-
import { createRoot } from "react-dom/client";
|
|
355
|
-
import { createElement } from "react";
|
|
356
|
-
|
|
357
|
-
(function scopeRenderHarness() {
|
|
358
|
-
var Component =
|
|
359
|
-
__scopeMod["default"] ||
|
|
360
|
-
__scopeMod[${JSON.stringify(componentName)}] ||
|
|
361
|
-
(Object.values(__scopeMod).find(
|
|
362
|
-
function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
|
|
363
|
-
));
|
|
364
|
-
|
|
365
|
-
if (!Component) {
|
|
366
|
-
window.__SCOPE_RENDER_ERROR__ =
|
|
367
|
-
"No renderable component found. Checked: default, " +
|
|
368
|
-
${JSON.stringify(componentName)} + ", and PascalCase named exports. " +
|
|
369
|
-
"Available exports: " + Object.keys(__scopeMod).join(", ");
|
|
370
|
-
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
try {
|
|
375
|
-
var props = ${propsJson};
|
|
376
|
-
var rootEl = document.getElementById("scope-root");
|
|
377
|
-
if (!rootEl) {
|
|
378
|
-
window.__SCOPE_RENDER_ERROR__ = "#scope-root element not found";
|
|
379
|
-
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
createRoot(rootEl).render(createElement(Component, props));
|
|
383
|
-
// Use requestAnimationFrame to let React flush the render
|
|
384
|
-
requestAnimationFrame(function() {
|
|
385
|
-
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
386
|
-
});
|
|
387
|
-
} catch (err) {
|
|
388
|
-
window.__SCOPE_RENDER_ERROR__ = err instanceof Error ? err.message : String(err);
|
|
389
|
-
window.__SCOPE_RENDER_COMPLETE__ = true;
|
|
390
|
-
}
|
|
391
|
-
})();
|
|
392
|
-
`
|
|
393
|
-
);
|
|
394
|
-
const result = await esbuild.build({
|
|
395
|
-
stdin: {
|
|
396
|
-
contents: wrapperCode,
|
|
397
|
-
// Resolve relative imports (within the component's dir)
|
|
398
|
-
resolveDir: dirname(filePath),
|
|
399
|
-
loader: "tsx",
|
|
400
|
-
sourcefile: "__scope_harness__.tsx"
|
|
401
|
-
},
|
|
402
|
-
bundle: true,
|
|
403
|
-
format: "iife",
|
|
404
|
-
write: false,
|
|
405
|
-
platform: "browser",
|
|
406
|
-
jsx: "automatic",
|
|
407
|
-
jsxImportSource: "react",
|
|
408
|
-
target: "es2020",
|
|
409
|
-
// Bundle everything — no externals
|
|
410
|
-
external: [],
|
|
411
|
-
define: {
|
|
412
|
-
"process.env.NODE_ENV": '"development"',
|
|
413
|
-
global: "globalThis"
|
|
414
|
-
},
|
|
415
|
-
logLevel: "silent",
|
|
416
|
-
// Suppress "React must be in scope" warnings from old JSX (we use automatic)
|
|
417
|
-
banner: {
|
|
418
|
-
js: "/* @agent-scope/cli component harness */"
|
|
419
|
-
}
|
|
420
|
-
});
|
|
421
|
-
if (result.errors.length > 0) {
|
|
422
|
-
const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
|
|
423
|
-
throw new Error(`esbuild failed to bundle component:
|
|
424
|
-
${msg}`);
|
|
425
|
-
}
|
|
426
|
-
const outputFile = result.outputFiles?.[0];
|
|
427
|
-
if (outputFile === void 0 || outputFile.text.length === 0) {
|
|
428
|
-
throw new Error("esbuild produced no output");
|
|
429
|
-
}
|
|
430
|
-
return outputFile.text;
|
|
431
|
-
}
|
|
432
|
-
function wrapInHtml(bundledScript, viewportWidth, projectCss) {
|
|
433
|
-
const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
|
|
434
|
-
${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
435
|
-
</style>` : "";
|
|
436
|
-
return `<!DOCTYPE html>
|
|
437
|
-
<html lang="en">
|
|
438
|
-
<head>
|
|
439
|
-
<meta charset="UTF-8" />
|
|
440
|
-
<meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
|
|
441
|
-
<style>
|
|
442
|
-
*, *::before, *::after { box-sizing: border-box; }
|
|
443
|
-
html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
|
|
444
|
-
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
|
|
445
|
-
</style>
|
|
446
|
-
${projectStyleBlock}
|
|
447
|
-
</head>
|
|
448
|
-
<body>
|
|
449
|
-
<div id="scope-root" data-reactscope-root></div>
|
|
450
|
-
<script>${bundledScript}</script>
|
|
451
|
-
</body>
|
|
452
|
-
</html>`;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
445
|
// src/render-formatter.ts
|
|
456
446
|
function parseViewport(spec) {
|
|
457
447
|
const lower = spec.toLowerCase();
|
|
@@ -568,54 +558,536 @@ function formatMatrixCsv(componentName, result) {
|
|
|
568
558
|
return `${[headers.join(","), ...rows].join("\n")}
|
|
569
559
|
`;
|
|
570
560
|
}
|
|
571
|
-
function renderProgressBar(completed, total, currentName, pct, barWidth = 20) {
|
|
572
|
-
const filled = Math.round(pct / 100 * barWidth);
|
|
573
|
-
const empty = barWidth - filled;
|
|
574
|
-
const bar = "=".repeat(Math.max(0, filled - 1)) + (filled > 0 ? ">" : "") + " ".repeat(empty);
|
|
575
|
-
const nameSlice = currentName.slice(0, 25).padEnd(25);
|
|
576
|
-
return `Rendering ${completed}/${total} ${nameSlice} [${bar}] ${pct}%`;
|
|
561
|
+
function renderProgressBar(completed, total, currentName, pct, barWidth = 20) {
|
|
562
|
+
const filled = Math.round(pct / 100 * barWidth);
|
|
563
|
+
const empty = barWidth - filled;
|
|
564
|
+
const bar = "=".repeat(Math.max(0, filled - 1)) + (filled > 0 ? ">" : "") + " ".repeat(empty);
|
|
565
|
+
const nameSlice = currentName.slice(0, 25).padEnd(25);
|
|
566
|
+
return `Rendering ${completed}/${total} ${nameSlice} [${bar}] ${pct}%`;
|
|
567
|
+
}
|
|
568
|
+
function formatSummaryText(results, outputDir) {
|
|
569
|
+
const total = results.length;
|
|
570
|
+
const passed = results.filter((r) => r.success).length;
|
|
571
|
+
const failed = total - passed;
|
|
572
|
+
const successTimes = results.filter((r) => r.success).map((r) => r.renderTimeMs);
|
|
573
|
+
const avgMs = successTimes.length > 0 ? successTimes.reduce((a, b) => a + b, 0) / successTimes.length : 0;
|
|
574
|
+
const lines = [
|
|
575
|
+
"\u2500".repeat(60),
|
|
576
|
+
`Render Summary`,
|
|
577
|
+
"\u2500".repeat(60),
|
|
578
|
+
` Total components : ${total}`,
|
|
579
|
+
` Passed : ${passed}`,
|
|
580
|
+
` Failed : ${failed}`,
|
|
581
|
+
` Avg render time : ${avgMs.toFixed(1)}ms`,
|
|
582
|
+
` Output dir : ${outputDir}`
|
|
583
|
+
];
|
|
584
|
+
if (failed > 0) {
|
|
585
|
+
lines.push("", " Failed components:");
|
|
586
|
+
for (const r of results) {
|
|
587
|
+
if (!r.success) {
|
|
588
|
+
lines.push(` \u2717 ${r.name}: ${r.errorMessage ?? "unknown error"}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
lines.push("\u2500".repeat(60));
|
|
593
|
+
return lines.join("\n");
|
|
594
|
+
}
|
|
595
|
+
function escapeHtml(str) {
|
|
596
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
597
|
+
}
|
|
598
|
+
function csvEscape(value) {
|
|
599
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
600
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
601
|
+
}
|
|
602
|
+
return value;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// src/instrument/renders.ts
|
|
606
|
+
var MANIFEST_PATH2 = ".reactscope/manifest.json";
|
|
607
|
+
function determineTrigger(event) {
|
|
608
|
+
if (event.forceUpdate) return "force_update";
|
|
609
|
+
if (event.stateChanged) return "state_change";
|
|
610
|
+
if (event.propsChanged) return "props_change";
|
|
611
|
+
if (event.contextChanged) return "context_change";
|
|
612
|
+
if (event.hookDepsChanged) return "hook_dependency";
|
|
613
|
+
return "parent_rerender";
|
|
614
|
+
}
|
|
615
|
+
function isWastedRender(event) {
|
|
616
|
+
return !event.propsChanged && !event.stateChanged && !event.contextChanged && !event.memoized;
|
|
617
|
+
}
|
|
618
|
+
function buildCausalityChains(rawEvents) {
|
|
619
|
+
const result = [];
|
|
620
|
+
const componentLastRender = /* @__PURE__ */ new Map();
|
|
621
|
+
for (const raw of rawEvents) {
|
|
622
|
+
const trigger = determineTrigger(raw);
|
|
623
|
+
const wasted = isWastedRender(raw);
|
|
624
|
+
const chain = [];
|
|
625
|
+
let current = raw;
|
|
626
|
+
const visited = /* @__PURE__ */ new Set();
|
|
627
|
+
while (true) {
|
|
628
|
+
if (visited.has(current.component)) break;
|
|
629
|
+
visited.add(current.component);
|
|
630
|
+
const currentTrigger = determineTrigger(current);
|
|
631
|
+
chain.unshift({
|
|
632
|
+
component: current.component,
|
|
633
|
+
trigger: currentTrigger,
|
|
634
|
+
propsChanged: current.propsChanged,
|
|
635
|
+
stateChanged: current.stateChanged,
|
|
636
|
+
contextChanged: current.contextChanged
|
|
637
|
+
});
|
|
638
|
+
if (currentTrigger !== "parent_rerender" || current.parentComponent === null) {
|
|
639
|
+
break;
|
|
640
|
+
}
|
|
641
|
+
const parentEvent = componentLastRender.get(current.parentComponent);
|
|
642
|
+
if (parentEvent === void 0) break;
|
|
643
|
+
current = parentEvent;
|
|
644
|
+
}
|
|
645
|
+
const rootCause = chain[0];
|
|
646
|
+
const cascadeRenders = rawEvents.filter((e) => {
|
|
647
|
+
if (rootCause === void 0) return false;
|
|
648
|
+
return e.component !== rootCause.component && !e.stateChanged && !e.propsChanged;
|
|
649
|
+
});
|
|
650
|
+
result.push({
|
|
651
|
+
component: raw.component,
|
|
652
|
+
renderIndex: raw.renderIndex,
|
|
653
|
+
trigger,
|
|
654
|
+
propsChanged: raw.propsChanged,
|
|
655
|
+
stateChanged: raw.stateChanged,
|
|
656
|
+
contextChanged: raw.contextChanged,
|
|
657
|
+
memoized: raw.memoized,
|
|
658
|
+
wasted,
|
|
659
|
+
chain,
|
|
660
|
+
cascade: {
|
|
661
|
+
totalRendersTriggered: cascadeRenders.length,
|
|
662
|
+
uniqueComponents: new Set(cascadeRenders.map((e) => e.component)).size,
|
|
663
|
+
unchangedPropRenders: cascadeRenders.filter((e) => !e.propsChanged).length
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
componentLastRender.set(raw.component, raw);
|
|
667
|
+
}
|
|
668
|
+
return result;
|
|
669
|
+
}
|
|
670
|
+
function applyHeuristicFlags(renders) {
|
|
671
|
+
const flags = [];
|
|
672
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
673
|
+
for (const r of renders) {
|
|
674
|
+
if (!byComponent.has(r.component)) byComponent.set(r.component, []);
|
|
675
|
+
byComponent.get(r.component).push(r);
|
|
676
|
+
}
|
|
677
|
+
for (const [component, events] of byComponent) {
|
|
678
|
+
const wastedCount = events.filter((e) => e.wasted).length;
|
|
679
|
+
const totalCount = events.length;
|
|
680
|
+
if (wastedCount > 0) {
|
|
681
|
+
flags.push({
|
|
682
|
+
id: "WASTED_RENDER",
|
|
683
|
+
severity: "warning",
|
|
684
|
+
component,
|
|
685
|
+
detail: `${wastedCount}/${totalCount} renders were wasted \u2014 unchanged props/state/context, not memoized`,
|
|
686
|
+
data: { wastedCount, totalCount, wastedRatio: wastedCount / totalCount }
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
for (const event of events) {
|
|
690
|
+
if (event.cascade.totalRendersTriggered > 50) {
|
|
691
|
+
flags.push({
|
|
692
|
+
id: "RENDER_CASCADE",
|
|
693
|
+
severity: "warning",
|
|
694
|
+
component,
|
|
695
|
+
detail: `State change in ${component} triggered ${event.cascade.totalRendersTriggered} downstream re-renders`,
|
|
696
|
+
data: {
|
|
697
|
+
totalRendersTriggered: event.cascade.totalRendersTriggered,
|
|
698
|
+
uniqueComponents: event.cascade.uniqueComponents,
|
|
699
|
+
unchangedPropRenders: event.cascade.unchangedPropRenders
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return flags;
|
|
707
|
+
}
|
|
708
|
+
function buildInstrumentationScript() {
|
|
709
|
+
return (
|
|
710
|
+
/* js */
|
|
711
|
+
`
|
|
712
|
+
(function installScopeRenderInstrumentation() {
|
|
713
|
+
window.__SCOPE_RENDER_EVENTS__ = [];
|
|
714
|
+
window.__SCOPE_RENDER_INDEX__ = 0;
|
|
715
|
+
|
|
716
|
+
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
717
|
+
if (!hook) return;
|
|
718
|
+
|
|
719
|
+
var originalOnCommit = hook.onCommitFiberRoot;
|
|
720
|
+
var renderedComponents = new Map(); // componentName -> { lastProps, lastState }
|
|
721
|
+
|
|
722
|
+
function extractName(fiber) {
|
|
723
|
+
if (!fiber) return 'Unknown';
|
|
724
|
+
var type = fiber.type;
|
|
725
|
+
if (!type) return 'Unknown';
|
|
726
|
+
if (typeof type === 'string') return type; // host element
|
|
727
|
+
if (typeof type === 'function') return type.displayName || type.name || 'Anonymous';
|
|
728
|
+
if (type.displayName) return type.displayName;
|
|
729
|
+
if (type.render && typeof type.render === 'function') {
|
|
730
|
+
return type.render.displayName || type.render.name || 'Anonymous';
|
|
731
|
+
}
|
|
732
|
+
return 'Anonymous';
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function isMemoized(fiber) {
|
|
736
|
+
// MemoComponent = 14, SimpleMemoComponent = 15
|
|
737
|
+
return fiber.tag === 14 || fiber.tag === 15;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function isComponent(fiber) {
|
|
741
|
+
// FunctionComponent=0, ClassComponent=1, MemoComponent=14, SimpleMemoComponent=15
|
|
742
|
+
return fiber.tag === 0 || fiber.tag === 1 || fiber.tag === 14 || fiber.tag === 15;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function shallowEqual(a, b) {
|
|
746
|
+
if (a === b) return true;
|
|
747
|
+
if (!a || !b) return a === b;
|
|
748
|
+
var keysA = Object.keys(a);
|
|
749
|
+
var keysB = Object.keys(b);
|
|
750
|
+
if (keysA.length !== keysB.length) return false;
|
|
751
|
+
for (var i = 0; i < keysA.length; i++) {
|
|
752
|
+
var k = keysA[i];
|
|
753
|
+
if (k === 'children') continue; // ignore children prop
|
|
754
|
+
if (a[k] !== b[k]) return false;
|
|
755
|
+
}
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function getParentComponentName(fiber) {
|
|
760
|
+
var parent = fiber.return;
|
|
761
|
+
while (parent) {
|
|
762
|
+
if (isComponent(parent)) {
|
|
763
|
+
var name = extractName(parent);
|
|
764
|
+
if (name && name !== 'Unknown' && !/^[a-z]/.test(name)) return name;
|
|
765
|
+
}
|
|
766
|
+
parent = parent.return;
|
|
767
|
+
}
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function walkCommit(fiber) {
|
|
772
|
+
if (!fiber) return;
|
|
773
|
+
|
|
774
|
+
if (isComponent(fiber)) {
|
|
775
|
+
var name = extractName(fiber);
|
|
776
|
+
if (name && name !== 'Unknown' && !/^[a-z]/.test(name)) {
|
|
777
|
+
var memoized = isMemoized(fiber);
|
|
778
|
+
var currentProps = fiber.memoizedProps || {};
|
|
779
|
+
var prev = renderedComponents.get(name);
|
|
780
|
+
|
|
781
|
+
var propsChanged = true;
|
|
782
|
+
var stateChanged = false;
|
|
783
|
+
var contextChanged = false;
|
|
784
|
+
var hookDepsChanged = false;
|
|
785
|
+
var forceUpdate = false;
|
|
786
|
+
|
|
787
|
+
if (prev) {
|
|
788
|
+
propsChanged = !shallowEqual(prev.lastProps, currentProps);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// State: check memoizedState chain
|
|
792
|
+
var memoizedState = fiber.memoizedState;
|
|
793
|
+
if (prev && prev.lastStateSerialized !== undefined) {
|
|
794
|
+
try {
|
|
795
|
+
var stateSig = JSON.stringify(memoizedState ? memoizedState.memoizedState : null);
|
|
796
|
+
stateChanged = stateSig !== prev.lastStateSerialized;
|
|
797
|
+
} catch (_) {
|
|
798
|
+
stateChanged = false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Context: use _debugHookTypes or check dependencies
|
|
803
|
+
var deps = fiber.dependencies;
|
|
804
|
+
if (deps && deps.firstContext) {
|
|
805
|
+
contextChanged = true; // conservative: context dep present = may have changed
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
var stateSig;
|
|
809
|
+
try {
|
|
810
|
+
stateSig = JSON.stringify(memoizedState ? memoizedState.memoizedState : null);
|
|
811
|
+
} catch (_) {
|
|
812
|
+
stateSig = null;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
renderedComponents.set(name, {
|
|
816
|
+
lastProps: currentProps,
|
|
817
|
+
lastStateSerialized: stateSig,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
var parentName = getParentComponentName(fiber);
|
|
821
|
+
|
|
822
|
+
window.__SCOPE_RENDER_EVENTS__.push({
|
|
823
|
+
component: name,
|
|
824
|
+
renderIndex: window.__SCOPE_RENDER_INDEX__++,
|
|
825
|
+
propsChanged: prev ? propsChanged : false,
|
|
826
|
+
stateChanged: stateChanged,
|
|
827
|
+
contextChanged: contextChanged,
|
|
828
|
+
memoized: memoized,
|
|
829
|
+
parentComponent: parentName,
|
|
830
|
+
hookDepsChanged: hookDepsChanged,
|
|
831
|
+
forceUpdate: forceUpdate,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
walkCommit(fiber.child);
|
|
837
|
+
walkCommit(fiber.sibling);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
hook.onCommitFiberRoot = function(rendererID, root, priorityLevel) {
|
|
841
|
+
if (typeof originalOnCommit === 'function') {
|
|
842
|
+
originalOnCommit.call(hook, rendererID, root, priorityLevel);
|
|
843
|
+
}
|
|
844
|
+
var wipRoot = root && root.current && root.current.alternate;
|
|
845
|
+
if (wipRoot) walkCommit(wipRoot);
|
|
846
|
+
};
|
|
847
|
+
})();
|
|
848
|
+
`
|
|
849
|
+
);
|
|
850
|
+
}
|
|
851
|
+
async function replayInteraction(page, steps) {
|
|
852
|
+
for (const step of steps) {
|
|
853
|
+
switch (step.action) {
|
|
854
|
+
case "click":
|
|
855
|
+
if (step.target !== void 0) {
|
|
856
|
+
await page.click(step.target, { timeout: 5e3 }).catch(() => {
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
break;
|
|
860
|
+
case "type":
|
|
861
|
+
if (step.target !== void 0 && step.text !== void 0) {
|
|
862
|
+
await page.fill(step.target, step.text, { timeout: 5e3 }).catch(async () => {
|
|
863
|
+
await page.type(step.target, step.text, { timeout: 5e3 }).catch(() => {
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
break;
|
|
868
|
+
case "hover":
|
|
869
|
+
if (step.target !== void 0) {
|
|
870
|
+
await page.hover(step.target, { timeout: 5e3 }).catch(() => {
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
break;
|
|
874
|
+
case "blur":
|
|
875
|
+
if (step.target !== void 0) {
|
|
876
|
+
await page.locator(step.target).blur({ timeout: 5e3 }).catch(() => {
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
break;
|
|
880
|
+
case "focus":
|
|
881
|
+
if (step.target !== void 0) {
|
|
882
|
+
await page.focus(step.target, { timeout: 5e3 }).catch(() => {
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
break;
|
|
886
|
+
case "scroll":
|
|
887
|
+
if (step.target !== void 0) {
|
|
888
|
+
await page.locator(step.target).scrollIntoViewIfNeeded({ timeout: 5e3 }).catch(() => {
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
break;
|
|
892
|
+
case "wait": {
|
|
893
|
+
const timeout = step.timeout ?? 1e3;
|
|
894
|
+
if (step.condition === "idle") {
|
|
895
|
+
await page.waitForLoadState("networkidle", { timeout }).catch(() => {
|
|
896
|
+
});
|
|
897
|
+
} else {
|
|
898
|
+
await page.waitForTimeout(timeout);
|
|
899
|
+
}
|
|
900
|
+
break;
|
|
901
|
+
}
|
|
902
|
+
default:
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
var _pool = null;
|
|
908
|
+
async function getPool() {
|
|
909
|
+
if (_pool === null) {
|
|
910
|
+
_pool = new BrowserPool({
|
|
911
|
+
size: { browsers: 1, pagesPerBrowser: 2 },
|
|
912
|
+
viewportWidth: 1280,
|
|
913
|
+
viewportHeight: 800
|
|
914
|
+
});
|
|
915
|
+
await _pool.init();
|
|
916
|
+
}
|
|
917
|
+
return _pool;
|
|
918
|
+
}
|
|
919
|
+
async function shutdownPool() {
|
|
920
|
+
if (_pool !== null) {
|
|
921
|
+
await _pool.close();
|
|
922
|
+
_pool = null;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
async function analyzeRenders(options) {
|
|
926
|
+
const manifestPath = options.manifestPath ?? MANIFEST_PATH2;
|
|
927
|
+
const manifest = loadManifest(manifestPath);
|
|
928
|
+
const descriptor = manifest.components[options.componentName];
|
|
929
|
+
if (descriptor === void 0) {
|
|
930
|
+
const available = Object.keys(manifest.components).slice(0, 5).join(", ");
|
|
931
|
+
throw new Error(
|
|
932
|
+
`Component "${options.componentName}" not found in manifest.
|
|
933
|
+
Available: ${available}`
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
const rootDir = process.cwd();
|
|
937
|
+
const filePath = resolve2(rootDir, descriptor.filePath);
|
|
938
|
+
const htmlHarness = await buildComponentHarness(filePath, options.componentName, {}, 1280);
|
|
939
|
+
const pool = await getPool();
|
|
940
|
+
const slot = await pool.acquire();
|
|
941
|
+
const { page } = slot;
|
|
942
|
+
const startMs = performance.now();
|
|
943
|
+
try {
|
|
944
|
+
await page.addInitScript(buildInstrumentationScript());
|
|
945
|
+
await page.setContent(htmlHarness, { waitUntil: "load" });
|
|
946
|
+
await page.waitForFunction(
|
|
947
|
+
() => window.__SCOPE_RENDER_COMPLETE__ === true,
|
|
948
|
+
{ timeout: 15e3 }
|
|
949
|
+
);
|
|
950
|
+
await page.waitForTimeout(100);
|
|
951
|
+
await page.evaluate(() => {
|
|
952
|
+
window.__SCOPE_RENDER_EVENTS__ = [];
|
|
953
|
+
window.__SCOPE_RENDER_INDEX__ = 0;
|
|
954
|
+
});
|
|
955
|
+
await replayInteraction(page, options.interaction);
|
|
956
|
+
await page.waitForTimeout(200);
|
|
957
|
+
const interactionDurationMs = performance.now() - startMs;
|
|
958
|
+
const rawEvents = await page.evaluate(() => {
|
|
959
|
+
return window.__SCOPE_RENDER_EVENTS__ ?? [];
|
|
960
|
+
});
|
|
961
|
+
const renders = buildCausalityChains(rawEvents);
|
|
962
|
+
const flags = applyHeuristicFlags(renders);
|
|
963
|
+
const uniqueComponents = new Set(renders.map((r) => r.component)).size;
|
|
964
|
+
const wastedRenders = renders.filter((r) => r.wasted).length;
|
|
965
|
+
return {
|
|
966
|
+
component: options.componentName,
|
|
967
|
+
interaction: options.interaction,
|
|
968
|
+
summary: {
|
|
969
|
+
totalRenders: renders.length,
|
|
970
|
+
uniqueComponents,
|
|
971
|
+
wastedRenders,
|
|
972
|
+
interactionDurationMs: Math.round(interactionDurationMs)
|
|
973
|
+
},
|
|
974
|
+
renders,
|
|
975
|
+
flags
|
|
976
|
+
};
|
|
977
|
+
} finally {
|
|
978
|
+
pool.release(slot);
|
|
979
|
+
}
|
|
577
980
|
}
|
|
578
|
-
function
|
|
579
|
-
const
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
lines.push(""
|
|
596
|
-
for (const r of
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
981
|
+
function formatRendersTable(result) {
|
|
982
|
+
const lines = [];
|
|
983
|
+
lines.push(`
|
|
984
|
+
\u{1F50D} Re-render Analysis: ${result.component}`);
|
|
985
|
+
lines.push(`${"\u2500".repeat(60)}`);
|
|
986
|
+
lines.push(`Total renders: ${result.summary.totalRenders}`);
|
|
987
|
+
lines.push(`Unique components: ${result.summary.uniqueComponents}`);
|
|
988
|
+
lines.push(`Wasted renders: ${result.summary.wastedRenders}`);
|
|
989
|
+
lines.push(`Duration: ${result.summary.interactionDurationMs}ms`);
|
|
990
|
+
lines.push("");
|
|
991
|
+
if (result.renders.length === 0) {
|
|
992
|
+
lines.push("No re-renders captured during interaction.");
|
|
993
|
+
} else {
|
|
994
|
+
lines.push("Re-renders:");
|
|
995
|
+
lines.push(
|
|
996
|
+
`${"#".padEnd(4)} ${"Component".padEnd(30)} ${"Trigger".padEnd(18)} ${"Wasted".padEnd(7)} ${"Chain Depth"}`
|
|
997
|
+
);
|
|
998
|
+
lines.push("\u2500".repeat(80));
|
|
999
|
+
for (const r of result.renders) {
|
|
1000
|
+
const wasted = r.wasted ? "\u26A0 yes" : "no";
|
|
1001
|
+
const idx = String(r.renderIndex).padEnd(4);
|
|
1002
|
+
const comp = r.component.slice(0, 29).padEnd(30);
|
|
1003
|
+
const trig = r.trigger.padEnd(18);
|
|
1004
|
+
const w = wasted.padEnd(7);
|
|
1005
|
+
const depth = r.chain.length;
|
|
1006
|
+
lines.push(`${idx} ${comp} ${trig} ${w} ${depth}`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
if (result.flags.length > 0) {
|
|
1010
|
+
lines.push("");
|
|
1011
|
+
lines.push("Flags:");
|
|
1012
|
+
for (const flag of result.flags) {
|
|
1013
|
+
const icon = flag.severity === "error" ? "\u2717" : flag.severity === "warning" ? "\u26A0" : "\u2139";
|
|
1014
|
+
lines.push(` ${icon} [${flag.id}] ${flag.component}: ${flag.detail}`);
|
|
600
1015
|
}
|
|
601
1016
|
}
|
|
602
|
-
lines.push("\u2500".repeat(60));
|
|
603
1017
|
return lines.join("\n");
|
|
604
1018
|
}
|
|
605
|
-
function
|
|
606
|
-
return
|
|
1019
|
+
function createInstrumentRendersCommand() {
|
|
1020
|
+
return new Command2("renders").description("Trace re-render causality chains for a component during an interaction sequence").argument("<component>", "Component name to instrument (must be in manifest)").option(
|
|
1021
|
+
"--interaction <json>",
|
|
1022
|
+
`Interaction sequence JSON, e.g. '[{"action":"click","target":"button"}]'`,
|
|
1023
|
+
"[]"
|
|
1024
|
+
).option("--json", "Output as JSON regardless of TTY", false).option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH2).action(
|
|
1025
|
+
async (componentName, opts) => {
|
|
1026
|
+
let interaction = [];
|
|
1027
|
+
try {
|
|
1028
|
+
interaction = JSON.parse(opts.interaction);
|
|
1029
|
+
if (!Array.isArray(interaction)) {
|
|
1030
|
+
throw new Error("Interaction must be a JSON array");
|
|
1031
|
+
}
|
|
1032
|
+
} catch {
|
|
1033
|
+
process.stderr.write(`Error: Invalid --interaction JSON: ${opts.interaction}
|
|
1034
|
+
`);
|
|
1035
|
+
process.exit(1);
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
process.stderr.write(
|
|
1039
|
+
`Instrumenting ${componentName} (${interaction.length} interaction steps)\u2026
|
|
1040
|
+
`
|
|
1041
|
+
);
|
|
1042
|
+
const result = await analyzeRenders({
|
|
1043
|
+
componentName,
|
|
1044
|
+
interaction,
|
|
1045
|
+
manifestPath: opts.manifest
|
|
1046
|
+
});
|
|
1047
|
+
await shutdownPool();
|
|
1048
|
+
if (opts.json || !isTTY()) {
|
|
1049
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
1050
|
+
`);
|
|
1051
|
+
} else {
|
|
1052
|
+
process.stdout.write(`${formatRendersTable(result)}
|
|
1053
|
+
`);
|
|
1054
|
+
}
|
|
1055
|
+
} catch (err) {
|
|
1056
|
+
await shutdownPool();
|
|
1057
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1058
|
+
`);
|
|
1059
|
+
process.exit(1);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
);
|
|
607
1063
|
}
|
|
608
|
-
function
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
1064
|
+
function createInstrumentCommand() {
|
|
1065
|
+
const instrumentCmd = new Command2("instrument").description(
|
|
1066
|
+
"Structured instrumentation commands for React component analysis"
|
|
1067
|
+
);
|
|
1068
|
+
instrumentCmd.addCommand(createInstrumentRendersCommand());
|
|
1069
|
+
return instrumentCmd;
|
|
613
1070
|
}
|
|
614
1071
|
|
|
1072
|
+
// src/render-commands.ts
|
|
1073
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
1074
|
+
import { resolve as resolve4 } from "path";
|
|
1075
|
+
import {
|
|
1076
|
+
ALL_CONTEXT_IDS,
|
|
1077
|
+
ALL_STRESS_IDS,
|
|
1078
|
+
BrowserPool as BrowserPool2,
|
|
1079
|
+
contextAxis,
|
|
1080
|
+
RenderMatrix,
|
|
1081
|
+
SatoriRenderer,
|
|
1082
|
+
safeRender,
|
|
1083
|
+
stressAxis
|
|
1084
|
+
} from "@agent-scope/render";
|
|
1085
|
+
import { Command as Command3 } from "commander";
|
|
1086
|
+
|
|
615
1087
|
// src/tailwind-css.ts
|
|
616
1088
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
617
1089
|
import { createRequire } from "module";
|
|
618
|
-
import { resolve as
|
|
1090
|
+
import { resolve as resolve3 } from "path";
|
|
619
1091
|
var CONFIG_FILENAMES = [
|
|
620
1092
|
".reactscope/config.json",
|
|
621
1093
|
".reactscope/config.js",
|
|
@@ -632,14 +1104,14 @@ var STYLE_ENTRY_CANDIDATES = [
|
|
|
632
1104
|
var TAILWIND_IMPORT = /@import\s+["']tailwindcss["']\s*;?/;
|
|
633
1105
|
var compilerCache = null;
|
|
634
1106
|
function getCachedBuild(cwd) {
|
|
635
|
-
if (compilerCache !== null &&
|
|
1107
|
+
if (compilerCache !== null && resolve3(compilerCache.cwd) === resolve3(cwd)) {
|
|
636
1108
|
return compilerCache.build;
|
|
637
1109
|
}
|
|
638
1110
|
return null;
|
|
639
1111
|
}
|
|
640
1112
|
function findStylesEntry(cwd) {
|
|
641
1113
|
for (const name of CONFIG_FILENAMES) {
|
|
642
|
-
const p =
|
|
1114
|
+
const p = resolve3(cwd, name);
|
|
643
1115
|
if (!existsSync2(p)) continue;
|
|
644
1116
|
try {
|
|
645
1117
|
if (name.endsWith(".json")) {
|
|
@@ -648,28 +1120,28 @@ function findStylesEntry(cwd) {
|
|
|
648
1120
|
const scope = data.scope;
|
|
649
1121
|
const entry = scope?.stylesEntry ?? data.stylesEntry;
|
|
650
1122
|
if (typeof entry === "string") {
|
|
651
|
-
const full =
|
|
1123
|
+
const full = resolve3(cwd, entry);
|
|
652
1124
|
if (existsSync2(full)) return full;
|
|
653
1125
|
}
|
|
654
1126
|
}
|
|
655
1127
|
} catch {
|
|
656
1128
|
}
|
|
657
1129
|
}
|
|
658
|
-
const pkgPath =
|
|
1130
|
+
const pkgPath = resolve3(cwd, "package.json");
|
|
659
1131
|
if (existsSync2(pkgPath)) {
|
|
660
1132
|
try {
|
|
661
1133
|
const raw = readFileSync2(pkgPath, "utf-8");
|
|
662
1134
|
const pkg = JSON.parse(raw);
|
|
663
1135
|
const entry = pkg.scope?.stylesEntry;
|
|
664
1136
|
if (typeof entry === "string") {
|
|
665
|
-
const full =
|
|
1137
|
+
const full = resolve3(cwd, entry);
|
|
666
1138
|
if (existsSync2(full)) return full;
|
|
667
1139
|
}
|
|
668
1140
|
} catch {
|
|
669
1141
|
}
|
|
670
1142
|
}
|
|
671
1143
|
for (const candidate of STYLE_ENTRY_CANDIDATES) {
|
|
672
|
-
const full =
|
|
1144
|
+
const full = resolve3(cwd, candidate);
|
|
673
1145
|
if (existsSync2(full)) {
|
|
674
1146
|
try {
|
|
675
1147
|
const content = readFileSync2(full, "utf-8");
|
|
@@ -687,7 +1159,7 @@ async function getTailwindCompiler(cwd) {
|
|
|
687
1159
|
if (entryPath === null) return null;
|
|
688
1160
|
let compile;
|
|
689
1161
|
try {
|
|
690
|
-
const require2 = createRequire(
|
|
1162
|
+
const require2 = createRequire(resolve3(cwd, "package.json"));
|
|
691
1163
|
const tailwind = require2("tailwindcss");
|
|
692
1164
|
const fn = tailwind.compile;
|
|
693
1165
|
if (typeof fn !== "function") return null;
|
|
@@ -698,8 +1170,8 @@ async function getTailwindCompiler(cwd) {
|
|
|
698
1170
|
const entryContent = readFileSync2(entryPath, "utf-8");
|
|
699
1171
|
const loadStylesheet = async (id, base) => {
|
|
700
1172
|
if (id === "tailwindcss") {
|
|
701
|
-
const nodeModules =
|
|
702
|
-
const tailwindCssPath =
|
|
1173
|
+
const nodeModules = resolve3(cwd, "node_modules");
|
|
1174
|
+
const tailwindCssPath = resolve3(nodeModules, "tailwindcss", "index.css");
|
|
703
1175
|
if (!existsSync2(tailwindCssPath)) {
|
|
704
1176
|
throw new Error(
|
|
705
1177
|
`Tailwind v4: tailwindcss package not found at ${tailwindCssPath}. Install with: npm install tailwindcss`
|
|
@@ -708,10 +1180,10 @@ async function getTailwindCompiler(cwd) {
|
|
|
708
1180
|
const content = readFileSync2(tailwindCssPath, "utf-8");
|
|
709
1181
|
return { path: "virtual:tailwindcss/index.css", base, content };
|
|
710
1182
|
}
|
|
711
|
-
const full =
|
|
1183
|
+
const full = resolve3(base, id);
|
|
712
1184
|
if (existsSync2(full)) {
|
|
713
1185
|
const content = readFileSync2(full, "utf-8");
|
|
714
|
-
return { path: full, base:
|
|
1186
|
+
return { path: full, base: resolve3(full, ".."), content };
|
|
715
1187
|
}
|
|
716
1188
|
throw new Error(`Tailwind v4: could not load stylesheet: ${id} (base: ${base})`);
|
|
717
1189
|
};
|
|
@@ -733,24 +1205,24 @@ async function getCompiledCssForClasses(cwd, classes) {
|
|
|
733
1205
|
}
|
|
734
1206
|
|
|
735
1207
|
// src/render-commands.ts
|
|
736
|
-
var
|
|
1208
|
+
var MANIFEST_PATH3 = ".reactscope/manifest.json";
|
|
737
1209
|
var DEFAULT_OUTPUT_DIR = ".reactscope/renders";
|
|
738
|
-
var
|
|
739
|
-
async function
|
|
740
|
-
if (
|
|
741
|
-
|
|
1210
|
+
var _pool2 = null;
|
|
1211
|
+
async function getPool2(viewportWidth, viewportHeight) {
|
|
1212
|
+
if (_pool2 === null) {
|
|
1213
|
+
_pool2 = new BrowserPool2({
|
|
742
1214
|
size: { browsers: 1, pagesPerBrowser: 4 },
|
|
743
1215
|
viewportWidth,
|
|
744
1216
|
viewportHeight
|
|
745
1217
|
});
|
|
746
|
-
await
|
|
1218
|
+
await _pool2.init();
|
|
747
1219
|
}
|
|
748
|
-
return
|
|
1220
|
+
return _pool2;
|
|
749
1221
|
}
|
|
750
|
-
async function
|
|
751
|
-
if (
|
|
752
|
-
await
|
|
753
|
-
|
|
1222
|
+
async function shutdownPool2() {
|
|
1223
|
+
if (_pool2 !== null) {
|
|
1224
|
+
await _pool2.close();
|
|
1225
|
+
_pool2 = null;
|
|
754
1226
|
}
|
|
755
1227
|
}
|
|
756
1228
|
function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
|
|
@@ -761,7 +1233,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
|
|
|
761
1233
|
_satori: satori,
|
|
762
1234
|
async renderCell(props, _complexityClass) {
|
|
763
1235
|
const startMs = performance.now();
|
|
764
|
-
const pool = await
|
|
1236
|
+
const pool = await getPool2(viewportWidth, viewportHeight);
|
|
765
1237
|
const htmlHarness = await buildComponentHarness(
|
|
766
1238
|
filePath,
|
|
767
1239
|
componentName,
|
|
@@ -858,7 +1330,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
|
|
|
858
1330
|
};
|
|
859
1331
|
}
|
|
860
1332
|
function registerRenderSingle(renderCmd) {
|
|
861
|
-
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",
|
|
1333
|
+
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_PATH3).action(
|
|
862
1334
|
async (componentName, opts) => {
|
|
863
1335
|
try {
|
|
864
1336
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -880,7 +1352,7 @@ Available: ${available}`
|
|
|
880
1352
|
}
|
|
881
1353
|
const { width, height } = parseViewport(opts.viewport);
|
|
882
1354
|
const rootDir = process.cwd();
|
|
883
|
-
const filePath =
|
|
1355
|
+
const filePath = resolve4(rootDir, descriptor.filePath);
|
|
884
1356
|
const renderer = buildRenderer(filePath, componentName, width, height);
|
|
885
1357
|
process.stderr.write(
|
|
886
1358
|
`Rendering ${componentName} [${descriptor.complexityClass}] at ${width}\xD7${height}\u2026
|
|
@@ -897,7 +1369,7 @@ Available: ${available}`
|
|
|
897
1369
|
}
|
|
898
1370
|
}
|
|
899
1371
|
);
|
|
900
|
-
await
|
|
1372
|
+
await shutdownPool2();
|
|
901
1373
|
if (outcome.crashed) {
|
|
902
1374
|
process.stderr.write(`\u2717 Render failed: ${outcome.error.message}
|
|
903
1375
|
`);
|
|
@@ -910,7 +1382,7 @@ Available: ${available}`
|
|
|
910
1382
|
}
|
|
911
1383
|
const result = outcome.result;
|
|
912
1384
|
if (opts.output !== void 0) {
|
|
913
|
-
const outPath =
|
|
1385
|
+
const outPath = resolve4(process.cwd(), opts.output);
|
|
914
1386
|
writeFileSync3(outPath, result.screenshot);
|
|
915
1387
|
process.stdout.write(
|
|
916
1388
|
`\u2713 ${componentName} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
@@ -924,9 +1396,9 @@ Available: ${available}`
|
|
|
924
1396
|
process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
925
1397
|
`);
|
|
926
1398
|
} else if (fmt === "file") {
|
|
927
|
-
const dir =
|
|
1399
|
+
const dir = resolve4(process.cwd(), DEFAULT_OUTPUT_DIR);
|
|
928
1400
|
mkdirSync2(dir, { recursive: true });
|
|
929
|
-
const outPath =
|
|
1401
|
+
const outPath = resolve4(dir, `${componentName}.png`);
|
|
930
1402
|
writeFileSync3(outPath, result.screenshot);
|
|
931
1403
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${componentName}.png`;
|
|
932
1404
|
process.stdout.write(
|
|
@@ -934,9 +1406,9 @@ Available: ${available}`
|
|
|
934
1406
|
`
|
|
935
1407
|
);
|
|
936
1408
|
} else {
|
|
937
|
-
const dir =
|
|
1409
|
+
const dir = resolve4(process.cwd(), DEFAULT_OUTPUT_DIR);
|
|
938
1410
|
mkdirSync2(dir, { recursive: true });
|
|
939
|
-
const outPath =
|
|
1411
|
+
const outPath = resolve4(dir, `${componentName}.png`);
|
|
940
1412
|
writeFileSync3(outPath, result.screenshot);
|
|
941
1413
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${componentName}.png`;
|
|
942
1414
|
process.stdout.write(
|
|
@@ -945,7 +1417,7 @@ Available: ${available}`
|
|
|
945
1417
|
);
|
|
946
1418
|
}
|
|
947
1419
|
} catch (err) {
|
|
948
|
-
await
|
|
1420
|
+
await shutdownPool2();
|
|
949
1421
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
950
1422
|
`);
|
|
951
1423
|
process.exit(1);
|
|
@@ -957,7 +1429,7 @@ function registerRenderMatrix(renderCmd) {
|
|
|
957
1429
|
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(
|
|
958
1430
|
"--contexts <ids>",
|
|
959
1431
|
"Composition context IDs, comma-separated (e.g. centered,rtl,sidebar)"
|
|
960
|
-
).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",
|
|
1432
|
+
).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_PATH3).action(
|
|
961
1433
|
async (componentName, opts) => {
|
|
962
1434
|
try {
|
|
963
1435
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -972,7 +1444,7 @@ Available: ${available}`
|
|
|
972
1444
|
const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 8);
|
|
973
1445
|
const { width, height } = { width: 375, height: 812 };
|
|
974
1446
|
const rootDir = process.cwd();
|
|
975
|
-
const filePath =
|
|
1447
|
+
const filePath = resolve4(rootDir, descriptor.filePath);
|
|
976
1448
|
const renderer = buildRenderer(filePath, componentName, width, height);
|
|
977
1449
|
const axes = [];
|
|
978
1450
|
if (opts.axes !== void 0) {
|
|
@@ -1030,7 +1502,7 @@ Available: ${available}`
|
|
|
1030
1502
|
concurrency
|
|
1031
1503
|
});
|
|
1032
1504
|
const result = await matrix.render();
|
|
1033
|
-
await
|
|
1505
|
+
await shutdownPool2();
|
|
1034
1506
|
process.stderr.write(
|
|
1035
1507
|
`Done. ${result.stats.totalCells} cells, avg ${result.stats.avgRenderTimeMs.toFixed(1)}ms
|
|
1036
1508
|
`
|
|
@@ -1039,7 +1511,7 @@ Available: ${available}`
|
|
|
1039
1511
|
const { SpriteSheetGenerator } = await import("@agent-scope/render");
|
|
1040
1512
|
const gen = new SpriteSheetGenerator();
|
|
1041
1513
|
const sheet = await gen.generate(result);
|
|
1042
|
-
const spritePath =
|
|
1514
|
+
const spritePath = resolve4(process.cwd(), opts.sprite);
|
|
1043
1515
|
writeFileSync3(spritePath, sheet.png);
|
|
1044
1516
|
process.stderr.write(`Sprite sheet saved to ${spritePath}
|
|
1045
1517
|
`);
|
|
@@ -1049,9 +1521,9 @@ Available: ${available}`
|
|
|
1049
1521
|
const { SpriteSheetGenerator } = await import("@agent-scope/render");
|
|
1050
1522
|
const gen = new SpriteSheetGenerator();
|
|
1051
1523
|
const sheet = await gen.generate(result);
|
|
1052
|
-
const dir =
|
|
1524
|
+
const dir = resolve4(process.cwd(), DEFAULT_OUTPUT_DIR);
|
|
1053
1525
|
mkdirSync2(dir, { recursive: true });
|
|
1054
|
-
const outPath =
|
|
1526
|
+
const outPath = resolve4(dir, `${componentName}-matrix.png`);
|
|
1055
1527
|
writeFileSync3(outPath, sheet.png);
|
|
1056
1528
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${componentName}-matrix.png`;
|
|
1057
1529
|
process.stdout.write(
|
|
@@ -1075,7 +1547,7 @@ Available: ${available}`
|
|
|
1075
1547
|
process.stdout.write(formatMatrixCsv(componentName, result));
|
|
1076
1548
|
}
|
|
1077
1549
|
} catch (err) {
|
|
1078
|
-
await
|
|
1550
|
+
await shutdownPool2();
|
|
1079
1551
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1080
1552
|
`);
|
|
1081
1553
|
process.exit(1);
|
|
@@ -1084,7 +1556,7 @@ Available: ${available}`
|
|
|
1084
1556
|
);
|
|
1085
1557
|
}
|
|
1086
1558
|
function registerRenderAll(renderCmd) {
|
|
1087
|
-
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",
|
|
1559
|
+
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_PATH3).option("--format <fmt>", "Output format: json|png (default: png)", "png").action(
|
|
1088
1560
|
async (opts) => {
|
|
1089
1561
|
try {
|
|
1090
1562
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -1095,7 +1567,7 @@ function registerRenderAll(renderCmd) {
|
|
|
1095
1567
|
return;
|
|
1096
1568
|
}
|
|
1097
1569
|
const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 4);
|
|
1098
|
-
const outputDir =
|
|
1570
|
+
const outputDir = resolve4(process.cwd(), opts.outputDir);
|
|
1099
1571
|
mkdirSync2(outputDir, { recursive: true });
|
|
1100
1572
|
const rootDir = process.cwd();
|
|
1101
1573
|
process.stderr.write(`Rendering ${total} components (concurrency: ${concurrency})\u2026
|
|
@@ -1105,7 +1577,7 @@ function registerRenderAll(renderCmd) {
|
|
|
1105
1577
|
const renderOne = async (name) => {
|
|
1106
1578
|
const descriptor = manifest.components[name];
|
|
1107
1579
|
if (descriptor === void 0) return;
|
|
1108
|
-
const filePath =
|
|
1580
|
+
const filePath = resolve4(rootDir, descriptor.filePath);
|
|
1109
1581
|
const renderer = buildRenderer(filePath, name, 375, 812);
|
|
1110
1582
|
const outcome = await safeRender(
|
|
1111
1583
|
() => renderer.renderCell({}, descriptor.complexityClass),
|
|
@@ -1128,7 +1600,7 @@ function registerRenderAll(renderCmd) {
|
|
|
1128
1600
|
success: false,
|
|
1129
1601
|
errorMessage: outcome.error.message
|
|
1130
1602
|
});
|
|
1131
|
-
const errPath =
|
|
1603
|
+
const errPath = resolve4(outputDir, `${name}.error.json`);
|
|
1132
1604
|
writeFileSync3(
|
|
1133
1605
|
errPath,
|
|
1134
1606
|
JSON.stringify(
|
|
@@ -1146,9 +1618,9 @@ function registerRenderAll(renderCmd) {
|
|
|
1146
1618
|
}
|
|
1147
1619
|
const result = outcome.result;
|
|
1148
1620
|
results.push({ name, renderTimeMs: result.renderTimeMs, success: true });
|
|
1149
|
-
const pngPath =
|
|
1621
|
+
const pngPath = resolve4(outputDir, `${name}.png`);
|
|
1150
1622
|
writeFileSync3(pngPath, result.screenshot);
|
|
1151
|
-
const jsonPath =
|
|
1623
|
+
const jsonPath = resolve4(outputDir, `${name}.json`);
|
|
1152
1624
|
writeFileSync3(jsonPath, JSON.stringify(formatRenderJson(name, {}, result), null, 2));
|
|
1153
1625
|
if (isTTY()) {
|
|
1154
1626
|
process.stdout.write(
|
|
@@ -1172,13 +1644,13 @@ function registerRenderAll(renderCmd) {
|
|
|
1172
1644
|
workers.push(worker());
|
|
1173
1645
|
}
|
|
1174
1646
|
await Promise.all(workers);
|
|
1175
|
-
await
|
|
1647
|
+
await shutdownPool2();
|
|
1176
1648
|
process.stderr.write("\n");
|
|
1177
1649
|
const summary = formatSummaryText(results, outputDir);
|
|
1178
1650
|
process.stderr.write(`${summary}
|
|
1179
1651
|
`);
|
|
1180
1652
|
} catch (err) {
|
|
1181
|
-
await
|
|
1653
|
+
await shutdownPool2();
|
|
1182
1654
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1183
1655
|
`);
|
|
1184
1656
|
process.exit(1);
|
|
@@ -1211,7 +1683,7 @@ function resolveMatrixFormat(formatFlag, spriteAlreadyWritten) {
|
|
|
1211
1683
|
return "json";
|
|
1212
1684
|
}
|
|
1213
1685
|
function createRenderCommand() {
|
|
1214
|
-
const renderCmd = new
|
|
1686
|
+
const renderCmd = new Command3("render").description(
|
|
1215
1687
|
"Render components to PNG or JSON via esbuild + BrowserPool"
|
|
1216
1688
|
);
|
|
1217
1689
|
registerRenderSingle(renderCmd);
|
|
@@ -1499,9 +1971,363 @@ function buildStructuredReport(report) {
|
|
|
1499
1971
|
};
|
|
1500
1972
|
}
|
|
1501
1973
|
|
|
1974
|
+
// src/tokens/commands.ts
|
|
1975
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
1976
|
+
import { resolve as resolve5 } from "path";
|
|
1977
|
+
import {
|
|
1978
|
+
parseTokenFileSync,
|
|
1979
|
+
TokenParseError,
|
|
1980
|
+
TokenResolver,
|
|
1981
|
+
TokenValidationError,
|
|
1982
|
+
validateTokenFile
|
|
1983
|
+
} from "@agent-scope/tokens";
|
|
1984
|
+
import { Command as Command4 } from "commander";
|
|
1985
|
+
var DEFAULT_TOKEN_FILE = "reactscope.tokens.json";
|
|
1986
|
+
var CONFIG_FILE = "reactscope.config.json";
|
|
1987
|
+
function isTTY2() {
|
|
1988
|
+
return process.stdout.isTTY === true;
|
|
1989
|
+
}
|
|
1990
|
+
function pad3(value, width) {
|
|
1991
|
+
return value.length >= width ? value.slice(0, width) : value + " ".repeat(width - value.length);
|
|
1992
|
+
}
|
|
1993
|
+
function buildTable2(headers, rows) {
|
|
1994
|
+
const colWidths = headers.map(
|
|
1995
|
+
(h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
|
|
1996
|
+
);
|
|
1997
|
+
const divider = colWidths.map((w) => "-".repeat(w)).join(" ");
|
|
1998
|
+
const headerRow = headers.map((h, i) => pad3(h, colWidths[i] ?? 0)).join(" ");
|
|
1999
|
+
const dataRows = rows.map(
|
|
2000
|
+
(row2) => row2.map((cell, i) => pad3(cell ?? "", colWidths[i] ?? 0)).join(" ")
|
|
2001
|
+
);
|
|
2002
|
+
return [headerRow, divider, ...dataRows].join("\n");
|
|
2003
|
+
}
|
|
2004
|
+
function resolveTokenFilePath(fileFlag) {
|
|
2005
|
+
if (fileFlag !== void 0) {
|
|
2006
|
+
return resolve5(process.cwd(), fileFlag);
|
|
2007
|
+
}
|
|
2008
|
+
const configPath = resolve5(process.cwd(), CONFIG_FILE);
|
|
2009
|
+
if (existsSync3(configPath)) {
|
|
2010
|
+
try {
|
|
2011
|
+
const raw = readFileSync3(configPath, "utf-8");
|
|
2012
|
+
const config = JSON.parse(raw);
|
|
2013
|
+
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
2014
|
+
const file = config.tokens.file;
|
|
2015
|
+
return resolve5(process.cwd(), file);
|
|
2016
|
+
}
|
|
2017
|
+
} catch {
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
return resolve5(process.cwd(), DEFAULT_TOKEN_FILE);
|
|
2021
|
+
}
|
|
2022
|
+
function loadTokens(absPath) {
|
|
2023
|
+
if (!existsSync3(absPath)) {
|
|
2024
|
+
throw new Error(
|
|
2025
|
+
`Token file not found at ${absPath}.
|
|
2026
|
+
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
const raw = readFileSync3(absPath, "utf-8");
|
|
2030
|
+
return parseTokenFileSync(raw);
|
|
2031
|
+
}
|
|
2032
|
+
function getRawValue(node, segments) {
|
|
2033
|
+
const [head, ...rest] = segments;
|
|
2034
|
+
if (head === void 0) return null;
|
|
2035
|
+
const child = node[head];
|
|
2036
|
+
if (child === void 0 || child === null) return null;
|
|
2037
|
+
if (rest.length === 0) {
|
|
2038
|
+
if (typeof child === "object" && !Array.isArray(child) && "value" in child) {
|
|
2039
|
+
const v = child.value;
|
|
2040
|
+
return typeof v === "string" || typeof v === "number" ? v : null;
|
|
2041
|
+
}
|
|
2042
|
+
return null;
|
|
2043
|
+
}
|
|
2044
|
+
if (typeof child === "object" && !Array.isArray(child)) {
|
|
2045
|
+
return getRawValue(child, rest);
|
|
2046
|
+
}
|
|
2047
|
+
return null;
|
|
2048
|
+
}
|
|
2049
|
+
function buildResolutionChain(startPath, rawTokens) {
|
|
2050
|
+
const chain = [];
|
|
2051
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2052
|
+
let current = startPath;
|
|
2053
|
+
while (!seen.has(current)) {
|
|
2054
|
+
seen.add(current);
|
|
2055
|
+
const rawValue = getRawValue(rawTokens, current.split("."));
|
|
2056
|
+
if (rawValue === null) break;
|
|
2057
|
+
chain.push({ path: current, rawValue: String(rawValue) });
|
|
2058
|
+
const refMatch = /^\{([^}]+)\}$/.exec(String(rawValue));
|
|
2059
|
+
if (refMatch === null) break;
|
|
2060
|
+
current = refMatch[1] ?? "";
|
|
2061
|
+
}
|
|
2062
|
+
return chain;
|
|
2063
|
+
}
|
|
2064
|
+
function registerGet2(tokensCmd) {
|
|
2065
|
+
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) => {
|
|
2066
|
+
try {
|
|
2067
|
+
const filePath = resolveTokenFilePath(opts.file);
|
|
2068
|
+
const { tokens } = loadTokens(filePath);
|
|
2069
|
+
const resolver = new TokenResolver(tokens);
|
|
2070
|
+
const resolvedValue = resolver.resolve(tokenPath);
|
|
2071
|
+
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY2();
|
|
2072
|
+
if (useJson) {
|
|
2073
|
+
const token = tokens.find((t) => t.path === tokenPath);
|
|
2074
|
+
process.stdout.write(
|
|
2075
|
+
`${JSON.stringify({ path: tokenPath, value: token?.value, resolvedValue, type: token?.type }, null, 2)}
|
|
2076
|
+
`
|
|
2077
|
+
);
|
|
2078
|
+
} else {
|
|
2079
|
+
process.stdout.write(`${resolvedValue}
|
|
2080
|
+
`);
|
|
2081
|
+
}
|
|
2082
|
+
} catch (err) {
|
|
2083
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
2084
|
+
`);
|
|
2085
|
+
process.exit(1);
|
|
2086
|
+
}
|
|
2087
|
+
});
|
|
2088
|
+
}
|
|
2089
|
+
function registerList2(tokensCmd) {
|
|
2090
|
+
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(
|
|
2091
|
+
(category, opts) => {
|
|
2092
|
+
try {
|
|
2093
|
+
const filePath = resolveTokenFilePath(opts.file);
|
|
2094
|
+
const { tokens } = loadTokens(filePath);
|
|
2095
|
+
const resolver = new TokenResolver(tokens);
|
|
2096
|
+
const filtered = resolver.list(opts.type, category);
|
|
2097
|
+
const useJson = opts.format === "json" || opts.format !== "table" && !isTTY2();
|
|
2098
|
+
if (useJson) {
|
|
2099
|
+
process.stdout.write(`${JSON.stringify(filtered, null, 2)}
|
|
2100
|
+
`);
|
|
2101
|
+
} else {
|
|
2102
|
+
if (filtered.length === 0) {
|
|
2103
|
+
process.stdout.write("No tokens found.\n");
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
const headers = ["PATH", "VALUE", "RESOLVED", "TYPE"];
|
|
2107
|
+
const rows = filtered.map((t) => [t.path, String(t.value), t.resolvedValue, t.type]);
|
|
2108
|
+
process.stdout.write(`${buildTable2(headers, rows)}
|
|
2109
|
+
`);
|
|
2110
|
+
}
|
|
2111
|
+
} catch (err) {
|
|
2112
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
2113
|
+
`);
|
|
2114
|
+
process.exit(1);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
function registerSearch(tokensCmd) {
|
|
2120
|
+
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(
|
|
2121
|
+
(value, opts) => {
|
|
2122
|
+
try {
|
|
2123
|
+
const filePath = resolveTokenFilePath(opts.file);
|
|
2124
|
+
const { tokens } = loadTokens(filePath);
|
|
2125
|
+
const resolver = new TokenResolver(tokens);
|
|
2126
|
+
const useJson = opts.format === "json" || opts.format !== "table" && !isTTY2();
|
|
2127
|
+
const typesToSearch = opts.type ? [opts.type] : [
|
|
2128
|
+
"color",
|
|
2129
|
+
"dimension",
|
|
2130
|
+
"fontFamily",
|
|
2131
|
+
"fontWeight",
|
|
2132
|
+
"number",
|
|
2133
|
+
"shadow",
|
|
2134
|
+
"duration",
|
|
2135
|
+
"cubicBezier"
|
|
2136
|
+
];
|
|
2137
|
+
const exactMatches = [];
|
|
2138
|
+
const nearestMatches = [];
|
|
2139
|
+
for (const type of typesToSearch) {
|
|
2140
|
+
const exact = resolver.match(value, type);
|
|
2141
|
+
if (exact !== null) {
|
|
2142
|
+
exactMatches.push({
|
|
2143
|
+
path: exact.token.path,
|
|
2144
|
+
resolvedValue: exact.token.resolvedValue,
|
|
2145
|
+
type: exact.token.type,
|
|
2146
|
+
exact: true,
|
|
2147
|
+
distance: 0
|
|
2148
|
+
});
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
if (exactMatches.length === 0 && opts.fuzzy) {
|
|
2152
|
+
for (const type of typesToSearch) {
|
|
2153
|
+
const typeTokens = tokens.filter((t) => t.type === type);
|
|
2154
|
+
if (typeTokens.length === 0) continue;
|
|
2155
|
+
try {
|
|
2156
|
+
const near = resolver.nearest(value, type);
|
|
2157
|
+
nearestMatches.push({
|
|
2158
|
+
path: near.token.path,
|
|
2159
|
+
resolvedValue: near.token.resolvedValue,
|
|
2160
|
+
type: near.token.type,
|
|
2161
|
+
exact: near.exact,
|
|
2162
|
+
distance: near.distance
|
|
2163
|
+
});
|
|
2164
|
+
} catch {
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
nearestMatches.sort((a, b) => a.distance - b.distance);
|
|
2168
|
+
nearestMatches.splice(3);
|
|
2169
|
+
}
|
|
2170
|
+
const results = exactMatches.length > 0 ? exactMatches : nearestMatches;
|
|
2171
|
+
if (useJson) {
|
|
2172
|
+
process.stdout.write(`${JSON.stringify(results, null, 2)}
|
|
2173
|
+
`);
|
|
2174
|
+
} else {
|
|
2175
|
+
if (results.length === 0) {
|
|
2176
|
+
process.stdout.write(
|
|
2177
|
+
`No tokens found matching "${value}".
|
|
2178
|
+
Tip: use --fuzzy for nearest-match search.
|
|
2179
|
+
`
|
|
2180
|
+
);
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
const headers = ["PATH", "RESOLVED VALUE", "TYPE", "MATCH", "DISTANCE"];
|
|
2184
|
+
const rows = results.map((r) => [
|
|
2185
|
+
r.path,
|
|
2186
|
+
r.resolvedValue,
|
|
2187
|
+
r.type,
|
|
2188
|
+
r.exact ? "exact" : "nearest",
|
|
2189
|
+
r.exact ? "\u2014" : r.distance.toFixed(2)
|
|
2190
|
+
]);
|
|
2191
|
+
process.stdout.write(`${buildTable2(headers, rows)}
|
|
2192
|
+
`);
|
|
2193
|
+
}
|
|
2194
|
+
} catch (err) {
|
|
2195
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
2196
|
+
`);
|
|
2197
|
+
process.exit(1);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
function registerResolve(tokensCmd) {
|
|
2203
|
+
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) => {
|
|
2204
|
+
try {
|
|
2205
|
+
const filePath = resolveTokenFilePath(opts.file);
|
|
2206
|
+
const absFilePath = filePath;
|
|
2207
|
+
const { tokens, rawFile } = loadTokens(absFilePath);
|
|
2208
|
+
const resolver = new TokenResolver(tokens);
|
|
2209
|
+
resolver.resolve(tokenPath);
|
|
2210
|
+
const chain = buildResolutionChain(tokenPath, rawFile.tokens);
|
|
2211
|
+
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY2();
|
|
2212
|
+
if (useJson) {
|
|
2213
|
+
process.stdout.write(`${JSON.stringify({ path: tokenPath, chain }, null, 2)}
|
|
2214
|
+
`);
|
|
2215
|
+
} else {
|
|
2216
|
+
if (chain.length === 0) {
|
|
2217
|
+
process.stdout.write(`Token "${tokenPath}" not found.
|
|
2218
|
+
`);
|
|
2219
|
+
return;
|
|
2220
|
+
}
|
|
2221
|
+
const parts = chain.map((step, i) => {
|
|
2222
|
+
if (i < chain.length - 1) {
|
|
2223
|
+
return `${step.path} \u2192 ${step.rawValue}`;
|
|
2224
|
+
}
|
|
2225
|
+
return step.rawValue;
|
|
2226
|
+
});
|
|
2227
|
+
process.stdout.write(`${parts.join("\n ")}
|
|
2228
|
+
`);
|
|
2229
|
+
}
|
|
2230
|
+
} catch (err) {
|
|
2231
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
2232
|
+
`);
|
|
2233
|
+
process.exit(1);
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
function registerValidate(tokensCmd) {
|
|
2238
|
+
tokensCmd.command("validate").description(
|
|
2239
|
+
"Validate the token file for errors (circular refs, missing refs, type mismatches)"
|
|
2240
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((opts) => {
|
|
2241
|
+
try {
|
|
2242
|
+
const filePath = resolveTokenFilePath(opts.file);
|
|
2243
|
+
if (!existsSync3(filePath)) {
|
|
2244
|
+
throw new Error(
|
|
2245
|
+
`Token file not found at ${filePath}.
|
|
2246
|
+
Create a reactscope.tokens.json file or use --file to specify a path.`
|
|
2247
|
+
);
|
|
2248
|
+
}
|
|
2249
|
+
const raw = readFileSync3(filePath, "utf-8");
|
|
2250
|
+
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY2();
|
|
2251
|
+
const errors = [];
|
|
2252
|
+
let parsed;
|
|
2253
|
+
try {
|
|
2254
|
+
parsed = JSON.parse(raw);
|
|
2255
|
+
} catch (err) {
|
|
2256
|
+
errors.push({
|
|
2257
|
+
code: "PARSE_ERROR",
|
|
2258
|
+
message: `Failed to parse token file as JSON: ${String(err)}`
|
|
2259
|
+
});
|
|
2260
|
+
outputValidationResult(filePath, errors, useJson);
|
|
2261
|
+
process.exit(1);
|
|
2262
|
+
}
|
|
2263
|
+
try {
|
|
2264
|
+
validateTokenFile(parsed);
|
|
2265
|
+
} catch (err) {
|
|
2266
|
+
if (err instanceof TokenValidationError) {
|
|
2267
|
+
for (const e of err.errors) {
|
|
2268
|
+
errors.push({ code: e.code, path: e.path, message: e.message });
|
|
2269
|
+
}
|
|
2270
|
+
outputValidationResult(filePath, errors, useJson);
|
|
2271
|
+
process.exit(1);
|
|
2272
|
+
}
|
|
2273
|
+
throw err;
|
|
2274
|
+
}
|
|
2275
|
+
try {
|
|
2276
|
+
parseTokenFileSync(raw);
|
|
2277
|
+
} catch (err) {
|
|
2278
|
+
if (err instanceof TokenParseError) {
|
|
2279
|
+
errors.push({ code: err.code, path: err.path, message: err.message });
|
|
2280
|
+
} else {
|
|
2281
|
+
errors.push({ code: "UNKNOWN", message: String(err) });
|
|
2282
|
+
}
|
|
2283
|
+
outputValidationResult(filePath, errors, useJson);
|
|
2284
|
+
process.exit(1);
|
|
2285
|
+
}
|
|
2286
|
+
outputValidationResult(filePath, errors, useJson);
|
|
2287
|
+
} catch (err) {
|
|
2288
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
2289
|
+
`);
|
|
2290
|
+
process.exit(1);
|
|
2291
|
+
}
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
function outputValidationResult(filePath, errors, useJson) {
|
|
2295
|
+
const valid = errors.length === 0;
|
|
2296
|
+
if (useJson) {
|
|
2297
|
+
process.stdout.write(`${JSON.stringify({ valid, file: filePath, errors }, null, 2)}
|
|
2298
|
+
`);
|
|
2299
|
+
} else {
|
|
2300
|
+
if (valid) {
|
|
2301
|
+
process.stdout.write(`\u2713 Token file is valid: ${filePath}
|
|
2302
|
+
`);
|
|
2303
|
+
} else {
|
|
2304
|
+
process.stderr.write(`\u2717 Token file has ${errors.length} error(s): ${filePath}
|
|
2305
|
+
|
|
2306
|
+
`);
|
|
2307
|
+
for (const e of errors) {
|
|
2308
|
+
const pathPrefix = e.path ? ` [${e.path}]` : "";
|
|
2309
|
+
process.stderr.write(` ${e.code}${pathPrefix}: ${e.message}
|
|
2310
|
+
`);
|
|
2311
|
+
}
|
|
2312
|
+
process.exit(1);
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
function createTokensCommand() {
|
|
2317
|
+
const tokensCmd = new Command4("tokens").description(
|
|
2318
|
+
"Query and validate design tokens from a reactscope.tokens.json file"
|
|
2319
|
+
);
|
|
2320
|
+
registerGet2(tokensCmd);
|
|
2321
|
+
registerList2(tokensCmd);
|
|
2322
|
+
registerSearch(tokensCmd);
|
|
2323
|
+
registerResolve(tokensCmd);
|
|
2324
|
+
registerValidate(tokensCmd);
|
|
2325
|
+
return tokensCmd;
|
|
2326
|
+
}
|
|
2327
|
+
|
|
1502
2328
|
// src/program.ts
|
|
1503
2329
|
function createProgram(options = {}) {
|
|
1504
|
-
const program2 = new
|
|
2330
|
+
const program2 = new Command5("scope").version(options.version ?? "0.1.0").description("Scope \u2014 React instrumentation toolkit");
|
|
1505
2331
|
program2.command("capture <url>").description("Capture a React component tree from a live URL and output as JSON").option("-o, --output <path>", "Write JSON to file instead of stdout").option("--pretty", "Pretty-print JSON output (default: minified)", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
|
|
1506
2332
|
async (url, opts) => {
|
|
1507
2333
|
try {
|
|
@@ -1574,7 +2400,7 @@ function createProgram(options = {}) {
|
|
|
1574
2400
|
}
|
|
1575
2401
|
);
|
|
1576
2402
|
program2.command("generate").description("Generate a Playwright test from a Scope trace file").argument("<trace>", "Path to a serialized Scope trace (.json)").option("-o, --output <path>", "Output file path", "scope.spec.ts").option("-d, --description <text>", "Test description").action((tracePath, opts) => {
|
|
1577
|
-
const raw =
|
|
2403
|
+
const raw = readFileSync4(tracePath, "utf-8");
|
|
1578
2404
|
const trace = loadTrace(raw);
|
|
1579
2405
|
const source = generateTest(trace, {
|
|
1580
2406
|
description: opts.description,
|
|
@@ -1585,6 +2411,8 @@ function createProgram(options = {}) {
|
|
|
1585
2411
|
});
|
|
1586
2412
|
program2.addCommand(createManifestCommand());
|
|
1587
2413
|
program2.addCommand(createRenderCommand());
|
|
2414
|
+
program2.addCommand(createTokensCommand());
|
|
2415
|
+
program2.addCommand(createInstrumentCommand());
|
|
1588
2416
|
return program2;
|
|
1589
2417
|
}
|
|
1590
2418
|
|