difflens 0.0.1

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.
@@ -0,0 +1,433 @@
1
+ // src/config.ts
2
+ import path from "path";
3
+ import fs from "fs";
4
+ import { build } from "esbuild";
5
+ var DEFAULT_CONFIG = {
6
+ scenarios: [],
7
+ threshold: 0.1,
8
+ outDir: ".difflens"
9
+ };
10
+ async function loadConfig(configPath) {
11
+ const searchPlaces = [
12
+ "difflens.config.ts",
13
+ "difflens.config.js",
14
+ "difflens.config.mjs",
15
+ "difflens.config.cjs"
16
+ ];
17
+ let resolvedPath;
18
+ if (configPath) {
19
+ resolvedPath = path.resolve(process.cwd(), configPath);
20
+ } else {
21
+ for (const place of searchPlaces) {
22
+ const p = path.resolve(process.cwd(), place);
23
+ if (fs.existsSync(p)) {
24
+ resolvedPath = p;
25
+ break;
26
+ }
27
+ }
28
+ }
29
+ if (!resolvedPath) {
30
+ console.warn("No configuration file found. Using default config.");
31
+ return DEFAULT_CONFIG;
32
+ }
33
+ try {
34
+ let importPath = resolvedPath;
35
+ let isTs = resolvedPath.endsWith(".ts");
36
+ let tempFile = null;
37
+ if (isTs) {
38
+ const outfile = resolvedPath.replace(/\.ts$/, ".js.tmp.mjs");
39
+ await build({
40
+ entryPoints: [resolvedPath],
41
+ outfile,
42
+ bundle: true,
43
+ platform: "node",
44
+ format: "esm",
45
+ external: ["difflens"]
46
+ // Exclude self if referenced?
47
+ });
48
+ importPath = outfile;
49
+ tempFile = outfile;
50
+ }
51
+ const importUrl = `file://${importPath}`;
52
+ const userConfigModule = await import(importUrl);
53
+ const userConfig = userConfigModule.default || userConfigModule;
54
+ if (tempFile && fs.existsSync(tempFile)) {
55
+ fs.unlinkSync(tempFile);
56
+ }
57
+ return {
58
+ ...DEFAULT_CONFIG,
59
+ ...userConfig
60
+ };
61
+ } catch (error) {
62
+ console.error(`Failed to load configuration from ${resolvedPath}:`, error);
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ // src/core/runner.ts
68
+ import { chromium } from "playwright";
69
+ import path3 from "path";
70
+ import fs4 from "fs";
71
+
72
+ // src/core/capture.ts
73
+ async function captureScreenshot(page, url, outputPath, options = {}) {
74
+ if (url) {
75
+ await page.goto(url, { waitUntil: "networkidle" });
76
+ }
77
+ await page.evaluate(() => document.fonts.ready);
78
+ await page.addStyleTag({
79
+ content: `
80
+ *, *::before, *::after {
81
+ animation-duration: 0s !important;
82
+ transition-duration: 0s !important;
83
+ animation-iteration-count: 1 !important;
84
+ }
85
+ `
86
+ });
87
+ if (options.hideSelectors) {
88
+ for (const selector of options.hideSelectors) {
89
+ await page.locator(selector).evaluate((el) => el.style.visibility = "hidden");
90
+ }
91
+ }
92
+ if (options.maskSelectors) {
93
+ for (const selector of options.maskSelectors) {
94
+ await page.locator(selector).evaluate((el) => el.style.backgroundColor = "#FF00FF");
95
+ }
96
+ }
97
+ await page.screenshot({
98
+ path: outputPath,
99
+ fullPage: options.fullPage ?? true,
100
+ animations: "disabled"
101
+ });
102
+ }
103
+
104
+ // src/core/compare.ts
105
+ import fs2 from "fs";
106
+ import { PNG } from "pngjs";
107
+ import pixelmatch from "pixelmatch";
108
+ function compareImages(img1Path, img2Path, diffPath, threshold = 0.1) {
109
+ const img1 = PNG.sync.read(fs2.readFileSync(img1Path));
110
+ const img2 = PNG.sync.read(fs2.readFileSync(img2Path));
111
+ const { width, height } = img1;
112
+ const diff = new PNG({ width, height });
113
+ if (img1.width !== img2.width || img1.height !== img2.height) {
114
+ return {
115
+ diffPixels: -1,
116
+ diffPercentage: -1,
117
+ isSameDimensions: false
118
+ };
119
+ }
120
+ const diffPixels = pixelmatch(
121
+ img1.data,
122
+ img2.data,
123
+ diff.data,
124
+ width,
125
+ height,
126
+ { threshold }
127
+ );
128
+ fs2.writeFileSync(diffPath, PNG.sync.write(diff));
129
+ const totalPixels = width * height;
130
+ const diffPercentage = diffPixels / totalPixels * 100;
131
+ return {
132
+ diffPixels,
133
+ diffPercentage,
134
+ isSameDimensions: true
135
+ };
136
+ }
137
+
138
+ // src/core/a11y.ts
139
+ import AxeBuilder from "@axe-core/playwright";
140
+ async function runAxeAudit(page) {
141
+ const results = await new AxeBuilder({ page }).analyze();
142
+ if (results.violations.length > 0) {
143
+ console.error(`Found ${results.violations.length} accessibility violations:`);
144
+ results.violations.forEach((violation) => {
145
+ console.error(`- [${violation.impact}] ${violation.help} (${violation.id})`);
146
+ violation.nodes.forEach((node) => {
147
+ console.error(` Target: ${node.target}`);
148
+ });
149
+ });
150
+ }
151
+ return {
152
+ violations: results.violations
153
+ };
154
+ }
155
+
156
+ // src/core/report.ts
157
+ import fs3 from "fs";
158
+ import path2 from "path";
159
+ function generateHtmlReport(data, outDir) {
160
+ const html = `
161
+ <!DOCTYPE html>
162
+ <html lang="en">
163
+ <head>
164
+ <meta charset="UTF-8">
165
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
166
+ <title>DiffLens Report</title>
167
+ <style>
168
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
169
+ .container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
170
+ h1 { color: #333; border-bottom: 2px solid #eee; padding-bottom: 10px; }
171
+ .summary { margin-bottom: 20px; padding: 15px; background: #eef; border-radius: 4px; }
172
+ .scenario { border: 1px solid #ddd; margin-bottom: 20px; border-radius: 4px; overflow: hidden; }
173
+ .scenario-header { padding: 10px 15px; background: #f9f9f9; border-bottom: 1px solid #ddd; display: flex; justify-content: space-between; align-items: center; }
174
+ .scenario-title { font-weight: bold; font-size: 1.1em; }
175
+ .status { padding: 4px 8px; border-radius: 4px; font-size: 0.9em; font-weight: bold; color: white; }
176
+ .status.pass { background: #28a745; }
177
+ .status.fail { background: #dc3545; }
178
+ .status.new { background: #17a2b8; }
179
+ .scenario-body { padding: 15px; }
180
+ .images { display: flex; gap: 20px; margin-bottom: 15px; overflow-x: auto; }
181
+ .image-container { flex: 0 0 auto; }
182
+ .image-container img { max-width: 300px; border: 1px solid #ddd; border-radius: 4px; }
183
+ .image-label { font-size: 0.9em; color: #666; margin-bottom: 5px; }
184
+ .a11y { margin-top: 15px; border-top: 1px solid #eee; padding-top: 10px; }
185
+ .a11y-title { font-weight: bold; color: #666; margin-bottom: 5px; }
186
+ .violation { margin-bottom: 5px; color: #d63384; }
187
+ </style>
188
+ </head>
189
+ <body>
190
+ <div class="container">
191
+ <h1>DiffLens Report</h1>
192
+ <div class="summary">
193
+ <p><strong>Generated at:</strong> ${new Date(data.timestamp).toLocaleString()}</p>
194
+ <p><strong>Total Scenarios:</strong> ${data.results.length}</p>
195
+ <p><strong>Failed:</strong> ${data.results.filter((r) => r.status === "fail").length}</p>
196
+ </div>
197
+
198
+ ${data.results.map((result) => `
199
+ <div class="scenario">
200
+ <div class="scenario-header">
201
+ <span class="scenario-title">${result.scenario}</span>
202
+ <span class="status ${result.status}">${result.status.toUpperCase()}</span>
203
+ </div>
204
+ <div class="scenario-body">
205
+ <div class="images">
206
+ ${result.baselinePath ? `
207
+ <div class="image-container">
208
+ <div class="image-label">Baseline</div>
209
+ <img src="${path2.relative(path2.resolve(outDir), path2.resolve(result.baselinePath))}" alt="Baseline">
210
+ </div>
211
+ ` : ""}
212
+ <div class="image-container">
213
+ <div class="image-label">Current</div>
214
+ <img src="${path2.relative(path2.resolve(outDir), path2.resolve(result.screenshotPath))}" alt="Current">
215
+ </div>
216
+ ${result.diffPath ? `
217
+ <div class="image-container">
218
+ <div class="image-label">Diff</div>
219
+ <img src="${path2.relative(path2.resolve(outDir), path2.resolve(result.diffPath))}" alt="Diff">
220
+ </div>
221
+ ` : ""}
222
+ </div>
223
+
224
+ ${result.diffPixels === -1 ? `
225
+ <p style="color: #dc3545; font-weight: bold;">[FAIL] Dimension mismatch! Baseline and current images have different sizes.</p>
226
+ ` : `
227
+ <p><strong>Diff:</strong> ${result.diffPixels} pixels (${result.diffPercentage.toFixed(2)}%)</p>
228
+ `}
229
+
230
+ ${result.a11yViolations.length > 0 ? `
231
+ <div class="a11y">
232
+ <div class="a11y-title">Accessibility Violations (${result.a11yViolations.length})</div>
233
+ ${result.a11yViolations.map((v) => `
234
+ <div class="violation">
235
+ [${v.impact}] ${v.help} (${v.id})
236
+ </div>
237
+ `).join("")}
238
+ </div>
239
+ ` : '<div class="a11y"><div class="a11y-title">No Accessibility Violations</div></div>'}
240
+ </div>
241
+ </div>
242
+ `).join("")}
243
+ </div>
244
+ </body>
245
+ </html>
246
+ `;
247
+ fs3.writeFileSync(path2.join(outDir, "index.html"), html);
248
+ fs3.writeFileSync(path2.join(outDir, "report.json"), JSON.stringify(data, null, 2));
249
+ console.log(`Report generated: ${path2.join(outDir, "index.html")}`);
250
+ }
251
+
252
+ // src/core/runner.ts
253
+ async function runTests(config) {
254
+ const browser = await chromium.launch();
255
+ const context = await browser.newContext();
256
+ const page = await context.newPage();
257
+ const outDir = config.outDir || ".difflens";
258
+ const dirs = {
259
+ baseline: path3.join(outDir, "baseline"),
260
+ current: path3.join(outDir, "current"),
261
+ diff: path3.join(outDir, "diff")
262
+ };
263
+ Object.values(dirs).forEach((dir) => {
264
+ if (!fs4.existsSync(dir)) {
265
+ fs4.mkdirSync(dir, { recursive: true });
266
+ }
267
+ });
268
+ const results = [];
269
+ if (!config.scenarios || config.scenarios.length === 0) {
270
+ console.error("Error: No scenarios defined in configuration.");
271
+ return false;
272
+ }
273
+ for (const scenario of config.scenarios) {
274
+ const viewports = scenario.viewports || config.viewports && Object.entries(config.viewports).map(([label, { width, height }]) => ({ label, width, height })) || [{ width: 1280, height: 720, label: "default" }];
275
+ for (const viewport of viewports) {
276
+ const viewportLabel = viewport.label || `${viewport.width}x${viewport.height}`;
277
+ const label = viewports.length > 1 || viewport.label ? `${scenario.label}-${viewportLabel}` : scenario.label;
278
+ console.error(`Running scenario: ${label}`);
279
+ const url = config.baseUrl ? new URL(scenario.path, config.baseUrl).toString() : scenario.path;
280
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
281
+ let retries = 2;
282
+ let navigationSuccess = false;
283
+ while (retries >= 0) {
284
+ try {
285
+ await page.goto(url, { waitUntil: "networkidle" });
286
+ navigationSuccess = true;
287
+ break;
288
+ } catch (e) {
289
+ if (retries === 0) {
290
+ console.error(` [WARN] Navigation failed: ${e}`);
291
+ } else {
292
+ console.error(` [WARN] Navigation failed, retrying... (${retries} attempts left)`);
293
+ retries--;
294
+ await page.waitForTimeout(1e3);
295
+ }
296
+ }
297
+ }
298
+ if (!navigationSuccess) {
299
+ if (config.failOnNavigationError) {
300
+ console.error(` [FAIL] Navigation failed.`);
301
+ results.push({
302
+ scenario: label,
303
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
304
+ status: "fail",
305
+ diffPixels: 0,
306
+ diffPercentage: 0,
307
+ screenshotPath: "",
308
+ a11yViolations: []
309
+ });
310
+ continue;
311
+ }
312
+ }
313
+ let actionFailed = false;
314
+ if (scenario.actions) {
315
+ for (const action of scenario.actions) {
316
+ console.error(` Action: ${action.type} ${action.selector || ""}`);
317
+ try {
318
+ if (action.type === "click" && action.selector) {
319
+ await page.click(action.selector);
320
+ } else if (action.type === "type" && action.selector && action.value) {
321
+ await page.fill(action.selector, action.value);
322
+ } else if (action.type === "wait" && action.timeout) {
323
+ await page.waitForTimeout(action.timeout);
324
+ }
325
+ } catch (e) {
326
+ console.error(` [WARN] Action failed: ${e}`);
327
+ actionFailed = true;
328
+ if (config.failOnActionError) {
329
+ break;
330
+ }
331
+ }
332
+ }
333
+ await page.waitForTimeout(500);
334
+ }
335
+ const currentPath = path3.join(dirs.current, `${label}.png`);
336
+ retries = 2;
337
+ while (retries >= 0) {
338
+ try {
339
+ await captureScreenshot(page, null, currentPath, {
340
+ fullPage: true,
341
+ maskSelectors: scenario.maskSelectors,
342
+ hideSelectors: scenario.hideSelectors
343
+ });
344
+ break;
345
+ } catch (e) {
346
+ if (retries === 0) throw e;
347
+ console.error(` [WARN] Screenshot failed, retrying... (${retries} attempts left)`);
348
+ retries--;
349
+ await page.waitForTimeout(1e3);
350
+ }
351
+ }
352
+ console.error(` Screenshot captured: ${currentPath}`);
353
+ const a11yResult = await runAxeAudit(page);
354
+ if (a11yResult.violations.length > 0) {
355
+ console.error(` Accessibility violations: ${a11yResult.violations.length}`);
356
+ }
357
+ const baselinePath = path3.join(dirs.baseline, `${label}.png`);
358
+ const diffPath = path3.join(dirs.diff, `${label}.png`);
359
+ let status = "new";
360
+ let diffPixels = 0;
361
+ let diffPercentage = 0;
362
+ let finalDiffPath;
363
+ let finalBaselinePath;
364
+ let dimensionMismatch = false;
365
+ if (fs4.existsSync(baselinePath)) {
366
+ finalBaselinePath = baselinePath;
367
+ console.error(" Comparing with baseline...");
368
+ const result = compareImages(baselinePath, currentPath, diffPath, config.threshold);
369
+ diffPixels = result.diffPixels;
370
+ diffPercentage = result.diffPercentage;
371
+ dimensionMismatch = !result.isSameDimensions;
372
+ if (!result.isSameDimensions) {
373
+ status = "fail";
374
+ console.error(` [FAIL] Dimension mismatch! Baseline and current images have different sizes.`);
375
+ } else if (result.diffPixels > 0) {
376
+ status = "fail";
377
+ finalDiffPath = diffPath;
378
+ console.error(` [FAIL] Visual regression detected! Diff pixels: ${result.diffPixels} (${result.diffPercentage.toFixed(2)}%)`);
379
+ console.error(` Diff image saved to: ${diffPath}`);
380
+ } else {
381
+ status = "pass";
382
+ console.error(" [PASS] No visual regression detected.");
383
+ }
384
+ } else {
385
+ console.error(" Baseline not found. Saving current as baseline.");
386
+ fs4.copyFileSync(currentPath, baselinePath);
387
+ console.error(` Baseline saved to: ${baselinePath}`);
388
+ finalBaselinePath = baselinePath;
389
+ }
390
+ if (a11yResult.violations.length > 0) {
391
+ status = "fail";
392
+ console.error(` [FAIL] Accessibility violations detected: ${a11yResult.violations.length}`);
393
+ }
394
+ if (actionFailed && config.failOnActionError) {
395
+ status = "fail";
396
+ console.error(` [FAIL] Action execution failed.`);
397
+ }
398
+ results.push({
399
+ scenario: label,
400
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
401
+ status,
402
+ diffPixels,
403
+ diffPercentage,
404
+ screenshotPath: currentPath,
405
+ diffPath: finalDiffPath,
406
+ baselinePath: finalBaselinePath,
407
+ a11yViolations: a11yResult.violations,
408
+ dimensionMismatch
409
+ });
410
+ }
411
+ }
412
+ await browser.close();
413
+ const reportData = {
414
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
415
+ results
416
+ };
417
+ if (config.format === "json") {
418
+ console.log(JSON.stringify(results, null, 2));
419
+ } else if (config.format === "ai") {
420
+ const { formatAiReport } = await import("./ai-reporter-GPDXP36M.mjs");
421
+ console.log(formatAiReport(results, outDir));
422
+ } else {
423
+ generateHtmlReport(reportData, outDir);
424
+ }
425
+ const hasFailures = results.some((r) => r.status === "fail");
426
+ return !hasFailures;
427
+ }
428
+
429
+ export {
430
+ DEFAULT_CONFIG,
431
+ loadConfig,
432
+ runTests
433
+ };
package/dist/cli.d.mts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+
2
+ export { }