@agent-scope/cli 1.17.2 → 1.18.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/index.js CHANGED
@@ -4,18 +4,19 @@ import { generateManifest } from '@agent-scope/manifest';
4
4
  import { SpriteSheetGenerator, safeRender, BrowserPool, ALL_CONTEXT_IDS, contextAxis, stressAxis, ALL_STRESS_IDS, RenderMatrix, SatoriRenderer } from '@agent-scope/render';
5
5
  import { TokenResolver, ComplianceEngine, parseTokenFileSync, ThemeResolver, exportTokens, validateTokenFile, TokenValidationError, TokenParseError, ImpactAnalyzer } from '@agent-scope/tokens';
6
6
  import { Command } from 'commander';
7
- import * as esbuild from 'esbuild';
7
+ import * as esbuild2 from 'esbuild';
8
8
  import { createRequire } from 'module';
9
9
  import * as readline from 'readline';
10
10
  import { loadTrace, generateTest, getBrowserEntryScript } from '@agent-scope/playwright';
11
11
  import { chromium } from 'playwright';
12
+ import { tmpdir } from 'os';
12
13
  import { createServer } from 'http';
13
14
  import { buildSite } from '@agent-scope/site';
14
15
 
15
16
  // src/ci/commands.ts
16
- async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss) {
17
+ async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript) {
17
18
  const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
18
- return wrapInHtml(bundledScript, viewportWidth, projectCss);
19
+ return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript);
19
20
  }
20
21
  async function bundleComponentToIIFE(filePath, componentName, props) {
21
22
  const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
@@ -51,7 +52,12 @@ import { createElement } from "react";
51
52
  window.__SCOPE_RENDER_COMPLETE__ = true;
52
53
  return;
53
54
  }
54
- createRoot(rootEl).render(createElement(Component, props));
55
+ // If a scope file wrapper was injected, use it to wrap the component
56
+ var Wrapper = (window).__SCOPE_WRAPPER__;
57
+ var element = Wrapper
58
+ ? createElement(Wrapper, null, createElement(Component, props))
59
+ : createElement(Component, props);
60
+ createRoot(rootEl).render(element);
55
61
  // Use requestAnimationFrame to let React flush the render
56
62
  requestAnimationFrame(function() {
57
63
  window.__SCOPE_RENDER_COMPLETE__ = true;
@@ -63,7 +69,7 @@ import { createElement } from "react";
63
69
  })();
64
70
  `
65
71
  );
66
- const result = await esbuild.build({
72
+ const result = await esbuild2.build({
67
73
  stdin: {
68
74
  contents: wrapperCode,
69
75
  // Resolve relative imports (within the component's dir)
@@ -101,10 +107,11 @@ ${msg}`);
101
107
  }
102
108
  return outputFile.text;
103
109
  }
