frameshot-mcp 0.3.0 → 0.7.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
@@ -1,9 +1,22 @@
1
1
  #!/usr/bin/env node
2
- var __defProp = Object.defineProperty;
3
- var __export = (target, all) => {
4
- for (var name in all)
5
- __defProp(target, name, { get: all[name], enumerable: true });
6
- };
2
+ import {
3
+ AuditUseCase,
4
+ ScreenshotUseCase,
5
+ SnapshotStore,
6
+ SnapshotUseCase
7
+ } from "./chunk-FTYTZW6D.js";
8
+ import {
9
+ BrowserPool,
10
+ CatalogUseCase,
11
+ DEVICE_PRESETS,
12
+ DiffUseCase,
13
+ EXT_TO_FRAMEWORK,
14
+ HtmlBuilder,
15
+ ImageComparator,
16
+ RenderUseCase,
17
+ ViteBundler,
18
+ __export
19
+ } from "./chunk-Q7A3DLED.js";
7
20
 
8
21
  // src/index.ts
9
22
  import { execSync } from "child_process";
@@ -14524,703 +14537,1114 @@ function date4(params) {
14524
14537
  // node_modules/zod/v4/classic/external.js
14525
14538
  config(en_default());
14526
14539
 
14527
- // src/renderer.ts
14528
- import pixelmatch from "pixelmatch";
14529
- import { chromium, firefox, webkit } from "playwright";
14530
- import { PNG } from "pngjs";
14531
- var pool = /* @__PURE__ */ new Map();
14532
- var ENGINES = ["chromium", "firefox", "webkit"];
14533
- async function warmup(engines = ENGINES) {
14534
- await Promise.all(engines.map((e) => getSlot(e)));
14535
- }
14536
- async function getSlot(engine) {
14537
- const existing = pool.get(engine);
14538
- if (existing?.browser.isConnected() && existing.ready) {
14539
- return existing;
14540
- }
14541
- const launcher = { chromium, firefox, webkit }[engine];
14542
- let browser;
14543
- try {
14544
- browser = await launcher.launch({
14545
- headless: true,
14546
- ...engine === "chromium" ? { channel: "chrome" } : {}
14547
- });
14548
- } catch (_e) {
14549
- throw new Error(
14550
- `${engine} is not installed. Run: npx playwright install ${engine}`
14551
- );
14552
- }
14553
- const context = await browser.newContext({
14554
- viewport: { width: 1280, height: 800 },
14555
- deviceScaleFactor: 2
14556
- });
14557
- const page = await context.newPage();
14558
- await page.setContent(
14559
- '<html><head><script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script></head><body></body></html>',
14560
- { waitUntil: "networkidle" }
14561
- );
14562
- const slot = { browser, page, ready: true };
14563
- pool.set(engine, slot);
14564
- return slot;
14565
- }
14566
- function wrapComponent(code, framework, darkMode = false, css = "") {
14567
- const tailwind = '<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>';
14568
- const tailwindConfig = `<script>tailwind.config={darkMode:'class'}</script>`;
14569
- const customCss = css ? `<style>${css}</style>` : "";
14570
- const baseStyle = `<style>*{margin:0;box-sizing:border-box}body{padding:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}</style>`;
14571
- const htmlClass = darkMode ? ' class="dark"' : "";
14572
- if (framework === "html") {
14573
- if (code.includes("<html")) return code;
14574
- return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}${baseStyle}${customCss}</head><body>${code}</body></html>`;
14575
- }
14576
- if (framework === "react") {
14577
- const cleanedCode = code.replace(/['"]use client['"];?\n?/g, "").replace(/['"]use server['"];?\n?/g, "").replace(/import\s+.*?\s+from\s+['"]next\/image['"];?\n?/g, "").replace(/import\s+.*?\s+from\s+['"]next\/link['"];?\n?/g, "");
14578
- return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14579
- <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
14580
- <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
14581
- <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
14582
- ${baseStyle}${customCss}</head><body><div id="root"></div>
14583
- <script type="text/babel">
14584
- const Image = (props) => React.createElement('img', {...props, src: props.src?.src || props.src});
14585
- const Link = ({href, children, ...props}) => React.createElement('a', {href, ...props}, children);
14586
- ${cleanedCode}
14587
- const _C = typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:typeof Default!=='undefined'?Default:null;
14588
- if(_C)ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(_C));
14589
- </script></body></html>`;
14590
- }
14591
- if (framework === "vue") {
14592
- return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14593
- <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
14594
- ${baseStyle}${customCss}</head><body><div id="app"></div>
14595
- <script>
14596
- const{createApp,ref,reactive,computed,onMounted,watch,watchEffect}=Vue;
14597
- ${code}
14598
- const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
14599
- if(_C)createApp(_C).mount('#app');
14600
- </script></body></html>`;
14601
- }
14602
- if (framework === "svelte") {
14603
- return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
14604
- <script src="https://unpkg.com/svelte@4/compiler.cjs"></script>
14605
- ${baseStyle}${customCss}</head><body><div id="app"></div>
14606
- <script type="module">
14607
- import "https://unpkg.com/svelte@4/internal/index.mjs";
14608
- ${code}
14609
- const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
14610
- if(_C)new _C({target:document.getElementById('app')});
14611
- </script></body></html>`;
14612
- }
14613
- return code;
14614
- }
14615
- async function renderSingle(engine, html, options) {
14616
- const { width = 1280, height = 800, fullPage = true, waitFor = 0 } = options;
14617
- const slot = await getSlot(engine);
14618
- const { page } = slot;
14619
- const consoleErrors = [];
14620
- const onError = (msg) => {
14621
- if (msg.type() === "error") {
14622
- consoleErrors.push(msg.text());
14623
- }
14624
- };
14625
- page.on("console", onError);
14626
- page.on("pageerror", (err) => consoleErrors.push(err.message));
14627
- const currentViewport = page.viewportSize();
14628
- if (currentViewport?.width !== width || currentViewport?.height !== height) {
14629
- await page.setViewportSize({ width, height });
14630
- }
14631
- await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
14632
- if (waitFor > 0) {
14633
- await page.waitForTimeout(waitFor);
14634
- }
14635
- const screenshot = await page.screenshot({ type: "png", fullPage });
14636
- const metrics = await page.evaluate(() => ({
14637
- w: document.documentElement.scrollWidth,
14638
- h: document.documentElement.scrollHeight
14639
- }));
14640
- page.removeListener("console", onError);
14641
- return {
14642
- engine,
14643
- image: screenshot.toString("base64"),
14644
- width: metrics.w,
14645
- height: metrics.h,
14646
- consoleErrors
14647
- };
14648
- }
14649
- async function render(code, framework, options = {}) {
14650
- const engines = options.engines ?? ["chromium"];
14651
- const html = wrapComponent(
14652
- code,
14653
- framework,
14654
- options.darkMode ?? false,
14655
- options.css ?? ""
14656
- );
14657
- const results = await Promise.all(
14658
- engines.map((e) => renderSingle(e, html, options))
14659
- );
14660
- return results;
14661
- }
14662
- async function screenshotUrl(url2, options = {}) {
14663
- const engines = options.engines ?? ["chromium"];
14664
- const results = await Promise.all(
14665
- engines.map(async (engine) => {
14666
- const {
14667
- width = 1280,
14668
- height = 800,
14669
- fullPage = true,
14670
- waitFor = 0
14671
- } = options;
14672
- const slot = await getSlot(engine);
14673
- const { page } = slot;
14674
- const consoleErrors = [];
14675
- const onError = (msg) => {
14676
- if (msg.type() === "error") {
14677
- consoleErrors.push(msg.text());
14540
+ // src/tools/audit-tools.ts
14541
+ function registerAuditTools(server2, useCase) {
14542
+ server2.tool(
14543
+ "audit_a11y",
14544
+ "Run an accessibility audit (axe-core) on a rendered component. Returns WCAG violations with impact level, description, and affected HTML nodes. Use this to catch a11y issues before shipping.",
14545
+ {
14546
+ code: external_exports.string().describe("Component code to audit"),
14547
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14548
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14549
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
14550
+ },
14551
+ async ({ code, framework, width, height }) => {
14552
+ try {
14553
+ const result = await useCase.auditA11y(code, framework, {
14554
+ viewport: { width, height }
14555
+ });
14556
+ if (result.violations.length === 0) {
14557
+ return {
14558
+ content: [
14559
+ {
14560
+ type: "text",
14561
+ text: `\u2705 No accessibility violations found. (${result.passes} rules passed, ${result.incomplete} need review)`
14562
+ }
14563
+ ]
14564
+ };
14678
14565
  }
14679
- };
14680
- page.on("console", onError);
14681
- page.on("pageerror", (err) => consoleErrors.push(err.message));
14682
- const currentViewport = page.viewportSize();
14683
- if (currentViewport?.width !== width || currentViewport?.height !== height) {
14684
- await page.setViewportSize({ width, height });
14685
- }
14686
- await page.goto(url2, { waitUntil: "domcontentloaded", timeout: 15e3 });
14687
- await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
14688
- });
14689
- if (waitFor > 0) {
14690
- await page.waitForTimeout(waitFor);
14691
- }
14692
- const screenshot = await page.screenshot({ type: "png", fullPage });
14693
- const metrics = await page.evaluate(() => ({
14694
- w: document.documentElement.scrollWidth,
14695
- h: document.documentElement.scrollHeight
14696
- }));
14697
- page.removeListener("console", onError);
14698
- return {
14699
- engine,
14700
- image: screenshot.toString("base64"),
14701
- width: metrics.w,
14702
- height: metrics.h,
14703
- consoleErrors
14704
- };
14705
- })
14706
- );
14707
- return results;
14708
- }
14709
- async function auditA11y(code, framework, options = {}) {
14710
- const html = wrapComponent(
14711
- code,
14712
- framework,
14713
- options.darkMode ?? false,
14714
- options.css ?? ""
14715
- );
14716
- const slot = await getSlot("chromium");
14717
- const { page } = slot;
14718
- const { width = 1280, height = 800 } = options;
14719
- const currentViewport = page.viewportSize();
14720
- if (currentViewport?.width !== width || currentViewport?.height !== height) {
14721
- await page.setViewportSize({ width, height });
14722
- }
14723
- await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
14724
- const axeSource = await import("axe-core").then((m) => m.source);
14725
- await page.addScriptTag({ content: axeSource });
14726
- const results = await page.evaluate(() => {
14727
- return window.axe.run();
14728
- });
14729
- return {
14730
- violations: results.violations.map(
14731
- (v) => ({
14732
- id: v.id,
14733
- impact: v.impact,
14734
- description: v.description,
14735
- helpUrl: v.helpUrl,
14736
- nodes: v.nodes.map((n) => ({
14737
- html: n.html,
14738
- target: n.target
14739
- }))
14740
- })
14741
- ),
14742
- passes: results.passes.length,
14743
- incomplete: results.incomplete.length
14744
- };
14745
- }
14746
- async function diffComponent(before, after, framework, options = {}) {
14747
- const [beforeResults, afterResults] = await Promise.all([
14748
- render(before, framework, { ...options, engines: ["chromium"] }),
14749
- render(after, framework, { ...options, engines: ["chromium"] })
14750
- ]);
14751
- const beforeBuf = Buffer.from(beforeResults[0].image, "base64");
14752
- const afterBuf = Buffer.from(afterResults[0].image, "base64");
14753
- const beforePng = PNG.sync.read(beforeBuf);
14754
- const afterPng = PNG.sync.read(afterBuf);
14755
- const width = Math.max(beforePng.width, afterPng.width);
14756
- const height = Math.max(beforePng.height, afterPng.height);
14757
- const normalizedBefore = new PNG({ width, height });
14758
- const normalizedAfter = new PNG({ width, height });
14759
- PNG.bitblt(
14760
- beforePng,
14761
- normalizedBefore,
14762
- 0,
14763
- 0,
14764
- beforePng.width,
14765
- beforePng.height,
14766
- 0,
14767
- 0
14768
- );
14769
- PNG.bitblt(
14770
- afterPng,
14771
- normalizedAfter,
14772
- 0,
14773
- 0,
14774
- afterPng.width,
14775
- afterPng.height,
14776
- 0,
14777
- 0
14778
- );
14779
- const diffPng = new PNG({ width, height });
14780
- const diffPixels = pixelmatch(
14781
- normalizedBefore.data,
14782
- normalizedAfter.data,
14783
- diffPng.data,
14784
- width,
14785
- height,
14786
- { threshold: 0.1 }
14566
+ const report = result.violations.map((v) => {
14567
+ const nodes = v.nodes.slice(0, 3).map((n) => ` ${n.target.join(" > ")}
14568
+ ${n.html}`).join("\n");
14569
+ return `[${v.impact?.toUpperCase()}] ${v.id}
14570
+ ${v.description}
14571
+ ${v.helpUrl}
14572
+ ${nodes}`;
14573
+ }).join("\n\n");
14574
+ return {
14575
+ content: [
14576
+ {
14577
+ type: "text",
14578
+ text: `Found ${result.violations.length} accessibility violation(s):
14579
+
14580
+ ${report}
14581
+
14582
+ (${result.passes} rules passed, ${result.incomplete} need review)`
14583
+ }
14584
+ ]
14585
+ };
14586
+ } catch (error51) {
14587
+ const msg = error51 instanceof Error ? error51.message : String(error51);
14588
+ return {
14589
+ content: [
14590
+ { type: "text", text: `A11y audit failed: ${msg}` }
14591
+ ],
14592
+ isError: true
14593
+ };
14594
+ }
14595
+ }
14787
14596
  );
14788
- const totalPixels = width * height;
14789
- return {
14790
- before: beforeResults[0].image,
14791
- after: afterResults[0].image,
14792
- diff: PNG.sync.write(diffPng).toString("base64"),
14793
- diffPixels,
14794
- totalPixels,
14795
- diffPercentage: Math.round(diffPixels / totalPixels * 1e4) / 100
14796
- };
14797
- }
14798
- async function captureAnimation(code, framework, options = {}) {
14799
- const { frames = 5, duration: duration3 = 1e3 } = options;
14800
- const html = wrapComponent(
14801
- code,
14802
- framework,
14803
- options.darkMode ?? false,
14804
- options.css ?? ""
14597
+ server2.tool(
14598
+ "perf_audit",
14599
+ "Analyze component performance: DOM element count, tree depth, render time, script/stylesheet count. Use this to catch bloated components before they ship.",
14600
+ {
14601
+ code: external_exports.string().describe("Component code to audit"),
14602
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework"),
14603
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14604
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
14605
+ },
14606
+ async ({ code, framework, width, height }) => {
14607
+ try {
14608
+ const metrics = await useCase.perfAudit(code, framework, {
14609
+ viewport: { width, height }
14610
+ });
14611
+ const lines = [
14612
+ `\u23F1\uFE0F Render time: ${metrics.renderTimeMs}ms`,
14613
+ `\u{1F4E6} DOM elements: ${metrics.domElements}`,
14614
+ `\u{1F333} DOM depth: ${metrics.domDepth}`,
14615
+ `\u{1F4DC} Scripts: ${metrics.scriptCount}`,
14616
+ `\u{1F3A8} Stylesheets: ${metrics.styleSheetCount}`,
14617
+ `\u{1F5BC}\uFE0F Images: ${metrics.imageCount}`,
14618
+ `\u{1F4D0} Total DOM size: ${(metrics.totalDomSize / 1024).toFixed(1)}KB`
14619
+ ];
14620
+ return {
14621
+ content: [{ type: "text", text: lines.join("\n") }]
14622
+ };
14623
+ } catch (error51) {
14624
+ const msg = error51 instanceof Error ? error51.message : String(error51);
14625
+ return {
14626
+ content: [
14627
+ { type: "text", text: `Perf audit failed: ${msg}` }
14628
+ ],
14629
+ isError: true
14630
+ };
14631
+ }
14632
+ }
14805
14633
  );
14806
- const slot = await getSlot("chromium");
14807
- const { page } = slot;
14808
- const { width = 1280, height = 800 } = options;
14809
- const currentViewport = page.viewportSize();
14810
- if (currentViewport?.width !== width || currentViewport?.height !== height) {
14811
- await page.setViewportSize({ width, height });
14812
- }
14813
- await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
14814
- const interval = duration3 / (frames - 1);
14815
- const results = [];
14816
- for (let i = 0; i < frames; i++) {
14817
- if (i > 0) {
14818
- await page.waitForTimeout(interval);
14819
- }
14820
- const screenshot = await page.screenshot({ type: "png", fullPage: false });
14821
- results.push({
14822
- timestamp: Math.round(i * interval),
14823
- image: screenshot.toString("base64")
14824
- });
14825
- }
14826
- return results;
14827
14634
  }
14828
- async function shutdown() {
14829
- for (const slot of pool.values()) {
14830
- await slot.browser.close().catch(() => {
14831
- });
14832
- }
14833
- pool.clear();
14635
+
14636
+ // src/tools/catalog-tools.ts
14637
+ function registerCatalogTools(server2, useCase) {
14638
+ server2.tool(
14639
+ "render_catalog",
14640
+ "Scan a directory for component files (.jsx/.tsx/.vue/.svelte/.html) and render a screenshot of each. Returns a visual catalog \u2014 like Storybook but zero-config. Use this to quickly audit all components in a folder.",
14641
+ {
14642
+ directory: external_exports.string().describe("Absolute path to the directory to scan"),
14643
+ recursive: external_exports.boolean().optional().default(false).describe("Scan subdirectories recursively"),
14644
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14645
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
14646
+ darkMode: external_exports.boolean().optional().default(false).describe("Render with dark mode"),
14647
+ tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)")
14648
+ },
14649
+ async ({
14650
+ directory,
14651
+ recursive,
14652
+ width,
14653
+ height,
14654
+ darkMode,
14655
+ tailwindVersion
14656
+ }) => {
14657
+ try {
14658
+ const results = await useCase.renderCatalog(directory, {
14659
+ recursive,
14660
+ viewport: { width, height },
14661
+ darkMode,
14662
+ tailwindVersion
14663
+ });
14664
+ if (results.length === 0) {
14665
+ return {
14666
+ content: [
14667
+ {
14668
+ type: "text",
14669
+ text: `No component files found in ${directory}`
14670
+ }
14671
+ ]
14672
+ };
14673
+ }
14674
+ const content = results.flatMap((r) => {
14675
+ if (!r.image) {
14676
+ return [
14677
+ {
14678
+ type: "text",
14679
+ text: `\u274C ${r.path} \u2014 failed to render`
14680
+ }
14681
+ ];
14682
+ }
14683
+ return [
14684
+ {
14685
+ type: "image",
14686
+ data: r.image,
14687
+ mimeType: "image/png"
14688
+ },
14689
+ {
14690
+ type: "text",
14691
+ text: `[${r.framework}] ${r.path} \u2014 ${r.width}x${r.height}${r.consoleErrors.length ? `
14692
+ \u26A0\uFE0F ${r.consoleErrors.join("\n")}` : ""}`
14693
+ }
14694
+ ];
14695
+ });
14696
+ content.unshift({
14697
+ type: "text",
14698
+ text: `\u{1F4E6} Component catalog: ${results.length} files from ${directory}`
14699
+ });
14700
+ return { content };
14701
+ } catch (error51) {
14702
+ const msg = error51 instanceof Error ? error51.message : String(error51);
14703
+ return {
14704
+ content: [
14705
+ { type: "text", text: `Catalog render failed: ${msg}` }
14706
+ ],
14707
+ isError: true
14708
+ };
14709
+ }
14710
+ }
14711
+ );
14834
14712
  }
14835
14713
 
14836
- // src/index.ts
14837
- async function ensureBrowser() {
14838
- try {
14839
- await warmup(["chromium"]);
14840
- } catch {
14841
- try {
14842
- execSync("npx playwright install chromium", { stdio: "pipe" });
14843
- await warmup(["chromium"]);
14844
- } catch {
14714
+ // src/tools/diff-tools.ts
14715
+ function registerDiffTools(server2, useCase) {
14716
+ server2.tool(
14717
+ "diff_component",
14718
+ "Visual regression test: render before/after code and return a pixel diff image with percentage changed. Use this during refactoring to catch unintended visual changes.",
14719
+ {
14720
+ before: external_exports.string().describe("Component code BEFORE the change"),
14721
+ after: external_exports.string().describe("Component code AFTER the change"),
14722
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14723
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14724
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
14725
+ },
14726
+ async ({ before, after, framework, width, height }) => {
14727
+ try {
14728
+ const result = await useCase.diffComponent(before, after, framework, {
14729
+ viewport: { width, height }
14730
+ });
14731
+ const content = [
14732
+ {
14733
+ type: "text",
14734
+ text: `Visual diff: ${result.diffPercentage}% pixels changed (${result.diffPixels}/${result.totalPixels})`
14735
+ },
14736
+ { type: "text", text: "Before:" },
14737
+ {
14738
+ type: "image",
14739
+ data: result.before,
14740
+ mimeType: "image/png"
14741
+ },
14742
+ { type: "text", text: "After:" },
14743
+ {
14744
+ type: "image",
14745
+ data: result.after,
14746
+ mimeType: "image/png"
14747
+ },
14748
+ { type: "text", text: "Diff (red = changed pixels):" },
14749
+ {
14750
+ type: "image",
14751
+ data: result.diff,
14752
+ mimeType: "image/png"
14753
+ }
14754
+ ];
14755
+ return { content };
14756
+ } catch (error51) {
14757
+ const msg = error51 instanceof Error ? error51.message : String(error51);
14758
+ return {
14759
+ content: [{ type: "text", text: `Diff failed: ${msg}` }],
14760
+ isError: true
14761
+ };
14762
+ }
14845
14763
  }
14846
- }
14764
+ );
14765
+ server2.tool(
14766
+ "diff_reference",
14767
+ "Compare rendered component output against a reference image (e.g. from Figma export or previous snapshot). Returns pixel diff percentage and pass/fail status. Use this for design QA \u2014 verify your component matches the design.",
14768
+ {
14769
+ code: external_exports.string().describe("Component code to render and compare"),
14770
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14771
+ referenceImage: external_exports.string().describe("Reference image as base64-encoded PNG"),
14772
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14773
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
14774
+ threshold: external_exports.number().optional().default(0.1).describe(
14775
+ "Pixel matching threshold (0-1). Lower = stricter. Default 0.1."
14776
+ ),
14777
+ darkMode: external_exports.boolean().optional().default(false).describe("Render with dark mode"),
14778
+ tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)")
14779
+ },
14780
+ async ({
14781
+ code,
14782
+ framework,
14783
+ referenceImage,
14784
+ width,
14785
+ height,
14786
+ threshold,
14787
+ darkMode,
14788
+ tailwindVersion
14789
+ }) => {
14790
+ try {
14791
+ const result = await useCase.diffFromReference(
14792
+ code,
14793
+ framework,
14794
+ referenceImage,
14795
+ {
14796
+ viewport: { width, height },
14797
+ threshold,
14798
+ darkMode,
14799
+ tailwindVersion
14800
+ }
14801
+ );
14802
+ const status = result.passed ? "PASS" : "FAIL";
14803
+ const content = [
14804
+ {
14805
+ type: "text",
14806
+ text: `${status}: ${result.diffPercentage}% pixels differ (${result.diffPixels}/${result.totalPixels})`
14807
+ },
14808
+ { type: "text", text: "Rendered:" },
14809
+ {
14810
+ type: "image",
14811
+ data: result.rendered,
14812
+ mimeType: "image/png"
14813
+ },
14814
+ { type: "text", text: "Diff (red = changed pixels):" },
14815
+ {
14816
+ type: "image",
14817
+ data: result.diff,
14818
+ mimeType: "image/png"
14819
+ }
14820
+ ];
14821
+ return { content };
14822
+ } catch (error51) {
14823
+ const msg = error51 instanceof Error ? error51.message : String(error51);
14824
+ return {
14825
+ content: [
14826
+ { type: "text", text: `Reference diff failed: ${msg}` }
14827
+ ],
14828
+ isError: true
14829
+ };
14830
+ }
14831
+ }
14832
+ );
14847
14833
  }
14848
- await ensureBrowser();
14849
- var server = new McpServer({
14850
- name: "frameshot",
14851
- version: "0.1.0"
14852
- });
14853
- server.tool(
14854
- "render_component",
14855
- "Instantly render a React/Vue/HTML component and return screenshots across browser engines. Zero setup needed \u2014 just pass your code. Tailwind CSS is built-in. Use this to visually verify UI code without starting a dev server.",
14856
- {
14857
- code: external_exports.string().describe("Component code to render"),
14858
- framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14859
- engines: external_exports.array(external_exports.enum(["chromium", "firefox", "webkit"])).optional().default(["chromium"]).describe(
14860
- 'Browser engines to render in. Use ["chromium","firefox","webkit"] for cross-browser check.'
14861
- ),
14862
- width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14863
- height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
14864
- fullPage: external_exports.boolean().optional().default(true).describe("Capture full scroll height"),
14865
- darkMode: external_exports.boolean().optional().default(false).describe("Render with Tailwind dark mode (adds 'dark' class to html)"),
14866
- colorSchemes: external_exports.array(external_exports.enum(["light", "dark"])).optional().describe(
14867
- 'Render both: ["light","dark"] returns 2 screenshots for comparison'
14868
- ),
14869
- css: external_exports.string().optional().describe("Custom CSS to inject (design tokens, variables, etc)")
14870
- },
14871
- async ({
14872
- code,
14873
- framework,
14874
- engines,
14875
- width,
14876
- height,
14877
- fullPage,
14878
- darkMode,
14879
- colorSchemes,
14880
- css
14881
- }) => {
14882
- try {
14883
- const start = performance.now();
14884
- const schemes = colorSchemes ?? (darkMode ? ["dark"] : ["light"]);
14885
- const allResults = await Promise.all(
14886
- schemes.map(
14887
- (scheme) => render(code, framework, {
14888
- width,
14889
- height,
14890
- fullPage,
14891
- engines,
14892
- darkMode: scheme === "dark",
14893
- css
14894
- })
14895
- )
14896
- );
14897
- const elapsed = Math.round(performance.now() - start);
14898
- const results = allResults.flat();
14899
- const content = results.flatMap((r) => [
14900
- {
14901
- type: "image",
14902
- data: r.image,
14903
- mimeType: "image/png"
14904
- },
14905
- {
14906
- type: "text",
14907
- text: `[${r.engine}] ${r.width}x${r.height} (${elapsed}ms)${r.consoleErrors.length ? `
14834
+
14835
+ // src/tools/render-tools.ts
14836
+ import { extname } from "path";
14837
+ function registerRenderTools(server2, useCase) {
14838
+ server2.tool(
14839
+ "render_file",
14840
+ "Render a component file with full dependency resolution. Uses your project's Vite config to resolve imports, path aliases, CSS modules, and node_modules. Falls back to CDN-based rendering if Vite is unavailable. Auto-detects framework from file extension.",
14841
+ {
14842
+ path: external_exports.string().describe("Absolute path to the component file"),
14843
+ props: external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe("Props to pass to the component"),
14844
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14845
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
14846
+ darkMode: external_exports.boolean().optional().default(false).describe("Render with dark mode"),
14847
+ tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)"),
14848
+ projectRoot: external_exports.string().optional().describe(
14849
+ "Project root directory override (defaults to auto-detect from file path)"
14850
+ )
14851
+ },
14852
+ async ({
14853
+ path,
14854
+ props,
14855
+ width,
14856
+ height,
14857
+ darkMode,
14858
+ tailwindVersion,
14859
+ projectRoot
14860
+ }) => {
14861
+ try {
14862
+ const start = performance.now();
14863
+ const { results, mode } = await useCase.renderFile(path, {
14864
+ props,
14865
+ viewport: { width, height },
14866
+ fullPage: true,
14867
+ engines: ["chromium"],
14868
+ darkMode,
14869
+ tailwindVersion,
14870
+ projectRoot
14871
+ });
14872
+ const elapsed = Math.round(performance.now() - start);
14873
+ const ext = extname(path).toLowerCase();
14874
+ const framework = EXT_TO_FRAMEWORK[ext] ?? "react";
14875
+ const content = results.flatMap((r) => [
14876
+ {
14877
+ type: "image",
14878
+ data: r.image,
14879
+ mimeType: "image/png"
14880
+ },
14881
+ {
14882
+ type: "text",
14883
+ text: `[${mode}/${framework}] ${r.width}x${r.height} (${elapsed}ms)${r.consoleErrors.length ? `
14908
14884
  \u26A0\uFE0F Console errors:
14909
14885
  ${r.consoleErrors.join("\n")}` : ""}`
14910
- }
14911
- ]);
14912
- return { content };
14913
- } catch (error51) {
14914
- const msg = error51 instanceof Error ? error51.message : String(error51);
14915
- return {
14916
- content: [{ type: "text", text: `Render failed: ${msg}` }],
14917
- isError: true
14918
- };
14886
+ }
14887
+ ]);
14888
+ return { content };
14889
+ } catch (error51) {
14890
+ const msg = error51 instanceof Error ? error51.message : String(error51);
14891
+ return {
14892
+ content: [
14893
+ { type: "text", text: `File render failed: ${msg}` }
14894
+ ],
14895
+ isError: true
14896
+ };
14897
+ }
14919
14898
  }
14920
- }
14921
- );
14922
- server.tool(
14923
- "screenshot_url",
14924
- "Take a screenshot of a URL (e.g. localhost:3000) across multiple browser engines in parallel.",
14925
- {
14926
- url: external_exports.string().url().describe("URL to screenshot"),
14927
- engines: external_exports.array(external_exports.enum(["chromium", "firefox", "webkit"])).optional().default(["chromium"]).describe("Browser engines"),
14928
- width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14929
- height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
14930
- fullPage: external_exports.boolean().optional().default(true).describe("Capture full scroll height")
14931
- },
14932
- async ({ url: url2, engines, width, height, fullPage }) => {
14933
- try {
14934
- const results = await screenshotUrl(url2, {
14935
- width,
14936
- height,
14937
- fullPage,
14938
- engines
14939
- });
14940
- const content = results.flatMap((r) => [
14941
- {
14942
- type: "image",
14943
- data: r.image,
14944
- mimeType: "image/png"
14945
- },
14946
- {
14947
- type: "text",
14948
- text: `[${r.engine}] ${r.width}x${r.height}${r.consoleErrors.length ? `
14899
+ );
14900
+ server2.tool(
14901
+ "render_component",
14902
+ "Instantly render a React/Vue/HTML component and return screenshots across browser engines. Zero setup needed \u2014 just pass your code. Tailwind CSS is built-in. Use this to visually verify UI code without starting a dev server.",
14903
+ {
14904
+ code: external_exports.string().describe("Component code to render"),
14905
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14906
+ engines: external_exports.array(external_exports.enum(["chromium", "firefox", "webkit"])).optional().default(["chromium"]).describe(
14907
+ 'Browser engines to render in. Use ["chromium","firefox","webkit"] for cross-browser check.'
14908
+ ),
14909
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
14910
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
14911
+ fullPage: external_exports.boolean().optional().default(true).describe("Capture full scroll height"),
14912
+ darkMode: external_exports.boolean().optional().default(false).describe("Render with Tailwind dark mode (adds 'dark' class to html)"),
14913
+ colorSchemes: external_exports.array(external_exports.enum(["light", "dark"])).optional().describe(
14914
+ 'Render both: ["light","dark"] returns 2 screenshots for comparison'
14915
+ ),
14916
+ css: external_exports.string().optional().describe("Custom CSS to inject (design tokens, variables, etc)"),
14917
+ tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)")
14918
+ },
14919
+ async ({
14920
+ code,
14921
+ framework,
14922
+ engines,
14923
+ width,
14924
+ height,
14925
+ fullPage,
14926
+ darkMode,
14927
+ colorSchemes,
14928
+ css,
14929
+ tailwindVersion
14930
+ }) => {
14931
+ try {
14932
+ const start = performance.now();
14933
+ const schemes = colorSchemes ?? (darkMode ? ["dark"] : ["light"]);
14934
+ const allResults = await Promise.all(
14935
+ schemes.map(
14936
+ (scheme) => useCase.render(code, framework, {
14937
+ viewport: { width, height },
14938
+ fullPage,
14939
+ engines,
14940
+ darkMode: scheme === "dark",
14941
+ css,
14942
+ tailwindVersion
14943
+ })
14944
+ )
14945
+ );
14946
+ const elapsed = Math.round(performance.now() - start);
14947
+ const results = allResults.flat();
14948
+ const content = results.flatMap((r) => [
14949
+ {
14950
+ type: "image",
14951
+ data: r.image,
14952
+ mimeType: "image/png"
14953
+ },
14954
+ {
14955
+ type: "text",
14956
+ text: `[${r.engine}] ${r.width}x${r.height} (${elapsed}ms)${r.consoleErrors.length ? `
14949
14957
  \u26A0\uFE0F Console errors:
14950
14958
  ${r.consoleErrors.join("\n")}` : ""}`
14951
- }
14952
- ]);
14953
- return { content };
14954
- } catch (error51) {
14955
- const msg = error51 instanceof Error ? error51.message : String(error51);
14956
- return {
14957
- content: [{ type: "text", text: `Screenshot failed: ${msg}` }],
14958
- isError: true
14959
- };
14959
+ }
14960
+ ]);
14961
+ return { content };
14962
+ } catch (error51) {
14963
+ const msg = error51 instanceof Error ? error51.message : String(error51);
14964
+ return {
14965
+ content: [{ type: "text", text: `Render failed: ${msg}` }],
14966
+ isError: true
14967
+ };
14968
+ }
14960
14969
  }
14961
- }
14962
- );
14963
- var DEVICE_PRESETS = {
14964
- mobile: { width: 375, height: 667 },
14965
- tablet: { width: 768, height: 1024 },
14966
- desktop: { width: 1280, height: 800 }
14967
- };
14968
- server.tool(
14969
- "render_responsive",
14970
- "Render a component at mobile, tablet, and desktop sizes in one call. Returns 3 screenshots for responsive verification.",
14971
- {
14972
- code: external_exports.string().describe("Component code to render"),
14973
- framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14974
- devices: external_exports.array(external_exports.enum(["mobile", "tablet", "desktop"])).optional().default(["mobile", "tablet", "desktop"]).describe("Device sizes to render")
14975
- },
14976
- async ({ code, framework, devices }) => {
14977
- try {
14978
- const results = await Promise.all(
14979
- devices.map(async (device) => {
14980
- const preset = DEVICE_PRESETS[device];
14981
- const [result] = await render(code, framework, {
14982
- width: preset.width,
14983
- height: preset.height,
14984
- fullPage: true,
14985
- engines: ["chromium"]
14986
- });
14987
- return { device, ...result };
14988
- })
14989
- );
14990
- const content = results.flatMap((r) => [
14991
- {
14992
- type: "image",
14993
- data: r.image,
14994
- mimeType: "image/png"
14995
- },
14996
- {
14997
- type: "text",
14998
- text: `[${r.device}] ${r.width}x${r.height}`
14999
- }
15000
- ]);
15001
- return { content };
15002
- } catch (error51) {
15003
- const msg = error51 instanceof Error ? error51.message : String(error51);
15004
- return {
15005
- content: [
15006
- { type: "text", text: `Responsive render failed: ${msg}` }
15007
- ],
15008
- isError: true
15009
- };
14970
+ );
14971
+ server2.tool(
14972
+ "render_responsive",
14973
+ "Render a component at mobile, tablet, and desktop sizes in one call. Returns 3 screenshots for responsive verification.",
14974
+ {
14975
+ code: external_exports.string().describe("Component code to render"),
14976
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
14977
+ devices: external_exports.array(external_exports.enum(["mobile", "tablet", "desktop"])).optional().default(["mobile", "tablet", "desktop"]).describe("Device sizes to render")
14978
+ },
14979
+ async ({ code, framework, devices }) => {
14980
+ try {
14981
+ const results = await Promise.all(
14982
+ devices.map(async (device) => {
14983
+ const preset = DEVICE_PRESETS[device];
14984
+ const [result] = await useCase.render(code, framework, {
14985
+ viewport: { width: preset.width, height: preset.height },
14986
+ fullPage: true,
14987
+ engines: ["chromium"]
14988
+ });
14989
+ return { device, ...result };
14990
+ })
14991
+ );
14992
+ const content = results.flatMap((r) => [
14993
+ {
14994
+ type: "image",
14995
+ data: r.image,
14996
+ mimeType: "image/png"
14997
+ },
14998
+ {
14999
+ type: "text",
15000
+ text: `[${r.device}] ${r.width}x${r.height}`
15001
+ }
15002
+ ]);
15003
+ return { content };
15004
+ } catch (error51) {
15005
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15006
+ return {
15007
+ content: [
15008
+ {
15009
+ type: "text",
15010
+ text: `Responsive render failed: ${msg}`
15011
+ }
15012
+ ],
15013
+ isError: true
15014
+ };
15015
+ }
15010
15016
  }
15011
- }
15012
- );
15013
- server.tool(
15014
- "render_variants",
15015
- "Render multiple variants of a component (different props/states) in one call. Returns a screenshot for each variant. Use this to verify buttons in all states, theme variations, etc.",
15016
- {
15017
- code: external_exports.string().describe("Component code (must export a function that accepts props)"),
15018
- variants: external_exports.array(
15019
- external_exports.object({
15020
- label: external_exports.string().describe("Label for this variant (e.g. 'disabled', 'loading')"),
15021
- props: external_exports.string().describe("Props as JSON string to pass to the component")
15022
- })
15023
- ).describe("Array of variants to render"),
15024
- framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15025
- width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15026
- height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15027
- },
15028
- async ({ code, variants, framework, width, height }) => {
15029
- try {
15030
- const results = await Promise.all(
15031
- variants.map(async (variant) => {
15032
- const wrappedCode = framework === "react" ? `${code}
15017
+ );
15018
+ server2.tool(
15019
+ "render_variants",
15020
+ "Render multiple variants of a component (different props/states) in one call. Returns a screenshot for each variant. Use this to verify buttons in all states, theme variations, etc.",
15021
+ {
15022
+ code: external_exports.string().describe("Component code (must export a function that accepts props)"),
15023
+ variants: external_exports.array(
15024
+ external_exports.object({
15025
+ label: external_exports.string().describe("Label for this variant (e.g. 'disabled', 'loading')"),
15026
+ props: external_exports.string().describe("Props as JSON string to pass to the component")
15027
+ })
15028
+ ).describe("Array of variants to render"),
15029
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15030
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15031
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15032
+ },
15033
+ async ({ code, variants, framework, width, height }) => {
15034
+ try {
15035
+ const results = await Promise.all(
15036
+ variants.map(async (variant) => {
15037
+ const wrappedCode = framework === "react" ? `${code}
15033
15038
  const _VARIANT_PROPS = ${variant.props};
15034
15039
  function _VariantWrapper() { return <App {..._VARIANT_PROPS} />; }` : code;
15035
- const renderFramework = framework;
15036
- const overrideCode = framework === "react" ? wrappedCode.replace(
15037
- /const _C = typeof App/,
15038
- "const _C = typeof _VariantWrapper!=='undefined'?_VariantWrapper:typeof App"
15039
- ) : wrappedCode;
15040
- const [result] = await render(overrideCode, renderFramework, {
15041
- width,
15042
- height,
15043
- fullPage: true,
15044
- engines: ["chromium"]
15045
- });
15046
- return { label: variant.label, ...result };
15040
+ const renderFramework = framework;
15041
+ const overrideCode = framework === "react" ? wrappedCode.replace(
15042
+ /const _C = typeof App/,
15043
+ "const _C = typeof _VariantWrapper!=='undefined'?_VariantWrapper:typeof App"
15044
+ ) : wrappedCode;
15045
+ const [result] = await useCase.render(
15046
+ overrideCode,
15047
+ renderFramework,
15048
+ {
15049
+ viewport: { width, height },
15050
+ fullPage: true,
15051
+ engines: ["chromium"]
15052
+ }
15053
+ );
15054
+ return { label: variant.label, ...result };
15055
+ })
15056
+ );
15057
+ const content = results.flatMap((r) => [
15058
+ {
15059
+ type: "image",
15060
+ data: r.image,
15061
+ mimeType: "image/png"
15062
+ },
15063
+ {
15064
+ type: "text",
15065
+ text: `[${r.label}] ${r.width}x${r.height}${r.consoleErrors.length ? `
15066
+ \u26A0\uFE0F Console errors:
15067
+ ${r.consoleErrors.join("\n")}` : ""}`
15068
+ }
15069
+ ]);
15070
+ return { content };
15071
+ } catch (error51) {
15072
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15073
+ return {
15074
+ content: [
15075
+ {
15076
+ type: "text",
15077
+ text: `Variants render failed: ${msg}`
15078
+ }
15079
+ ],
15080
+ isError: true
15081
+ };
15082
+ }
15083
+ }
15084
+ );
15085
+ server2.tool(
15086
+ "render_interaction",
15087
+ "Render a component, simulate user interactions (click, hover, focus, type), then screenshot the result. Use this to verify hover states, dropdowns, modals, form inputs.",
15088
+ {
15089
+ code: external_exports.string().describe("Component code to render"),
15090
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework"),
15091
+ interactions: external_exports.array(
15092
+ external_exports.object({
15093
+ action: external_exports.enum(["click", "hover", "focus", "type", "wait"]).describe("Interaction type"),
15094
+ selector: external_exports.string().optional().describe("CSS selector for the target element"),
15095
+ value: external_exports.string().optional().describe("Text to type (for 'type' action)"),
15096
+ ms: external_exports.number().optional().describe("Wait duration in ms (for 'wait' action)")
15047
15097
  })
15048
- );
15049
- const content = results.flatMap((r) => [
15050
- {
15051
- type: "image",
15052
- data: r.image,
15053
- mimeType: "image/png"
15054
- },
15055
- {
15098
+ ).describe("Sequence of interactions to perform"),
15099
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15100
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15101
+ },
15102
+ async ({ code, framework, interactions, width, height }) => {
15103
+ try {
15104
+ const result = await useCase.renderInteraction(
15105
+ code,
15106
+ framework,
15107
+ interactions,
15108
+ {
15109
+ viewport: { width, height }
15110
+ }
15111
+ );
15112
+ const content = [
15113
+ {
15114
+ type: "image",
15115
+ data: result.image,
15116
+ mimeType: "image/png"
15117
+ },
15118
+ {
15119
+ type: "text",
15120
+ text: `After ${interactions.length} interaction(s) \u2014 ${result.width}x${result.height}`
15121
+ }
15122
+ ];
15123
+ return { content };
15124
+ } catch (error51) {
15125
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15126
+ return {
15127
+ content: [
15128
+ {
15129
+ type: "text",
15130
+ text: `Interaction render failed: ${msg}`
15131
+ }
15132
+ ],
15133
+ isError: true
15134
+ };
15135
+ }
15136
+ }
15137
+ );
15138
+ server2.tool(
15139
+ "render_grid",
15140
+ "Render multiple code snippets in a grid layout and return a single combined image. Use this to compare components side-by-side (e.g. design system overview, theme comparison).",
15141
+ {
15142
+ cells: external_exports.array(
15143
+ external_exports.object({
15144
+ label: external_exports.string().describe("Label for this cell"),
15145
+ code: external_exports.string().describe("Component code for this cell")
15146
+ })
15147
+ ).describe("Array of cells to render in the grid"),
15148
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework"),
15149
+ columns: external_exports.number().optional().default(3).describe("Number of columns in the grid"),
15150
+ cellWidth: external_exports.number().optional().default(400).describe("Width of each cell (px)"),
15151
+ cellHeight: external_exports.number().optional().default(300).describe("Height of each cell (px)")
15152
+ },
15153
+ async ({ cells, framework, columns, cellWidth, cellHeight }) => {
15154
+ try {
15155
+ const result = await useCase.renderGrid(cells, framework, {
15156
+ columns,
15157
+ viewport: { width: cellWidth, height: cellHeight }
15158
+ });
15159
+ const content = [
15160
+ {
15161
+ type: "image",
15162
+ data: result.image,
15163
+ mimeType: "image/png"
15164
+ },
15165
+ {
15166
+ type: "text",
15167
+ text: `Grid: ${result.cells} cells, ${columns} columns \u2014 ${result.width}x${result.height}`
15168
+ }
15169
+ ];
15170
+ return { content };
15171
+ } catch (error51) {
15172
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15173
+ return {
15174
+ content: [
15175
+ { type: "text", text: `Grid render failed: ${msg}` }
15176
+ ],
15177
+ isError: true
15178
+ };
15179
+ }
15180
+ }
15181
+ );
15182
+ server2.tool(
15183
+ "render_theme",
15184
+ "Render a component in both light and dark mode side-by-side. Returns 2 labeled screenshots for quick theme verification.",
15185
+ {
15186
+ code: external_exports.string().describe("Component code to render"),
15187
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework"),
15188
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15189
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15190
+ },
15191
+ async ({ code, framework, width, height }) => {
15192
+ try {
15193
+ const start = performance.now();
15194
+ const [lightResults, darkResults] = await Promise.all([
15195
+ useCase.render(code, framework, {
15196
+ viewport: { width, height },
15197
+ fullPage: true,
15198
+ engines: ["chromium"],
15199
+ darkMode: false
15200
+ }),
15201
+ useCase.render(code, framework, {
15202
+ viewport: { width, height },
15203
+ fullPage: true,
15204
+ engines: ["chromium"],
15205
+ darkMode: true
15206
+ })
15207
+ ]);
15208
+ const elapsed = Math.round(performance.now() - start);
15209
+ const content = [
15210
+ { type: "text", text: "\u2600\uFE0F Light mode:" },
15211
+ {
15212
+ type: "image",
15213
+ data: lightResults[0].image,
15214
+ mimeType: "image/png"
15215
+ },
15216
+ { type: "text", text: "\u{1F319} Dark mode:" },
15217
+ {
15218
+ type: "image",
15219
+ data: darkResults[0].image,
15220
+ mimeType: "image/png"
15221
+ },
15222
+ {
15223
+ type: "text",
15224
+ text: `${lightResults[0].width}x${lightResults[0].height} (${elapsed}ms)`
15225
+ }
15226
+ ];
15227
+ return { content };
15228
+ } catch (error51) {
15229
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15230
+ return {
15231
+ content: [
15232
+ { type: "text", text: `Theme render failed: ${msg}` }
15233
+ ],
15234
+ isError: true
15235
+ };
15236
+ }
15237
+ }
15238
+ );
15239
+ server2.tool(
15240
+ "render_matrix",
15241
+ "Render a component across multiple viewports AND themes in one call. Returns a grid of screenshots (viewports x themes) for comprehensive responsive + theme verification.",
15242
+ {
15243
+ code: external_exports.string().describe("Component code to render"),
15244
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15245
+ viewports: external_exports.array(
15246
+ external_exports.union([
15247
+ external_exports.enum(["mobile", "tablet", "desktop"]),
15248
+ external_exports.object({
15249
+ width: external_exports.number().describe("Viewport width (px)"),
15250
+ height: external_exports.number().describe("Viewport height (px)")
15251
+ })
15252
+ ])
15253
+ ).default(["mobile", "tablet", "desktop"]).describe(
15254
+ "Viewports to render: preset names ('mobile','tablet','desktop') or custom {width, height}"
15255
+ ),
15256
+ themes: external_exports.array(external_exports.enum(["light", "dark"])).default(["light", "dark"]).describe("Themes to render for each viewport"),
15257
+ css: external_exports.string().optional().describe("Custom CSS to inject (design tokens, variables, etc)"),
15258
+ tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)")
15259
+ },
15260
+ async ({ code, framework, viewports, themes, css, tailwindVersion }) => {
15261
+ try {
15262
+ const start = performance.now();
15263
+ const resolvedViewports = viewports.map((v) => {
15264
+ if (typeof v === "string") {
15265
+ const preset = DEVICE_PRESETS[v];
15266
+ return { label: v, width: preset.width, height: preset.height };
15267
+ }
15268
+ return {
15269
+ label: `${v.width}x${v.height}`,
15270
+ width: v.width,
15271
+ height: v.height
15272
+ };
15273
+ });
15274
+ const results = await useCase.renderMatrix(
15275
+ code,
15276
+ framework,
15277
+ resolvedViewports,
15278
+ themes,
15279
+ {
15280
+ css,
15281
+ tailwindVersion
15282
+ }
15283
+ );
15284
+ const elapsed = Math.round(performance.now() - start);
15285
+ const content = results.flatMap((r) => [
15286
+ {
15287
+ type: "image",
15288
+ data: r.image,
15289
+ mimeType: "image/png"
15290
+ },
15291
+ {
15292
+ type: "text",
15293
+ text: `[${r.viewport}/${r.theme}] ${r.width}x${r.height}${r.consoleErrors.length ? `
15294
+ \u26A0\uFE0F Console errors:
15295
+ ${r.consoleErrors.join("\n")}` : ""}`
15296
+ }
15297
+ ]);
15298
+ content.push({
15056
15299
  type: "text",
15057
- text: `[${r.label}] ${r.width}x${r.height}${r.consoleErrors.length ? `
15300
+ text: `Matrix: ${resolvedViewports.length} viewports x ${themes.length} themes = ${results.length} renders (${elapsed}ms)`
15301
+ });
15302
+ return { content };
15303
+ } catch (error51) {
15304
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15305
+ return {
15306
+ content: [
15307
+ { type: "text", text: `Matrix render failed: ${msg}` }
15308
+ ],
15309
+ isError: true
15310
+ };
15311
+ }
15312
+ }
15313
+ );
15314
+ server2.tool(
15315
+ "capture_animation",
15316
+ "Capture multiple frames of a CSS animation or transition over time. Returns sequential screenshots to verify animation behavior, timing, and smoothness.",
15317
+ {
15318
+ code: external_exports.string().describe("Component code with CSS animations/transitions"),
15319
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15320
+ frames: external_exports.number().optional().default(5).describe("Number of frames to capture"),
15321
+ duration: external_exports.number().optional().default(1e3).describe("Total capture duration in ms"),
15322
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15323
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15324
+ },
15325
+ async ({ code, framework, frames, duration: duration3, width, height }) => {
15326
+ try {
15327
+ const results = await useCase.captureAnimation(code, framework, {
15328
+ frames,
15329
+ duration: duration3,
15330
+ viewport: { width, height }
15331
+ });
15332
+ const content = results.flatMap((r) => [
15333
+ {
15334
+ type: "image",
15335
+ data: r.image,
15336
+ mimeType: "image/png"
15337
+ },
15338
+ {
15339
+ type: "text",
15340
+ text: `[${r.timestamp}ms]`
15341
+ }
15342
+ ]);
15343
+ return { content };
15344
+ } catch (error51) {
15345
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15346
+ return {
15347
+ content: [
15348
+ {
15349
+ type: "text",
15350
+ text: `Animation capture failed: ${msg}`
15351
+ }
15352
+ ],
15353
+ isError: true
15354
+ };
15355
+ }
15356
+ }
15357
+ );
15358
+ }
15359
+
15360
+ // src/tools/screenshot-tools.ts
15361
+ function registerScreenshotTools(server2, useCase) {
15362
+ server2.tool(
15363
+ "screenshot_url",
15364
+ "Take a screenshot of a URL (e.g. localhost:3000) across multiple browser engines in parallel. Supports waiting for specific elements and retry with exponential backoff for dev servers that may be slow to start.",
15365
+ {
15366
+ url: external_exports.string().url().describe("URL to screenshot"),
15367
+ engines: external_exports.array(external_exports.enum(["chromium", "firefox", "webkit"])).optional().default(["chromium"]).describe("Browser engines"),
15368
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15369
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
15370
+ fullPage: external_exports.boolean().optional().default(true).describe("Capture full scroll height"),
15371
+ waitForSelector: external_exports.string().optional().describe(
15372
+ "Wait for a specific CSS selector to appear before capturing (e.g. '#app', '.loaded')"
15373
+ ),
15374
+ waitForNetworkIdle: external_exports.boolean().optional().default(true).describe("Wait for network to settle before capturing"),
15375
+ retryOnError: external_exports.number().optional().default(0).describe(
15376
+ "Number of retries with exponential backoff (300ms, 600ms, 1200ms...) if the page fails to load"
15377
+ )
15378
+ },
15379
+ async ({
15380
+ url: url2,
15381
+ engines,
15382
+ width,
15383
+ height,
15384
+ fullPage,
15385
+ waitForSelector,
15386
+ waitForNetworkIdle,
15387
+ retryOnError
15388
+ }) => {
15389
+ try {
15390
+ const results = await useCase.screenshotUrlWithRetry(url2, {
15391
+ viewport: { width, height },
15392
+ engines,
15393
+ fullPage,
15394
+ waitForSelector,
15395
+ waitForNetworkIdle,
15396
+ retryCount: retryOnError
15397
+ });
15398
+ const content = results.flatMap((r) => [
15399
+ {
15400
+ type: "image",
15401
+ data: r.image,
15402
+ mimeType: "image/png"
15403
+ },
15404
+ {
15405
+ type: "text",
15406
+ text: `[${r.engine}] ${r.width}x${r.height}${r.consoleErrors.length ? `
15058
15407
  \u26A0\uFE0F Console errors:
15059
15408
  ${r.consoleErrors.join("\n")}` : ""}`
15409
+ }
15410
+ ]);
15411
+ return { content };
15412
+ } catch (error51) {
15413
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15414
+ const retryInfo = retryOnError > 0 ? ` (after ${retryOnError + 1} attempts)` : "";
15415
+ return {
15416
+ content: [
15417
+ {
15418
+ type: "text",
15419
+ text: `Screenshot failed${retryInfo}: ${msg}`
15420
+ }
15421
+ ],
15422
+ isError: true
15423
+ };
15424
+ }
15425
+ }
15426
+ );
15427
+ }
15428
+
15429
+ // src/tools/snapshot-tools.ts
15430
+ function registerSnapshotTools(server2, useCase) {
15431
+ server2.tool(
15432
+ "snapshot_save",
15433
+ "Render a component and save the screenshot as a named snapshot. Use this to establish a baseline before making changes. Later use snapshot_check to detect visual regressions.",
15434
+ {
15435
+ name: external_exports.string().describe(
15436
+ "Unique name for this snapshot (e.g. 'header-desktop', 'button-hover')"
15437
+ ),
15438
+ code: external_exports.string().describe("Component code to render and snapshot"),
15439
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework"),
15440
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15441
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
15442
+ darkMode: external_exports.boolean().optional().default(false).describe("Render with dark mode"),
15443
+ tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)")
15444
+ },
15445
+ async ({
15446
+ name,
15447
+ code,
15448
+ framework,
15449
+ width,
15450
+ height,
15451
+ darkMode,
15452
+ tailwindVersion
15453
+ }) => {
15454
+ try {
15455
+ const result = await useCase.save(name, code, framework, {
15456
+ viewport: { width, height },
15457
+ darkMode,
15458
+ tailwindVersion
15459
+ });
15460
+ return {
15461
+ content: [
15462
+ {
15463
+ type: "image",
15464
+ data: result.image,
15465
+ mimeType: "image/png"
15466
+ },
15467
+ {
15468
+ type: "text",
15469
+ text: `Snapshot "${name}" saved (${result.width}x${result.height})`
15470
+ }
15471
+ ]
15472
+ };
15473
+ } catch (error51) {
15474
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15475
+ return {
15476
+ content: [
15477
+ { type: "text", text: `Snapshot save failed: ${msg}` }
15478
+ ],
15479
+ isError: true
15480
+ };
15481
+ }
15482
+ }
15483
+ );
15484
+ server2.tool(
15485
+ "snapshot_check",
15486
+ "Render a component and compare against a previously saved snapshot. Returns pixel diff and pass/fail. Use this after making changes to verify no visual regression.",
15487
+ {
15488
+ name: external_exports.string().describe("Name of the snapshot to compare against"),
15489
+ code: external_exports.string().describe("Current component code to render and compare"),
15490
+ framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework"),
15491
+ width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15492
+ height: external_exports.number().optional().default(800).describe("Viewport height (px)"),
15493
+ darkMode: external_exports.boolean().optional().default(false).describe("Render with dark mode"),
15494
+ tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)")
15495
+ },
15496
+ async ({
15497
+ name,
15498
+ code,
15499
+ framework,
15500
+ width,
15501
+ height,
15502
+ darkMode,
15503
+ tailwindVersion
15504
+ }) => {
15505
+ try {
15506
+ const result = await useCase.check(name, code, framework, {
15507
+ viewport: { width, height },
15508
+ darkMode,
15509
+ tailwindVersion
15510
+ });
15511
+ if (!result) {
15512
+ return {
15513
+ content: [
15514
+ {
15515
+ type: "text",
15516
+ text: `No snapshot found with name "${name}". Use snapshot_save first.`
15517
+ }
15518
+ ],
15519
+ isError: true
15520
+ };
15060
15521
  }
15061
- ]);
15062
- return { content };
15063
- } catch (error51) {
15064
- const msg = error51 instanceof Error ? error51.message : String(error51);
15065
- return {
15066
- content: [
15067
- { type: "text", text: `Variants render failed: ${msg}` }
15068
- ],
15069
- isError: true
15070
- };
15522
+ const status = result.passed ? "PASS" : "FAIL";
15523
+ const content = [
15524
+ {
15525
+ type: "text",
15526
+ text: `${status}: ${result.diffPercentage}% pixels differ vs snapshot "${name}"`
15527
+ },
15528
+ {
15529
+ type: "image",
15530
+ data: result.rendered,
15531
+ mimeType: "image/png"
15532
+ },
15533
+ { type: "text", text: "Diff:" },
15534
+ {
15535
+ type: "image",
15536
+ data: result.diff,
15537
+ mimeType: "image/png"
15538
+ }
15539
+ ];
15540
+ return { content };
15541
+ } catch (error51) {
15542
+ const msg = error51 instanceof Error ? error51.message : String(error51);
15543
+ return {
15544
+ content: [
15545
+ { type: "text", text: `Snapshot check failed: ${msg}` }
15546
+ ],
15547
+ isError: true
15548
+ };
15549
+ }
15071
15550
  }
15072
- }
15073
- );
15074
- server.tool(
15075
- "audit_a11y",
15076
- "Run an accessibility audit (axe-core) on a rendered component. Returns WCAG violations with impact level, description, and affected HTML nodes. Use this to catch a11y issues before shipping.",
15077
- {
15078
- code: external_exports.string().describe("Component code to audit"),
15079
- framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15080
- width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15081
- height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15082
- },
15083
- async ({ code, framework, width, height }) => {
15084
- try {
15085
- const result = await auditA11y(code, framework, { width, height });
15086
- if (result.violations.length === 0) {
15551
+ );
15552
+ server2.tool(
15553
+ "snapshot_list",
15554
+ "List all saved snapshots with their names and timestamps.",
15555
+ {},
15556
+ async () => {
15557
+ const snapshots = useCase.list();
15558
+ if (snapshots.length === 0) {
15087
15559
  return {
15088
15560
  content: [
15089
15561
  {
15090
15562
  type: "text",
15091
- text: `\u2705 No accessibility violations found. (${result.passes} rules passed, ${result.incomplete} need review)`
15563
+ text: "No snapshots saved. Use snapshot_save to create one."
15092
15564
  }
15093
15565
  ]
15094
15566
  };
15095
15567
  }
15096
- const report = result.violations.map((v) => {
15097
- const nodes = v.nodes.slice(0, 3).map((n) => ` ${n.target.join(" > ")}
15098
- ${n.html}`).join("\n");
15099
- return `[${v.impact?.toUpperCase()}] ${v.id}
15100
- ${v.description}
15101
- ${v.helpUrl}
15102
- ${nodes}`;
15103
- }).join("\n\n");
15568
+ const lines = snapshots.map(
15569
+ (s) => `\u2022 ${s.key} (saved ${new Date(s.timestamp).toLocaleTimeString()})`
15570
+ );
15104
15571
  return {
15105
15572
  content: [
15106
15573
  {
15107
15574
  type: "text",
15108
- text: `Found ${result.violations.length} accessibility violation(s):
15109
-
15110
- ${report}
15111
-
15112
- (${result.passes} rules passed, ${result.incomplete} need review)`
15575
+ text: `Saved snapshots (${snapshots.length}):
15576
+ ${lines.join("\n")}`
15113
15577
  }
15114
15578
  ]
15115
15579
  };
15116
- } catch (error51) {
15117
- const msg = error51 instanceof Error ? error51.message : String(error51);
15118
- return {
15119
- content: [{ type: "text", text: `A11y audit failed: ${msg}` }],
15120
- isError: true
15121
- };
15122
15580
  }
15123
- }
15581
+ );
15582
+ }
15583
+
15584
+ // src/tools/register.ts
15585
+ function registerAllTools(server2, useCases) {
15586
+ registerRenderTools(server2, useCases.render);
15587
+ registerScreenshotTools(server2, useCases.screenshot);
15588
+ registerDiffTools(server2, useCases.diff);
15589
+ registerAuditTools(server2, useCases.audit);
15590
+ registerSnapshotTools(server2, useCases.snapshot);
15591
+ registerCatalogTools(server2, useCases.catalog);
15592
+ }
15593
+
15594
+ // src/index.ts
15595
+ var browserPool = new BrowserPool();
15596
+ var htmlBuilder = new HtmlBuilder();
15597
+ var imageComparator = new ImageComparator();
15598
+ var snapshotStore = new SnapshotStore();
15599
+ var viteBundler = new ViteBundler();
15600
+ var renderUseCase = new RenderUseCase(
15601
+ browserPool,
15602
+ htmlBuilder,
15603
+ imageComparator,
15604
+ viteBundler
15124
15605
  );
15125
- server.tool(
15126
- "diff_component",
15127
- "Visual regression test: render before/after code and return a pixel diff image with percentage changed. Use this during refactoring to catch unintended visual changes.",
15128
- {
15129
- before: external_exports.string().describe("Component code BEFORE the change"),
15130
- after: external_exports.string().describe("Component code AFTER the change"),
15131
- framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15132
- width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15133
- height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15134
- },
15135
- async ({ before, after, framework, width, height }) => {
15136
- try {
15137
- const result = await diffComponent(before, after, framework, {
15138
- width,
15139
- height
15140
- });
15141
- const content = [
15142
- {
15143
- type: "text",
15144
- text: `Visual diff: ${result.diffPercentage}% pixels changed (${result.diffPixels}/${result.totalPixels})`
15145
- },
15146
- { type: "text", text: "Before:" },
15147
- {
15148
- type: "image",
15149
- data: result.before,
15150
- mimeType: "image/png"
15151
- },
15152
- { type: "text", text: "After:" },
15153
- {
15154
- type: "image",
15155
- data: result.after,
15156
- mimeType: "image/png"
15157
- },
15158
- { type: "text", text: "Diff (red = changed pixels):" },
15159
- {
15160
- type: "image",
15161
- data: result.diff,
15162
- mimeType: "image/png"
15163
- }
15164
- ];
15165
- return { content };
15166
- } catch (error51) {
15167
- const msg = error51 instanceof Error ? error51.message : String(error51);
15168
- return {
15169
- content: [{ type: "text", text: `Diff failed: ${msg}` }],
15170
- isError: true
15171
- };
15172
- }
15173
- }
15606
+ var screenshotUseCase = new ScreenshotUseCase(browserPool);
15607
+ var diffUseCase = new DiffUseCase(renderUseCase, imageComparator);
15608
+ var auditUseCase = new AuditUseCase(browserPool, htmlBuilder);
15609
+ var snapshotUseCase = new SnapshotUseCase(
15610
+ snapshotStore,
15611
+ renderUseCase,
15612
+ diffUseCase
15174
15613
  );
15175
- server.tool(
15176
- "capture_animation",
15177
- "Capture multiple frames of a CSS animation or transition over time. Returns sequential screenshots to verify animation behavior, timing, and smoothness.",
15178
- {
15179
- code: external_exports.string().describe("Component code with CSS animations/transitions"),
15180
- framework: external_exports.enum(["html", "react", "vue", "svelte"]).default("react").describe("Framework: html, react, vue, or svelte"),
15181
- frames: external_exports.number().optional().default(5).describe("Number of frames to capture"),
15182
- duration: external_exports.number().optional().default(1e3).describe("Total capture duration in ms"),
15183
- width: external_exports.number().optional().default(1280).describe("Viewport width (px)"),
15184
- height: external_exports.number().optional().default(800).describe("Viewport height (px)")
15185
- },
15186
- async ({ code, framework, frames, duration: duration3, width, height }) => {
15614
+ var catalogUseCase = new CatalogUseCase(renderUseCase);
15615
+ async function ensureBrowser() {
15616
+ try {
15617
+ await browserPool.warmup(["chromium"]);
15618
+ } catch {
15187
15619
  try {
15188
- const results = await captureAnimation(code, framework, {
15189
- frames,
15190
- duration: duration3,
15191
- width,
15192
- height
15193
- });
15194
- const content = results.flatMap((r) => [
15195
- {
15196
- type: "image",
15197
- data: r.image,
15198
- mimeType: "image/png"
15199
- },
15200
- {
15201
- type: "text",
15202
- text: `[${r.timestamp}ms]`
15203
- }
15204
- ]);
15205
- return { content };
15206
- } catch (error51) {
15207
- const msg = error51 instanceof Error ? error51.message : String(error51);
15208
- return {
15209
- content: [
15210
- { type: "text", text: `Animation capture failed: ${msg}` }
15211
- ],
15212
- isError: true
15213
- };
15620
+ execSync("npx playwright install chromium", { stdio: "pipe" });
15621
+ await browserPool.warmup(["chromium"]);
15622
+ } catch {
15214
15623
  }
15215
15624
  }
15216
- );
15625
+ }
15626
+ await ensureBrowser();
15627
+ var server = new McpServer({
15628
+ name: "frameshot",
15629
+ version: "0.5.0"
15630
+ });
15631
+ registerAllTools(server, {
15632
+ render: renderUseCase,
15633
+ screenshot: screenshotUseCase,
15634
+ diff: diffUseCase,
15635
+ audit: auditUseCase,
15636
+ snapshot: snapshotUseCase,
15637
+ catalog: catalogUseCase
15638
+ });
15217
15639
  var transport = new StdioServerTransport();
15218
15640
  await server.connect(transport);
15219
15641
  process.on("SIGINT", async () => {
15220
- await shutdown();
15642
+ await viteBundler.shutdown();
15643
+ await browserPool.shutdown();
15221
15644
  process.exit(0);
15222
15645
  });
15223
15646
  process.on("SIGTERM", async () => {
15224
- await shutdown();
15647
+ await viteBundler.shutdown();
15648
+ await browserPool.shutdown();
15225
15649
  process.exit(0);
15226
15650
  });