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/README.md +157 -47
- package/action.yml +144 -0
- package/dist/chunk-3LVWVDET.js +849 -0
- package/dist/chunk-47YJG5HR.js +690 -0
- package/dist/chunk-67JZQ6OI.js +819 -0
- package/dist/chunk-AZCGKIMU.js +850 -0
- package/dist/chunk-B3CLIGWU.js +786 -0
- package/dist/chunk-C6QSY4WR.js +811 -0
- package/dist/chunk-DX54PJKO.js +603 -0
- package/dist/chunk-EMCJGIMY.js +984 -0
- package/dist/chunk-FQNWGR62.js +849 -0
- package/dist/chunk-FTYTZW6D.js +203 -0
- package/dist/chunk-JGVKYXY2.js +857 -0
- package/dist/chunk-JYPEA4P2.js +846 -0
- package/dist/chunk-KHK35HDD.js +855 -0
- package/dist/chunk-Q7A3DLED.js +848 -0
- package/dist/chunk-SIA6XEHM.js +811 -0
- package/dist/chunk-ST35YDI6.js +834 -0
- package/dist/chunk-T5OBJK35.js +855 -0
- package/dist/chunk-U3GHS7KO.js +837 -0
- package/dist/chunk-WS2ASCD6.js +683 -0
- package/dist/chunk-WZMHVSUA.js +847 -0
- package/dist/chunk-ZZST6K7Y.js +987 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +407 -0
- package/dist/index.js +1077 -653
- package/dist/renderer.d.ts +297 -0
- package/dist/renderer.js +34 -0
- package/package.json +32 -7
- package/scripts/render-changed.mjs +233 -0
- package/scripts/setup-labels.sh +48 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __export = (target, all) => {
|
|
3
|
+
for (var name in all)
|
|
4
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// src/infrastructure/browser-pool.ts
|
|
8
|
+
import { chromium, firefox, webkit } from "playwright";
|
|
9
|
+
var BrowserPool = class {
|
|
10
|
+
pool = /* @__PURE__ */ new Map();
|
|
11
|
+
async warmup(engines) {
|
|
12
|
+
await Promise.all(engines.map((e) => this.getSlot(e)));
|
|
13
|
+
}
|
|
14
|
+
async getPage(engine) {
|
|
15
|
+
const slot = await this.getSlot(engine);
|
|
16
|
+
return slot.page;
|
|
17
|
+
}
|
|
18
|
+
async setViewport(engine, viewport) {
|
|
19
|
+
const slot = await this.getSlot(engine);
|
|
20
|
+
const current = slot.page.viewportSize();
|
|
21
|
+
if (current?.width !== viewport.width || current?.height !== viewport.height) {
|
|
22
|
+
await slot.page.setViewportSize(viewport);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async shutdown() {
|
|
26
|
+
for (const slot of this.pool.values()) {
|
|
27
|
+
await slot.browser.close().catch(() => {
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
this.pool.clear();
|
|
31
|
+
}
|
|
32
|
+
async getSlot(engine) {
|
|
33
|
+
const existing = this.pool.get(engine);
|
|
34
|
+
if (existing?.browser.isConnected() && existing.ready) {
|
|
35
|
+
return existing;
|
|
36
|
+
}
|
|
37
|
+
const launcher = { chromium, firefox, webkit }[engine];
|
|
38
|
+
let browser;
|
|
39
|
+
try {
|
|
40
|
+
browser = await launcher.launch({
|
|
41
|
+
headless: true,
|
|
42
|
+
...engine === "chromium" ? { channel: "chrome" } : {}
|
|
43
|
+
});
|
|
44
|
+
} catch (_e) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`${engine} is not installed. Run: npx playwright install ${engine}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const context = await browser.newContext({
|
|
50
|
+
viewport: { width: 1280, height: 800 },
|
|
51
|
+
deviceScaleFactor: 2
|
|
52
|
+
});
|
|
53
|
+
const page = await context.newPage();
|
|
54
|
+
await page.setContent(
|
|
55
|
+
'<html><head><script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script></head><body></body></html>',
|
|
56
|
+
{ waitUntil: "networkidle" }
|
|
57
|
+
);
|
|
58
|
+
const slot = { browser, page, ready: true };
|
|
59
|
+
this.pool.set(engine, slot);
|
|
60
|
+
return slot;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// src/infrastructure/html-builder.ts
|
|
65
|
+
var HtmlBuilder = class {
|
|
66
|
+
build(code, framework, options) {
|
|
67
|
+
const { darkMode, css, tailwindVersion } = options;
|
|
68
|
+
const tailwind = tailwindVersion === "4" ? '<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>' : '<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>';
|
|
69
|
+
const tailwindConfig = tailwindVersion === "4" ? "" : "<script>tailwind.config={darkMode:'class'}</script>";
|
|
70
|
+
const customCss = css ? `<style>${css}</style>` : "";
|
|
71
|
+
const baseStyle = `<style>*{margin:0;box-sizing:border-box}body{padding:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}</style>`;
|
|
72
|
+
const htmlClass = darkMode ? ' class="dark"' : "";
|
|
73
|
+
if (framework === "html") {
|
|
74
|
+
if (code.includes("<html")) return code;
|
|
75
|
+
return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}${baseStyle}${customCss}</head><body>${code}</body></html>`;
|
|
76
|
+
}
|
|
77
|
+
if (framework === "react") {
|
|
78
|
+
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, "");
|
|
79
|
+
return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
|
|
80
|
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
|
81
|
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
|
82
|
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
83
|
+
${baseStyle}${customCss}</head><body><div id="root"></div>
|
|
84
|
+
<script type="text/babel">
|
|
85
|
+
const Image = (props) => React.createElement('img', {...props, src: props.src?.src || props.src});
|
|
86
|
+
const Link = ({href, children, ...props}) => React.createElement('a', {href, ...props}, children);
|
|
87
|
+
${cleanedCode}
|
|
88
|
+
const _C = typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:typeof Default!=='undefined'?Default:null;
|
|
89
|
+
if(_C)ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(_C));
|
|
90
|
+
</script></body></html>`;
|
|
91
|
+
}
|
|
92
|
+
if (framework === "vue") {
|
|
93
|
+
return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
|
|
94
|
+
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|
95
|
+
${baseStyle}${customCss}</head><body><div id="app"></div>
|
|
96
|
+
<script>
|
|
97
|
+
const{createApp,ref,reactive,computed,onMounted,watch,watchEffect}=Vue;
|
|
98
|
+
${code}
|
|
99
|
+
const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
|
|
100
|
+
if(_C)createApp(_C).mount('#app');
|
|
101
|
+
</script></body></html>`;
|
|
102
|
+
}
|
|
103
|
+
if (framework === "svelte") {
|
|
104
|
+
return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
|
|
105
|
+
<script src="https://unpkg.com/svelte@4/compiler.cjs"></script>
|
|
106
|
+
${baseStyle}${customCss}</head><body><div id="app"></div>
|
|
107
|
+
<script type="module">
|
|
108
|
+
import "https://unpkg.com/svelte@4/internal/index.mjs";
|
|
109
|
+
${code}
|
|
110
|
+
const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
|
|
111
|
+
if(_C)new _C({target:document.getElementById('app')});
|
|
112
|
+
</script></body></html>`;
|
|
113
|
+
}
|
|
114
|
+
return code;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// src/infrastructure/image-comparator.ts
|
|
119
|
+
import pixelmatch from "pixelmatch";
|
|
120
|
+
import { PNG } from "pngjs";
|
|
121
|
+
var ImageComparator = class {
|
|
122
|
+
diff(imageA, imageB, threshold = 0.1) {
|
|
123
|
+
const bufA = Buffer.from(imageA, "base64");
|
|
124
|
+
const bufB = Buffer.from(imageB, "base64");
|
|
125
|
+
const pngA = PNG.sync.read(bufA);
|
|
126
|
+
const pngB = PNG.sync.read(bufB);
|
|
127
|
+
const width = Math.max(pngA.width, pngB.width);
|
|
128
|
+
const height = Math.max(pngA.height, pngB.height);
|
|
129
|
+
const normalizedA = new PNG({ width, height });
|
|
130
|
+
const normalizedB = new PNG({ width, height });
|
|
131
|
+
PNG.bitblt(pngA, normalizedA, 0, 0, pngA.width, pngA.height, 0, 0);
|
|
132
|
+
PNG.bitblt(pngB, normalizedB, 0, 0, pngB.width, pngB.height, 0, 0);
|
|
133
|
+
const diffPng = new PNG({ width, height });
|
|
134
|
+
const diffPixels = pixelmatch(
|
|
135
|
+
normalizedA.data,
|
|
136
|
+
normalizedB.data,
|
|
137
|
+
diffPng.data,
|
|
138
|
+
width,
|
|
139
|
+
height,
|
|
140
|
+
{ threshold }
|
|
141
|
+
);
|
|
142
|
+
const totalPixels = width * height;
|
|
143
|
+
return {
|
|
144
|
+
diff: PNG.sync.write(diffPng).toString("base64"),
|
|
145
|
+
diffPixels,
|
|
146
|
+
totalPixels,
|
|
147
|
+
diffPercentage: Math.round(diffPixels / totalPixels * 1e4) / 100
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
composite(images, columns, labelHeight = 30) {
|
|
151
|
+
const pngs = images.map((buf) => PNG.sync.read(buf));
|
|
152
|
+
const cellWidth = Math.max(...pngs.map((p) => p.width));
|
|
153
|
+
const cellHeight = Math.max(...pngs.map((p) => p.height));
|
|
154
|
+
const rows = Math.ceil(pngs.length / columns);
|
|
155
|
+
const gridWidth = cellWidth * columns;
|
|
156
|
+
const gridHeight = (cellHeight + labelHeight) * rows;
|
|
157
|
+
const grid = new PNG({ width: gridWidth, height: gridHeight });
|
|
158
|
+
grid.data.fill(255);
|
|
159
|
+
for (let i = 0; i < pngs.length; i++) {
|
|
160
|
+
const col = i % columns;
|
|
161
|
+
const row = Math.floor(i / columns);
|
|
162
|
+
const x = col * cellWidth;
|
|
163
|
+
const y = row * (cellHeight + labelHeight) + labelHeight;
|
|
164
|
+
PNG.bitblt(pngs[i], grid, 0, 0, pngs[i].width, pngs[i].height, x, y);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
image: PNG.sync.write(grid).toString("base64"),
|
|
168
|
+
width: gridWidth,
|
|
169
|
+
height: gridHeight
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// src/infrastructure/snapshot-store.ts
|
|
175
|
+
var SnapshotStore = class {
|
|
176
|
+
store = /* @__PURE__ */ new Map();
|
|
177
|
+
save(key, image) {
|
|
178
|
+
this.store.set(key, { image, timestamp: Date.now() });
|
|
179
|
+
}
|
|
180
|
+
get(key) {
|
|
181
|
+
return this.store.get(key);
|
|
182
|
+
}
|
|
183
|
+
list() {
|
|
184
|
+
return Array.from(this.store.entries()).map(([key, val]) => ({
|
|
185
|
+
key,
|
|
186
|
+
timestamp: val.timestamp
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// src/use-cases/render.ts
|
|
192
|
+
var RenderUseCase = class {
|
|
193
|
+
constructor(pool, htmlBuilder, imageComparator) {
|
|
194
|
+
this.pool = pool;
|
|
195
|
+
this.htmlBuilder = htmlBuilder;
|
|
196
|
+
this.imageComparator = imageComparator;
|
|
197
|
+
}
|
|
198
|
+
pool;
|
|
199
|
+
htmlBuilder;
|
|
200
|
+
imageComparator;
|
|
201
|
+
async render(code, framework, options = {}) {
|
|
202
|
+
const opts = this.resolveOptions(options);
|
|
203
|
+
const html = this.htmlBuilder.build(code, framework, {
|
|
204
|
+
darkMode: opts.darkMode,
|
|
205
|
+
css: opts.css,
|
|
206
|
+
tailwindVersion: opts.tailwindVersion
|
|
207
|
+
});
|
|
208
|
+
return Promise.all(
|
|
209
|
+
opts.engines.map((engine) => this.renderHtml(engine, html, opts))
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
async renderInteraction(code, framework, interactions, options = {}) {
|
|
213
|
+
const opts = this.resolveOptions(options);
|
|
214
|
+
const html = this.htmlBuilder.build(code, framework, {
|
|
215
|
+
darkMode: opts.darkMode,
|
|
216
|
+
css: opts.css,
|
|
217
|
+
tailwindVersion: opts.tailwindVersion
|
|
218
|
+
});
|
|
219
|
+
const page = await this.pool.getPage("chromium");
|
|
220
|
+
await this.pool.setViewport("chromium", opts.viewport);
|
|
221
|
+
await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
|
|
222
|
+
for (const interaction of interactions) {
|
|
223
|
+
switch (interaction.action) {
|
|
224
|
+
case "click":
|
|
225
|
+
if (interaction.selector) await page.click(interaction.selector);
|
|
226
|
+
break;
|
|
227
|
+
case "hover":
|
|
228
|
+
if (interaction.selector) await page.hover(interaction.selector);
|
|
229
|
+
break;
|
|
230
|
+
case "focus":
|
|
231
|
+
if (interaction.selector) await page.focus(interaction.selector);
|
|
232
|
+
break;
|
|
233
|
+
case "type":
|
|
234
|
+
if (interaction.selector && interaction.value)
|
|
235
|
+
await page.fill(interaction.selector, interaction.value);
|
|
236
|
+
break;
|
|
237
|
+
case "wait":
|
|
238
|
+
await page.waitForTimeout(interaction.ms ?? 300);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const screenshot = await page.screenshot({ type: "png", fullPage: true });
|
|
243
|
+
const metrics = await page.evaluate(() => ({
|
|
244
|
+
w: document.documentElement.scrollWidth,
|
|
245
|
+
h: document.documentElement.scrollHeight
|
|
246
|
+
}));
|
|
247
|
+
return {
|
|
248
|
+
image: screenshot.toString("base64"),
|
|
249
|
+
width: metrics.w,
|
|
250
|
+
height: metrics.h
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
async captureAnimation(code, framework, options = {}) {
|
|
254
|
+
const { frames = 5, duration = 1e3 } = options;
|
|
255
|
+
const opts = this.resolveOptions(options);
|
|
256
|
+
const html = this.htmlBuilder.build(code, framework, {
|
|
257
|
+
darkMode: opts.darkMode,
|
|
258
|
+
css: opts.css,
|
|
259
|
+
tailwindVersion: opts.tailwindVersion
|
|
260
|
+
});
|
|
261
|
+
const page = await this.pool.getPage("chromium");
|
|
262
|
+
await this.pool.setViewport("chromium", opts.viewport);
|
|
263
|
+
await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
|
|
264
|
+
const interval = duration / (frames - 1);
|
|
265
|
+
const results = [];
|
|
266
|
+
for (let i = 0; i < frames; i++) {
|
|
267
|
+
if (i > 0) await page.waitForTimeout(interval);
|
|
268
|
+
const screenshot = await page.screenshot({ type: "png", fullPage: false });
|
|
269
|
+
results.push({
|
|
270
|
+
timestamp: Math.round(i * interval),
|
|
271
|
+
image: screenshot.toString("base64")
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return results;
|
|
275
|
+
}
|
|
276
|
+
async renderGrid(cells, framework, options = {}) {
|
|
277
|
+
const { columns = Math.min(cells.length, 3) } = options;
|
|
278
|
+
const screenshots = await Promise.all(
|
|
279
|
+
cells.map(async (cell) => {
|
|
280
|
+
const [result] = await this.render(cell.code, framework, {
|
|
281
|
+
...options,
|
|
282
|
+
engines: ["chromium"]
|
|
283
|
+
});
|
|
284
|
+
return Buffer.from(result.image, "base64");
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
const composite = this.imageComparator.composite(screenshots, columns);
|
|
288
|
+
return { ...composite, cells: cells.length };
|
|
289
|
+
}
|
|
290
|
+
async renderMatrix(code, framework, viewports, themes, options = {}) {
|
|
291
|
+
const combinations = [];
|
|
292
|
+
for (const viewport of viewports) {
|
|
293
|
+
for (const theme of themes) {
|
|
294
|
+
combinations.push({ viewport, theme });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return Promise.all(
|
|
298
|
+
combinations.map(async ({ viewport, theme }) => {
|
|
299
|
+
const [result] = await this.render(code, framework, {
|
|
300
|
+
...options,
|
|
301
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
302
|
+
darkMode: theme === "dark",
|
|
303
|
+
engines: ["chromium"],
|
|
304
|
+
fullPage: true
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
viewport: viewport.label,
|
|
308
|
+
theme,
|
|
309
|
+
image: result.image,
|
|
310
|
+
width: result.width,
|
|
311
|
+
height: result.height,
|
|
312
|
+
consoleErrors: result.consoleErrors
|
|
313
|
+
};
|
|
314
|
+
})
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
async renderHtml(engine, html, options) {
|
|
318
|
+
const page = await this.pool.getPage(engine);
|
|
319
|
+
await this.pool.setViewport(engine, options.viewport);
|
|
320
|
+
const consoleErrors = [];
|
|
321
|
+
const onError = (msg) => {
|
|
322
|
+
if (msg.type() === "error") consoleErrors.push(msg.text());
|
|
323
|
+
};
|
|
324
|
+
page.on("console", onError);
|
|
325
|
+
page.on("pageerror", (err) => consoleErrors.push(err.message));
|
|
326
|
+
await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
|
|
327
|
+
if (options.waitFor > 0) {
|
|
328
|
+
await page.waitForTimeout(options.waitFor);
|
|
329
|
+
}
|
|
330
|
+
const screenshot = await page.screenshot({
|
|
331
|
+
type: "png",
|
|
332
|
+
fullPage: options.fullPage
|
|
333
|
+
});
|
|
334
|
+
const metrics = await page.evaluate(() => ({
|
|
335
|
+
w: document.documentElement.scrollWidth,
|
|
336
|
+
h: document.documentElement.scrollHeight
|
|
337
|
+
}));
|
|
338
|
+
page.removeListener("console", onError);
|
|
339
|
+
return {
|
|
340
|
+
engine,
|
|
341
|
+
image: screenshot.toString("base64"),
|
|
342
|
+
width: metrics.w,
|
|
343
|
+
height: metrics.h,
|
|
344
|
+
consoleErrors
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
resolveOptions(partial) {
|
|
348
|
+
return {
|
|
349
|
+
viewport: partial.viewport ?? { width: 1280, height: 800 },
|
|
350
|
+
engines: partial.engines ?? ["chromium"],
|
|
351
|
+
fullPage: partial.fullPage ?? true,
|
|
352
|
+
darkMode: partial.darkMode ?? false,
|
|
353
|
+
css: partial.css ?? "",
|
|
354
|
+
tailwindVersion: partial.tailwindVersion ?? "3",
|
|
355
|
+
waitFor: partial.waitFor ?? 0
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// src/use-cases/screenshot.ts
|
|
361
|
+
var ScreenshotUseCase = class {
|
|
362
|
+
constructor(pool) {
|
|
363
|
+
this.pool = pool;
|
|
364
|
+
}
|
|
365
|
+
pool;
|
|
366
|
+
async screenshotUrl(url, options = {}) {
|
|
367
|
+
const engines = options.engines ?? ["chromium"];
|
|
368
|
+
const { waitForNetworkIdle = true } = options;
|
|
369
|
+
return Promise.all(
|
|
370
|
+
engines.map(async (engine) => {
|
|
371
|
+
const { width = 1280, height = 800 } = options.viewport ?? {};
|
|
372
|
+
const fullPage = options.fullPage ?? true;
|
|
373
|
+
const waitFor = options.waitFor ?? 0;
|
|
374
|
+
const page = await this.pool.getPage(engine);
|
|
375
|
+
await this.pool.setViewport(engine, { width, height });
|
|
376
|
+
const consoleErrors = [];
|
|
377
|
+
const onError = (msg) => {
|
|
378
|
+
if (msg.type() === "error") consoleErrors.push(msg.text());
|
|
379
|
+
};
|
|
380
|
+
page.on("console", onError);
|
|
381
|
+
page.on("pageerror", (err) => consoleErrors.push(err.message));
|
|
382
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 });
|
|
383
|
+
if (waitForNetworkIdle) {
|
|
384
|
+
await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (options.waitForSelector) {
|
|
388
|
+
await page.waitForSelector(options.waitForSelector, {
|
|
389
|
+
timeout: 1e4
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
if (waitFor > 0) {
|
|
393
|
+
await page.waitForTimeout(waitFor);
|
|
394
|
+
}
|
|
395
|
+
const screenshot = await page.screenshot({ type: "png", fullPage });
|
|
396
|
+
const metrics = await page.evaluate(() => ({
|
|
397
|
+
w: document.documentElement.scrollWidth,
|
|
398
|
+
h: document.documentElement.scrollHeight
|
|
399
|
+
}));
|
|
400
|
+
page.removeListener("console", onError);
|
|
401
|
+
return {
|
|
402
|
+
engine,
|
|
403
|
+
image: screenshot.toString("base64"),
|
|
404
|
+
width: metrics.w,
|
|
405
|
+
height: metrics.h,
|
|
406
|
+
consoleErrors
|
|
407
|
+
};
|
|
408
|
+
})
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
async screenshotUrlWithRetry(url, options = {}) {
|
|
412
|
+
const { retryCount = 0, ...screenshotOpts } = options;
|
|
413
|
+
const maxAttempts = retryCount + 1;
|
|
414
|
+
let lastError;
|
|
415
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
416
|
+
try {
|
|
417
|
+
if (attempt > 0) {
|
|
418
|
+
const delay = 300 * 2 ** (attempt - 1);
|
|
419
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
420
|
+
}
|
|
421
|
+
return await this.screenshotUrl(url, screenshotOpts);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
lastError = error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
throw lastError;
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// src/use-cases/diff.ts
|
|
431
|
+
var DiffUseCase = class {
|
|
432
|
+
constructor(renderUseCase, imageComparator) {
|
|
433
|
+
this.renderUseCase = renderUseCase;
|
|
434
|
+
this.imageComparator = imageComparator;
|
|
435
|
+
}
|
|
436
|
+
renderUseCase;
|
|
437
|
+
imageComparator;
|
|
438
|
+
async diffComponent(before, after, framework, options = {}) {
|
|
439
|
+
const [beforeResults, afterResults] = await Promise.all([
|
|
440
|
+
this.renderUseCase.render(before, framework, {
|
|
441
|
+
...options,
|
|
442
|
+
engines: ["chromium"]
|
|
443
|
+
}),
|
|
444
|
+
this.renderUseCase.render(after, framework, {
|
|
445
|
+
...options,
|
|
446
|
+
engines: ["chromium"]
|
|
447
|
+
})
|
|
448
|
+
]);
|
|
449
|
+
const comparison = this.imageComparator.diff(
|
|
450
|
+
beforeResults[0].image,
|
|
451
|
+
afterResults[0].image
|
|
452
|
+
);
|
|
453
|
+
return {
|
|
454
|
+
before: beforeResults[0].image,
|
|
455
|
+
after: afterResults[0].image,
|
|
456
|
+
...comparison
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
async diffFromReference(code, framework, referenceImage, options = {}) {
|
|
460
|
+
const { threshold = 0.1, ...renderOptions } = options;
|
|
461
|
+
const [renderResult] = await this.renderUseCase.render(
|
|
462
|
+
code,
|
|
463
|
+
framework,
|
|
464
|
+
{ ...renderOptions, engines: ["chromium"] }
|
|
465
|
+
);
|
|
466
|
+
const comparison = this.imageComparator.diff(
|
|
467
|
+
referenceImage,
|
|
468
|
+
renderResult.image,
|
|
469
|
+
threshold
|
|
470
|
+
);
|
|
471
|
+
return {
|
|
472
|
+
rendered: renderResult.image,
|
|
473
|
+
...comparison,
|
|
474
|
+
passed: comparison.diffPercentage === 0
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// src/use-cases/audit.ts
|
|
480
|
+
var AuditUseCase = class {
|
|
481
|
+
constructor(pool, htmlBuilder) {
|
|
482
|
+
this.pool = pool;
|
|
483
|
+
this.htmlBuilder = htmlBuilder;
|
|
484
|
+
}
|
|
485
|
+
pool;
|
|
486
|
+
htmlBuilder;
|
|
487
|
+
async auditA11y(code, framework, options = {}) {
|
|
488
|
+
const html = this.htmlBuilder.build(code, framework, {
|
|
489
|
+
darkMode: options.darkMode ?? false,
|
|
490
|
+
css: options.css ?? "",
|
|
491
|
+
tailwindVersion: options.tailwindVersion ?? "3"
|
|
492
|
+
});
|
|
493
|
+
const page = await this.pool.getPage("chromium");
|
|
494
|
+
const viewport = options.viewport ?? { width: 1280, height: 800 };
|
|
495
|
+
await this.pool.setViewport("chromium", viewport);
|
|
496
|
+
await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
|
|
497
|
+
const axeSource = await import("axe-core").then((m) => m.source);
|
|
498
|
+
await page.addScriptTag({ content: axeSource });
|
|
499
|
+
const results = await page.evaluate(() => {
|
|
500
|
+
return window.axe.run();
|
|
501
|
+
});
|
|
502
|
+
return {
|
|
503
|
+
violations: results.violations.map(
|
|
504
|
+
(v) => ({
|
|
505
|
+
id: v.id,
|
|
506
|
+
impact: v.impact,
|
|
507
|
+
description: v.description,
|
|
508
|
+
helpUrl: v.helpUrl,
|
|
509
|
+
nodes: v.nodes.map((n) => ({
|
|
510
|
+
html: n.html,
|
|
511
|
+
target: n.target
|
|
512
|
+
}))
|
|
513
|
+
})
|
|
514
|
+
),
|
|
515
|
+
passes: results.passes.length,
|
|
516
|
+
incomplete: results.incomplete.length
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
async perfAudit(code, framework, options = {}) {
|
|
520
|
+
const html = this.htmlBuilder.build(code, framework, {
|
|
521
|
+
darkMode: options.darkMode ?? false,
|
|
522
|
+
css: options.css ?? "",
|
|
523
|
+
tailwindVersion: options.tailwindVersion ?? "3"
|
|
524
|
+
});
|
|
525
|
+
const page = await this.pool.getPage("chromium");
|
|
526
|
+
const viewport = options.viewport ?? { width: 1280, height: 800 };
|
|
527
|
+
await this.pool.setViewport("chromium", viewport);
|
|
528
|
+
const start = performance.now();
|
|
529
|
+
await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
|
|
530
|
+
const renderTimeMs = Math.round(performance.now() - start);
|
|
531
|
+
const metrics = await page.evaluate(() => {
|
|
532
|
+
const all = document.querySelectorAll("*");
|
|
533
|
+
let maxDepth = 0;
|
|
534
|
+
for (const el of all) {
|
|
535
|
+
let depth = 0;
|
|
536
|
+
let node = el;
|
|
537
|
+
while (node) {
|
|
538
|
+
depth++;
|
|
539
|
+
node = node.parentElement;
|
|
540
|
+
}
|
|
541
|
+
if (depth > maxDepth) maxDepth = depth;
|
|
542
|
+
}
|
|
543
|
+
return {
|
|
544
|
+
domElements: all.length,
|
|
545
|
+
domDepth: maxDepth,
|
|
546
|
+
scriptCount: document.querySelectorAll("script").length,
|
|
547
|
+
styleSheetCount: document.styleSheets.length,
|
|
548
|
+
imageCount: document.querySelectorAll("img").length,
|
|
549
|
+
totalDomSize: document.documentElement.outerHTML.length
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
return { renderTimeMs, ...metrics };
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// src/use-cases/snapshot.ts
|
|
557
|
+
var SnapshotUseCase = class {
|
|
558
|
+
constructor(store, renderUseCase, diffUseCase) {
|
|
559
|
+
this.store = store;
|
|
560
|
+
this.renderUseCase = renderUseCase;
|
|
561
|
+
this.diffUseCase = diffUseCase;
|
|
562
|
+
}
|
|
563
|
+
store;
|
|
564
|
+
renderUseCase;
|
|
565
|
+
diffUseCase;
|
|
566
|
+
async save(key, code, framework, options = {}) {
|
|
567
|
+
const [result] = await this.renderUseCase.render(code, framework, {
|
|
568
|
+
...options,
|
|
569
|
+
engines: ["chromium"]
|
|
570
|
+
});
|
|
571
|
+
this.store.save(key, result.image);
|
|
572
|
+
return { image: result.image, width: result.width, height: result.height };
|
|
573
|
+
}
|
|
574
|
+
async check(key, code, framework, options = {}) {
|
|
575
|
+
const snapshot = this.store.get(key);
|
|
576
|
+
if (!snapshot) return null;
|
|
577
|
+
return this.diffUseCase.diffFromReference(code, framework, snapshot.image, options);
|
|
578
|
+
}
|
|
579
|
+
list() {
|
|
580
|
+
return this.store.list();
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// src/domain/types.ts
|
|
585
|
+
var DEVICE_PRESETS = {
|
|
586
|
+
mobile: { width: 375, height: 667 },
|
|
587
|
+
tablet: { width: 768, height: 1024 },
|
|
588
|
+
desktop: { width: 1280, height: 800 }
|
|
589
|
+
};
|
|
590
|
+
var EXT_TO_FRAMEWORK = {
|
|
591
|
+
".jsx": "react",
|
|
592
|
+
".tsx": "react",
|
|
593
|
+
".vue": "vue",
|
|
594
|
+
".svelte": "svelte",
|
|
595
|
+
".html": "html",
|
|
596
|
+
".htm": "html"
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// src/use-cases/catalog.ts
|
|
600
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
601
|
+
import { extname, join, relative } from "path";
|
|
602
|
+
var CatalogUseCase = class {
|
|
603
|
+
constructor(renderUseCase) {
|
|
604
|
+
this.renderUseCase = renderUseCase;
|
|
605
|
+
}
|
|
606
|
+
renderUseCase;
|
|
607
|
+
async renderCatalog(directory, options = {}) {
|
|
608
|
+
const { recursive = false, ...renderOptions } = options;
|
|
609
|
+
const files = this.scanDirectory(directory, recursive);
|
|
610
|
+
const results = [];
|
|
611
|
+
for (const filePath of files) {
|
|
612
|
+
const relativePath = relative(directory, filePath);
|
|
613
|
+
const framework = this.detectFramework(filePath);
|
|
614
|
+
const code = readFileSync(filePath, "utf-8");
|
|
615
|
+
try {
|
|
616
|
+
const [result] = await this.renderUseCase.render(code, framework, {
|
|
617
|
+
...renderOptions,
|
|
618
|
+
engines: ["chromium"]
|
|
619
|
+
});
|
|
620
|
+
results.push({
|
|
621
|
+
path: relativePath,
|
|
622
|
+
framework,
|
|
623
|
+
image: result.image,
|
|
624
|
+
width: result.width,
|
|
625
|
+
height: result.height,
|
|
626
|
+
consoleErrors: result.consoleErrors
|
|
627
|
+
});
|
|
628
|
+
} catch {
|
|
629
|
+
results.push({
|
|
630
|
+
path: relativePath,
|
|
631
|
+
framework,
|
|
632
|
+
image: "",
|
|
633
|
+
width: 0,
|
|
634
|
+
height: 0,
|
|
635
|
+
consoleErrors: [`Failed to render ${relativePath}`]
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return results;
|
|
640
|
+
}
|
|
641
|
+
scanDirectory(dir, recursive) {
|
|
642
|
+
const componentExtensions = /* @__PURE__ */ new Set([
|
|
643
|
+
".jsx",
|
|
644
|
+
".tsx",
|
|
645
|
+
".vue",
|
|
646
|
+
".svelte",
|
|
647
|
+
".html",
|
|
648
|
+
".htm"
|
|
649
|
+
]);
|
|
650
|
+
const files = [];
|
|
651
|
+
const entries = readdirSync(dir);
|
|
652
|
+
for (const entry of entries) {
|
|
653
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
654
|
+
const fullPath = join(dir, entry);
|
|
655
|
+
const stat = statSync(fullPath);
|
|
656
|
+
if (stat.isDirectory() && recursive) {
|
|
657
|
+
files.push(...this.scanDirectory(fullPath, recursive));
|
|
658
|
+
} else if (stat.isFile() && componentExtensions.has(extname(entry).toLowerCase())) {
|
|
659
|
+
files.push(fullPath);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return files;
|
|
663
|
+
}
|
|
664
|
+
detectFramework(filePath) {
|
|
665
|
+
return EXT_TO_FRAMEWORK[extname(filePath).toLowerCase()] ?? "react";
|
|
666
|
+
}
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
export {
|
|
670
|
+
__export,
|
|
671
|
+
BrowserPool,
|
|
672
|
+
HtmlBuilder,
|
|
673
|
+
ImageComparator,
|
|
674
|
+
SnapshotStore,
|
|
675
|
+
RenderUseCase,
|
|
676
|
+
ScreenshotUseCase,
|
|
677
|
+
DiffUseCase,
|
|
678
|
+
AuditUseCase,
|
|
679
|
+
SnapshotUseCase,
|
|
680
|
+
DEVICE_PRESETS,
|
|
681
|
+
EXT_TO_FRAMEWORK,
|
|
682
|
+
CatalogUseCase
|
|
683
|
+
};
|