104
- function wrapInHtml(bundledScript, viewportWidth, projectCss) {
110
+ function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript) {
105
111
  const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
106
112
  ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
107
113
  </style>` : "";
114
+ const wrapperScriptBlock = wrapperScript != null && wrapperScript.length > 0 ? `<script id="scope-wrapper-script">${wrapperScript}</script>` : "";
108
115
  return `<!DOCTYPE html>
109
116
  <html lang="en">
110
117
  <head>
@@ -119,6 +126,7 @@ ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
119
126
  </head>
120
127
  <body>
121
128
  <div id="scope-root" data-reactscope-root></div>
129
+ ${wrapperScriptBlock}
122
130
  <script>${bundledScript}</script>
123
131
  </body>
124
132
  </html>`;
@@ -482,16 +490,16 @@ async function getTailwindCompiler(cwd) {
482
490
  from: entryPath,
483
491
  loadStylesheet
484
492
  });
485
- const build2 = result.build.bind(result);
486
- compilerCache = { cwd, build: build2 };
487
- return build2;
493
+ const build3 = result.build.bind(result);
494
+ compilerCache = { cwd, build: build3 };
495
+ return build3;
488
496
  }
489
497
  async function getCompiledCssForClasses(cwd, classes) {
490
- const build2 = await getTailwindCompiler(cwd);
491
- if (build2 === null) return null;
498
+ const build3 = await getTailwindCompiler(cwd);
499
+ if (build3 === null) return null;
492
500
  const deduped = [...new Set(classes)].filter(Boolean);
493
501
  if (deduped.length === 0) return null;
494
- return build2(deduped);
502
+ return build3(deduped);
495
503
  }
496
504
 
497
505
  // src/ci/commands.ts
@@ -1109,9 +1117,9 @@ function createRL() {
1109
1117
  });
1110
1118
  }
1111
1119
  async function ask(rl, question) {
1112
- return new Promise((resolve17) => {
1120
+ return new Promise((resolve18) => {
1113
1121
  rl.question(question, (answer) => {
1114
- resolve17(answer.trim());
1122
+ resolve18(answer.trim());
1115
1123
  });
1116
1124
  });
1117
1125
  }
@@ -1683,8 +1691,15 @@ async function runHooksProfiling(componentName, filePath, props) {
1683
1691
  try {
1684
1692
  const context = await browser.newContext();
1685
1693
  const page = await context.newPage();
1686
- await page.addInitScript({ content: getBrowserEntryScript() });
1687
- const htmlHarness = await buildComponentHarness(filePath, componentName, props, 1280);
1694
+ const scopeRuntime = getBrowserEntryScript();
1695
+ const htmlHarness = await buildComponentHarness(
1696
+ filePath,
1697
+ componentName,
1698
+ props,
1699
+ 1280,
1700
+ void 0,
1701
+ scopeRuntime
1702
+ );
1688
1703
  await page.setContent(htmlHarness, { waitUntil: "load" });
1689
1704
  await page.waitForFunction(
1690
1705
  () => {
@@ -1943,7 +1958,15 @@ async function runInteractionProfile(componentName, filePath, props, interaction
1943
1958
  try {
1944
1959
  const context = await browser.newContext();
1945
1960
  const page = await context.newPage();
1946
- const htmlHarness = await buildComponentHarness(filePath, componentName, props, 1280);
1961
+ const scopeRuntime = getBrowserEntryScript();
1962
+ const htmlHarness = await buildComponentHarness(
1963
+ filePath,
1964
+ componentName,
1965
+ props,
1966
+ 1280,
1967
+ void 0,
1968
+ scopeRuntime
1969
+ );
1947
1970
  await page.setContent(htmlHarness, { waitUntil: "load" });
1948
1971
  await page.waitForFunction(
1949
1972
  () => {
@@ -2282,12 +2305,14 @@ async function runInstrumentTree(options) {
2282
2305
  viewport: { width: DEFAULT_VIEWPORT_WIDTH, height: DEFAULT_VIEWPORT_HEIGHT }
2283
2306
  });
2284
2307
  const page = await context.newPage();
2285
- await page.addInitScript({ content: getBrowserEntryScript() });
2308
+ const scopeRuntime = getBrowserEntryScript();
2286
2309
  const htmlHarness = await buildComponentHarness(
2287
2310
  filePath,
2288
2311
  componentName,
2289
2312
  {},
2290
- DEFAULT_VIEWPORT_WIDTH
2313
+ DEFAULT_VIEWPORT_WIDTH,
2314
+ void 0,
2315
+ scopeRuntime
2291
2316
  );
2292
2317
  await page.setContent(htmlHarness, { waitUntil: "load" });
2293
2318
  await page.waitForFunction(
@@ -2733,13 +2758,20 @@ Available: ${available}`
2733
2758
  }
2734
2759
  const rootDir = process.cwd();
2735
2760
  const filePath = resolve(rootDir, descriptor.filePath);
2736
- const htmlHarness = await buildComponentHarness(filePath, options.componentName, {}, 1280);
2761
+ const preScript = getBrowserEntryScript() + "\n" + buildInstrumentationScript();
2762
+ const htmlHarness = await buildComponentHarness(
2763
+ filePath,
2764
+ options.componentName,
2765
+ {},
2766
+ 1280,
2767
+ void 0,
2768
+ preScript
2769
+ );
2737
2770
  const pool = await getPool2();
2738
2771
  const slot = await pool.acquire();
2739
2772
  const { page } = slot;
2740
2773
  const startMs = performance.now();
2741
2774
  try {
2742
- await page.addInitScript(buildInstrumentationScript());
2743
2775
  await page.setContent(htmlHarness, { waitUntil: "load" });
2744
2776
  await page.waitForFunction(
2745
2777
  () => window.__SCOPE_RENDER_COMPLETE__ === true,
@@ -2909,6 +2941,122 @@ function writeReportToFile(report, outputPath, pretty) {
2909
2941
  const json = pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
2910
2942
  writeFileSync(outputPath, json, "utf-8");
2911
2943
  }
2944
+ var SCOPE_EXTENSIONS = [".scope.tsx", ".scope.ts", ".scope.jsx", ".scope.js"];
2945
+ function findScopeFile(componentFilePath) {
2946
+ const dir = dirname(componentFilePath);
2947
+ const stem = componentFilePath.replace(/\.(tsx?|jsx?)$/, "");
2948
+ const baseName = stem.slice(dir.length + 1);
2949
+ for (const ext of SCOPE_EXTENSIONS) {
2950
+ const candidate = join(dir, `${baseName}${ext}`);
2951
+ if (existsSync(candidate)) return candidate;
2952
+ }
2953
+ return null;
2954
+ }
2955
+ async function loadScopeFile(scopeFilePath) {
2956
+ const tmpDir = join(tmpdir(), `scope-file-${Date.now()}-${Math.random().toString(36).slice(2)}`);
2957
+ mkdirSync(tmpDir, { recursive: true });
2958
+ const outFile = join(tmpDir, "scope-file.cjs");
2959
+ try {
2960
+ const result = await esbuild2.build({
2961
+ entryPoints: [scopeFilePath],
2962
+ bundle: true,
2963
+ format: "cjs",
2964
+ platform: "node",
2965
+ target: "node18",
2966
+ outfile: outFile,
2967
+ write: true,
2968
+ jsx: "automatic",
2969
+ jsxImportSource: "react",
2970
+ // Externalize React — we don't need to execute JSX, just extract plain data
2971
+ external: ["react", "react-dom", "react/jsx-runtime"],
2972
+ define: {
2973
+ "process.env.NODE_ENV": '"development"'
2974
+ },
2975
+ logLevel: "silent"
2976
+ });
2977
+ if (result.errors.length > 0) {
2978
+ const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
2979
+ throw new Error(`Failed to bundle scope file ${scopeFilePath}:
2980
+ ${msg}`);
2981
+ }
2982
+ const req = createRequire(import.meta.url);
2983
+ delete req.cache[resolve(outFile)];
2984
+ const mod = req(outFile);
2985
+ const scenarios = extractScenarios(mod, scopeFilePath);
2986
+ const hasWrapper = typeof mod.wrapper === "function" || typeof mod.default?.wrapper === "function";
2987
+ return { filePath: scopeFilePath, scenarios, hasWrapper };
2988
+ } finally {
2989
+ try {
2990
+ rmSync(tmpDir, { recursive: true, force: true });
2991
+ } catch {
2992
+ }
2993
+ }
2994
+ }
2995
+ async function loadScopeFileForComponent(componentFilePath) {
2996
+ const scopeFilePath = findScopeFile(componentFilePath);
2997
+ if (scopeFilePath === null) return null;
2998
+ return loadScopeFile(scopeFilePath);
2999
+ }
3000
+ function extractScenarios(mod, filePath) {
3001
+ const raw = mod.scenarios ?? mod.default?.scenarios;
3002
+ if (raw === void 0) return {};
3003
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
3004
+ console.warn(`[scope] ${filePath}: "scenarios" export is not a plain object \u2014 ignoring.`);
3005
+ return {};
3006
+ }
3007
+ const result = {};
3008
+ for (const [name, props] of Object.entries(raw)) {
3009
+ if (typeof props !== "object" || props === null || Array.isArray(props)) {
3010
+ console.warn(`[scope] ${filePath}: scenario "${name}" is not a plain object \u2014 skipping.`);
3011
+ continue;
3012
+ }
3013
+ result[name] = props;
3014
+ }
3015
+ return result;
3016
+ }
3017
+ async function buildWrapperScript(scopeFilePath) {
3018
+ const wrapperEntry = (
3019
+ /* ts */
3020
+ `
3021
+ import * as __scopeMod from ${JSON.stringify(scopeFilePath)};
3022
+ // Expose the wrapper on window so the harness can access it
3023
+ var wrapper =
3024
+ __scopeMod.wrapper ??
3025
+ (__scopeMod.default && __scopeMod.default.wrapper) ??
3026
+ null;
3027
+ window.__SCOPE_WRAPPER__ = wrapper;
3028
+ `
3029
+ );
3030
+ const result = await esbuild2.build({
3031
+ stdin: {
3032
+ contents: wrapperEntry,
3033
+ resolveDir: dirname(scopeFilePath),
3034
+ loader: "tsx",
3035
+ sourcefile: "__scope_wrapper_entry__.tsx"
3036
+ },
3037
+ bundle: true,
3038
+ format: "iife",
3039
+ platform: "browser",
3040
+ target: "es2020",
3041
+ write: false,
3042
+ jsx: "automatic",
3043
+ jsxImportSource: "react",
3044
+ external: [],
3045
+ define: {
3046
+ "process.env.NODE_ENV": '"development"',
3047
+ global: "globalThis"
3048
+ },
3049
+ logLevel: "silent"
3050
+ });
3051
+ if (result.errors.length > 0) {
3052
+ const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
3053
+ throw new Error(`Failed to build wrapper script from ${scopeFilePath}:
3054
+ ${msg}`);
3055
+ }
3056
+ return result.outputFiles?.[0]?.text ?? "";
3057
+ }
3058
+
3059
+ // src/render-commands.ts
2912
3060
  var MANIFEST_PATH6 = ".reactscope/manifest.json";
2913
3061
  var DEFAULT_OUTPUT_DIR = ".reactscope/renders";
2914
3062
  var _pool3 = null;
@@ -2929,7 +3077,7 @@ async function shutdownPool3() {
2929
3077
  _pool3 = null;
2930
3078
  }
2931
3079
  }
2932
- function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
3080
+ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, wrapperScript) {
2933
3081
  const satori = new SatoriRenderer({
2934
3082
  defaultViewport: { width: viewportWidth, height: viewportHeight }
2935
3083
  });
@@ -2942,7 +3090,10 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
2942
3090
  filePath,
2943
3091
  componentName,
2944
3092
  props,
2945
- viewportWidth
3093
+ viewportWidth,
3094
+ void 0,
3095
+ // projectCss (handled separately)
3096
+ wrapperScript
2946
3097
  );
2947
3098
  const slot = await pool.acquire();
2948
3099
  const { page } = slot;
@@ -3033,8 +3184,37 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight) {
3033
3184
  }
3034
3185
  };
3035
3186
  }
3187
+ function buildScenarioMap(opts, scopeData) {
3188
+ if (opts.scenario !== void 0) {
3189
+ if (scopeData === null) {
3190
+ throw new Error(`--scenario "${opts.scenario}" requires a .scope file next to the component`);
3191
+ }
3192
+ const props = scopeData.scenarios[opts.scenario];
3193
+ if (props === void 0) {
3194
+ const available = Object.keys(scopeData.scenarios).join(", ") || "(none)";
3195
+ throw new Error(
3196
+ `Scenario "${opts.scenario}" not found in scope file.
3197
+ Available: ${available}`
3198
+ );
3199
+ }
3200
+ return { [opts.scenario]: props };
3201
+ }
3202
+ if (opts.props !== void 0) {
3203
+ let parsed;
3204
+ try {
3205
+ parsed = JSON.parse(opts.props);
3206
+ } catch {
3207
+ throw new Error(`Invalid props JSON: ${opts.props}`);
3208
+ }
3209
+ return { __default__: parsed };
3210
+ }
3211
+ if (scopeData !== null && Object.keys(scopeData.scenarios).length > 0) {
3212
+ return scopeData.scenarios;
3213
+ }
3214
+ return { __default__: {} };
3215
+ }
3036
3216
  function registerRenderSingle(renderCmd) {
3037
- 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(
3217
+ 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("--scenario <name>", "Run a named scenario from the component's .scope file").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(
3038
3218
  async (componentName, opts) => {
3039
3219
  try {
3040
3220
  const manifest = loadManifest(opts.manifest);
@@ -3046,80 +3226,71 @@ function registerRenderSingle(renderCmd) {
3046
3226
  Available: ${available}`
3047
3227
  );
3048
3228
  }
3049
- let props = {};
3050
- if (opts.props !== void 0) {
3051
- try {
3052
- props = JSON.parse(opts.props);
3053
- } catch {
3054
- throw new Error(`Invalid props JSON: ${opts.props}`);
3055
- }
3056
- }
3057
3229
  const { width, height } = parseViewport(opts.viewport);
3058
3230
  const rootDir = process.cwd();
3059
3231
  const filePath = resolve(rootDir, descriptor.filePath);
3060
- const renderer = buildRenderer(filePath, componentName, width, height);
3232
+ const scopeData = await loadScopeFileForComponent(filePath);
3233
+ const wrapperScript = scopeData?.hasWrapper === true ? await buildWrapperScript(scopeData.filePath) : void 0;
3234
+ const scenarios = buildScenarioMap(opts, scopeData);
3235
+ const renderer = buildRenderer(filePath, componentName, width, height, wrapperScript);
3061
3236
  process.stderr.write(
3062
3237
  `Rendering ${componentName} [${descriptor.complexityClass}] at ${width}\xD7${height}\u2026
3063
3238
  `
3064
3239
  );
3065
- const outcome = await safeRender(
3066
- () => renderer.renderCell(props, descriptor.complexityClass),
3067
- {
3068
- props,
3069
- sourceLocation: {
3070
- file: descriptor.filePath,
3071
- line: descriptor.loc.start,
3072
- column: 0
3240
+ const fmt2 = resolveSingleFormat(opts.format);
3241
+ let anyFailed = false;
3242
+ for (const [scenarioName, props] of Object.entries(scenarios)) {
3243
+ const isNamed = scenarioName !== "__default__";
3244
+ const label = isNamed ? `${componentName}:${scenarioName}` : componentName;
3245
+ const outcome = await safeRender(
3246
+ () => renderer.renderCell(props, descriptor.complexityClass),
3247
+ {
3248
+ props,
3249
+ sourceLocation: {
3250
+ file: descriptor.filePath,
3251
+ line: descriptor.loc.start,
3252
+ column: 0
3253
+ }
3073
3254
  }
3074
- }
3075
- );
3076
- await shutdownPool3();
3077
- if (outcome.crashed) {
3078
- process.stderr.write(`\u2717 Render failed: ${outcome.error.message}
3255
+ );
3256
+ if (outcome.crashed) {
3257
+ process.stderr.write(`\u2717 ${label} render failed: ${outcome.error.message}
3079
3258
  `);
3080
- const hintList = outcome.error.heuristicFlags.join(", ");
3081
- if (hintList.length > 0) {
3082
- process.stderr.write(` Hints: ${hintList}
3259
+ const hintList = outcome.error.heuristicFlags.join(", ");
3260
+ if (hintList.length > 0) {
3261
+ process.stderr.write(` Hints: ${hintList}
3083
3262
  `);
3263
+ }
3264
+ anyFailed = true;
3265
+ continue;
3084
3266
  }
3085
- process.exit(1);
3086
- }
3087
- const result = outcome.result;
3088
- if (opts.output !== void 0) {
3089
- const outPath = resolve(process.cwd(), opts.output);
3090
- writeFileSync(outPath, result.screenshot);
3091
- process.stdout.write(
3092
- `\u2713 ${componentName} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
3267
+ const result = outcome.result;
3268
+ const outFileName = isNamed ? `${componentName}-${scenarioName}.png` : `${componentName}.png`;
3269
+ if (opts.output !== void 0 && !isNamed) {
3270
+ const outPath = resolve(process.cwd(), opts.output);
3271
+ writeFileSync(outPath, result.screenshot);
3272
+ process.stdout.write(
3273
+ `\u2713 ${label} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
3093
3274
  `
3094
- );
3095
- return;
3096
- }
3097
- const fmt2 = resolveSingleFormat(opts.format);
3098
- if (fmt2 === "json") {
3099
- const json = formatRenderJson(componentName, props, result);
3100
- process.stdout.write(`${JSON.stringify(json, null, 2)}
3275
+ );
3276
+ } else if (fmt2 === "json") {
3277
+ const json = formatRenderJson(label, props, result);
3278
+ process.stdout.write(`${JSON.stringify(json, null, 2)}
3101
3279
  `);
3102
- } else if (fmt2 === "file") {
3103
- const dir = resolve(process.cwd(), DEFAULT_OUTPUT_DIR);
3104
- mkdirSync(dir, { recursive: true });
3105
- const outPath = resolve(dir, `${componentName}.png`);
3106
- writeFileSync(outPath, result.screenshot);
3107
- const relPath = `${DEFAULT_OUTPUT_DIR}/${componentName}.png`;
3108
- process.stdout.write(
3109
- `\u2713 ${componentName} \u2192 ${relPath} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
3110
- `
3111
- );
3112
- } else {
3113
- const dir = resolve(process.cwd(), DEFAULT_OUTPUT_DIR);
3114
- mkdirSync(dir, { recursive: true });
3115
- const outPath = resolve(dir, `${componentName}.png`);
3116
- writeFileSync(outPath, result.screenshot);
3117
- const relPath = `${DEFAULT_OUTPUT_DIR}/${componentName}.png`;
3118
- process.stdout.write(
3119
- `\u2713 ${componentName} \u2192 ${relPath} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
3280
+ } else {
3281
+ const dir = resolve(process.cwd(), DEFAULT_OUTPUT_DIR);
3282
+ mkdirSync(dir, { recursive: true });
3283
+ const outPath = resolve(dir, outFileName);
3284
+ writeFileSync(outPath, result.screenshot);
3285
+ const relPath = `${DEFAULT_OUTPUT_DIR}/${outFileName}`;
3286
+ process.stdout.write(
3287
+ `\u2713 ${label} \u2192 ${relPath} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
3120
3288
  `
3121
- );
3289
+ );
3290
+ }
3122
3291
  }
3292
+ await shutdownPool3();
3293
+ if (anyFailed) process.exit(1);
3123
3294
  } catch (err) {
3124
3295
  await shutdownPool3();
3125
3296
  process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}