diffpx 1.1.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/bin/diffpx.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ const {loadConfig} = require('../src/configLoader');
3
+ const {launchBrowser} = require('../src/steps/browser');
4
+ const {runSnapshots} = require('../src/runner');
5
+ const crypto = require("crypto");
6
+
7
+ (async () => {
8
+ const {config, devicesConfig, settings} = await loadConfig();
9
+
10
+ const ppKey = process.env.PP_KEY;
11
+ if (!ppKey) throw new Error("No API key found in .env or settings.yml");
12
+
13
+ const {browser, page} = await launchBrowser();
14
+
15
+ const timestamp = new Date().toISOString().replace('T', ' ').split('.')[0];
16
+ const groupId = crypto.randomUUID();
17
+
18
+ try {
19
+ await runSnapshots({
20
+ ppKey,
21
+ config,
22
+ devicesConfig,
23
+ settings,
24
+ page,
25
+ timestamp,
26
+ groupId
27
+ });
28
+ } catch (err) {
29
+ console.error('🔴 Error during snapshot run:', err);
30
+ process.exitCode = 1;
31
+ } finally {
32
+ await browser.close();
33
+ }
34
+ })();
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "diffpx",
3
+ "version": "1.1.0",
4
+ "bin": {
5
+ "diffpx": "bin/diffpx.js"
6
+ },
7
+ "scripts": {
8
+ "start": "node bin/diffpx.js"
9
+ },
10
+ "dependencies": {
11
+ "axios": "^1.7.4",
12
+ "cli-progress": "^3.12.0",
13
+ "dotenv": "^16.4.5",
14
+ "form-data": "^4.0.0",
15
+ "js-yaml": "^4.1.0",
16
+ "ora": "^8.1.0",
17
+ "puppeteer": "^24.23.0"
18
+ },
19
+ "description": "",
20
+ "keywords": [],
21
+ "author": "",
22
+ "license": "ISC",
23
+ "files": [
24
+ "bin",
25
+ "src"
26
+ ]
27
+ }
package/src/config.js ADDED
@@ -0,0 +1,18 @@
1
+ import fs from "fs";
2
+ import yaml from "js-yaml";
3
+ import path from "path";
4
+
5
+ export function loadYaml(file) {
6
+ const fullPath = path.resolve(process.cwd(), file);
7
+ if (!fs.existsSync(fullPath)) {
8
+ throw new Error(`${file} not found`);
9
+ }
10
+ const content = fs.readFileSync(fullPath, "utf8");
11
+ return yaml.load(content);
12
+ }
13
+
14
+ export function loadConfigs() {
15
+ const settings = loadYaml("settings.yml");
16
+ const snapshots = loadYaml("snapshots.yml");
17
+ return { settings, snapshots };
18
+ }
@@ -0,0 +1,11 @@
1
+ const fs = require('fs');
2
+ const yaml = require('js-yaml');
3
+ const validateConfig = require('./validateConfig');
4
+
5
+ async function loadConfig() {
6
+ const { config, devicesConfig } = await validateConfig();
7
+ const settings = yaml.load(fs.readFileSync('settings.yml', 'utf8'));
8
+ return { config, devicesConfig, settings };
9
+ }
10
+
11
+ module.exports = { loadConfig };
package/src/runner.js ADDED
@@ -0,0 +1,76 @@
1
+ const cliProgress = require('cli-progress');
2
+ const {handleDevice} = require('./steps/device');
3
+ const {navigatePage} = require('./steps/navigate');
4
+ const {handleCookies} = require('./steps/cookies');
5
+ const {handleHides} = require('./steps/hides');
6
+ const {takeScreenshot} = require('./steps/screenshot');
7
+
8
+ async function runSnapshots({ppKey, config, devicesConfig, settings, page, timestamp, groupId}) {
9
+ const multiBar = new cliProgress.MultiBar({
10
+ clearOnComplete: false,
11
+ hideCursor: true,
12
+ barCompleteChar: '█',
13
+ barIncompleteChar: '░',
14
+ format: '{name} [{bar}] {percentage}% | {value}/{total}'
15
+ }, cliProgress.Presets.shades_classic);
16
+
17
+ const outer = multiBar.create(config.length, 0, {name: config[0]?.name || ''});
18
+ const inner = multiBar.create(1, 0, {name: ''});
19
+
20
+ const logs = [];
21
+ const log = msg => logs.push(`[${new Date().toISOString().replace('T', ' ').split('.')[0]}] ${msg}`);
22
+
23
+ for (let index = 0; index < config.length; index++) {
24
+ const entry = config[index];
25
+ const {name, url, tags, timeout, readySelector, readySelectors, devices, hide, cookies} = entry;
26
+
27
+ log(`▶️ Starting snapshots for "${name}" → ${url}`);
28
+
29
+ const runs = devices?.length > 0 ? devices : settings.width;
30
+ const isDeviceMode = !!devices?.length;
31
+
32
+ outer.update(index, {name});
33
+ inner.setTotal(runs.length);
34
+ inner.update(0, {name: ''});
35
+
36
+ for (const r of runs) {
37
+ const label = isDeviceMode ? r : `${r}px`;
38
+ inner.update(inner.value, {name: label});
39
+
40
+ try {
41
+ const {
42
+ label: resolvedLabel,
43
+ logs: deviceLogs
44
+ } = await handleDevice(page, r, isDeviceMode, devicesConfig, name, url, settings);
45
+
46
+ const shotLogs = [];
47
+ shotLogs.push(...deviceLogs);
48
+
49
+ await navigatePage(page, url, timeout, readySelector, readySelectors, r);
50
+ await handleCookies(page, cookies, settings);
51
+ await handleHides(page, hide, settings);
52
+ await takeScreenshot(ppKey, settings, page, name, resolvedLabel, tags, timestamp, shotLogs, groupId);
53
+
54
+ shotLogs.push(
55
+ `[${new Date().toISOString().replace('T', ' ').split('.')[0]}] 🟢 Saved ${name}_${resolvedLabel}.png`
56
+ );
57
+
58
+ logs.push(...shotLogs);
59
+ } catch (err) {
60
+ log(`🔴 Error during run ${name}_${label}: ${err.message}`);
61
+ }
62
+
63
+ inner.increment();
64
+ }
65
+
66
+ log(`🏁 Finished snapshots for "${name}"`);
67
+ outer.increment({name});
68
+ }
69
+
70
+ outer.stop();
71
+ inner.stop();
72
+ multiBar.stop();
73
+ return logs;
74
+ }
75
+
76
+ module.exports = {runSnapshots};
@@ -0,0 +1,12 @@
1
+ const puppeteer = require('puppeteer');
2
+
3
+ async function launchBrowser() {
4
+ const browser = await puppeteer.launch({
5
+ headless: true,
6
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
7
+ });
8
+ const page = await browser.newPage();
9
+ return { browser, page };
10
+ }
11
+
12
+ module.exports = { launchBrowser };
@@ -0,0 +1,17 @@
1
+ async function handleCookies(page, cookies, settings) {
2
+ let cookieSelector = cookies || settings.cookies;
3
+ if (cookieSelector) {
4
+ try {
5
+ const button = await page.$(cookieSelector);
6
+ if (button) {
7
+ await button.click();
8
+ console.log(`🍪 Accepted cookies with selector: ${ cookieSelector }`);
9
+ await page.waitForTimeout(500);
10
+ }
11
+ } catch (err) {
12
+ console.warn(`⚠️ Could not click cookie button (${ cookieSelector }): ${ err.message }`);
13
+ }
14
+ }
15
+ }
16
+
17
+ module.exports = { handleCookies };
@@ -0,0 +1,30 @@
1
+ async function handleDevice(page, r, isDeviceMode, devicesConfig, name, url, settings = {}) {
2
+ let label
3
+ const logs = []
4
+
5
+ const log = msg =>
6
+ logs.push(
7
+ `[${new Date().toISOString().replace("T", " ").split(".")[0]}] ${msg}`
8
+ )
9
+
10
+ if (isDeviceMode) {
11
+ const spec = devicesConfig[r]
12
+ label = r.replace(/\s+/g, "_")
13
+
14
+ log(`📷 Taking screenshot of ${url} with device ${r} → ${name}_${label}.png`)
15
+
16
+ await page.setUserAgent(spec.userAgent)
17
+ await page.setViewport(spec.viewport)
18
+ } else {
19
+ label = r
20
+
21
+ log(`📷 Taking screenshot of ${url} at width ${r}px → ${name}_${label}.png`)
22
+
23
+ const height = settings.height || 1080
24
+ await page.setViewport({width: r, height})
25
+ }
26
+
27
+ return {label, logs}
28
+ }
29
+
30
+ module.exports = { handleDevice };
@@ -0,0 +1,20 @@
1
+ async function handleHides(page, hide, settings) {
2
+ let globalHides = [];
3
+ if (settings.hide) {
4
+ globalHides = settings.hide.split(',').map(s => s.trim());
5
+ }
6
+
7
+ let snapshotHides = [];
8
+ if (hide) {
9
+ snapshotHides = hide.split(',').map(s => s.trim());
10
+ }
11
+
12
+ const allHides = [...globalHides, ...snapshotHides];
13
+ if (allHides.length > 0) {
14
+ await page.addStyleTag({
15
+ content: `${ allHides.join(', ') } { display: none !important; }`
16
+ });
17
+ }
18
+ }
19
+
20
+ module.exports = { handleHides };
@@ -0,0 +1,18 @@
1
+ async function navigatePage(page, url, timeout, readySelector, readySelectors, r) {
2
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: timeout || 60000 });
3
+
4
+ let selector = null;
5
+ if (readySelectors && readySelectors[r]) {
6
+ selector = readySelectors[r];
7
+ } else if (readySelector) {
8
+ selector = readySelector;
9
+ }
10
+
11
+ if (selector) {
12
+ await page.waitForSelector(selector, { timeout: timeout || 60000 });
13
+ } else if (timeout) {
14
+ await new Promise(resolve => setTimeout(resolve, timeout));
15
+ }
16
+ }
17
+
18
+ module.exports = { navigatePage };
@@ -0,0 +1,33 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const {uploadSnapshots} = require("../upload.js");
4
+
5
+ async function takeScreenshot(ppKey, settings, page, name, label, tags, timestamp, logs, groupId) {
6
+ const screenshot = await page.screenshot({fullPage: true});
7
+ const tmpFile = path.join(process.cwd(), `${name}_${label}.png`);
8
+ fs.writeFileSync(tmpFile, screenshot);
9
+
10
+ try {
11
+ await uploadSnapshots(ppKey, settings, {
12
+ timestamp,
13
+ label,
14
+ name,
15
+ tags,
16
+ logs,
17
+ groupId,
18
+ filePath: tmpFile
19
+ });
20
+ } catch (err) {
21
+ const status = err.response?.status;
22
+ const msg = err.response?.data?.error || err.message;
23
+
24
+ if (status === 401 || status === 403) {
25
+ console.error("🔴 error: " + msg);
26
+ process.exit(1);
27
+ }
28
+ } finally {
29
+ fs.unlinkSync(tmpFile);
30
+ }
31
+ }
32
+
33
+ module.exports = {takeScreenshot};
package/src/upload.js ADDED
@@ -0,0 +1,34 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import axios from "axios";
4
+ import FormData from "form-data";
5
+ import dotenv from "dotenv";
6
+
7
+ dotenv.config();
8
+
9
+ const ppUrl = process.env.PP_URL;
10
+
11
+ export async function uploadSnapshots(ppKey, settings, shot) {
12
+ const form = new FormData();
13
+ form.append("settings", JSON.stringify(settings));
14
+ form.append("timestamp", shot.timestamp);
15
+ form.append("path", shot.label);
16
+ form.append("name", shot.name);
17
+ form.append("groupId", shot.groupId);
18
+
19
+ if (shot.tags) form.append("tags", JSON.stringify(shot.tags));
20
+ if (shot.logs) form.append("logs", JSON.stringify(shot.logs));
21
+
22
+ const stream = fs.createReadStream(path.resolve(shot.filePath));
23
+ form.append("snapshots", stream, `${shot.name}_${shot.label}.png`);
24
+
25
+ const res = await axios.post(`${ppUrl}/compare`, form, {
26
+ headers: {
27
+ ...form.getHeaders(),
28
+ Authorization: `Bearer ${ppKey}`
29
+ },
30
+ maxBodyLength: Infinity
31
+ });
32
+
33
+ return res.data;
34
+ }
@@ -0,0 +1,72 @@
1
+ const fs = require('fs');
2
+ const yaml = require('js-yaml');
3
+ const axios = require('axios');
4
+ require('dotenv').config();
5
+
6
+ async function validateConfig() {
7
+ const errors = [];
8
+
9
+ const ppKey = process.env.PP_KEY;
10
+ const ppUrl = process.env.PP_URL;
11
+
12
+ if (!ppKey) errors.push("🔴 Missing PP_KEY in .env");
13
+ if (!ppUrl) errors.push("🔴 Missing PP_URL in .env");
14
+
15
+ if (errors.length > 0) {
16
+ console.error("🔴 Configuration check failed:");
17
+ for (const err of errors) console.error(" " + err);
18
+ process.exit(1);
19
+ }
20
+
21
+ try {
22
+ await axios.get(`${ ppUrl }/auth/ping`, {
23
+ headers: { Authorization: `Bearer ${ ppKey }` },
24
+ timeout: 5000
25
+ });
26
+ } catch (err) {
27
+ const msg = err.response?.data?.error || err.message;
28
+ if (msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
29
+ console.error("🔴 API validation failed: Connection to server failed. Make sure PP_URL in .env is correct");
30
+ } else {
31
+ console.error("🔴 API validation failed:", msg);
32
+ }
33
+ process.exit(1);
34
+ }
35
+
36
+ const config = yaml.load(fs.readFileSync('snapshots.yml', 'utf8'));
37
+ const devicesList = yaml.load(fs.readFileSync('devices.yml', 'utf8'));
38
+
39
+ const devicesConfig = {};
40
+ for (const d of devicesList) devicesConfig[d.name] = d;
41
+
42
+ const seenNames = new Set();
43
+ const configErrors = [];
44
+
45
+ for (const entry of config) {
46
+ if (seenNames.has(entry.name)) {
47
+ configErrors.push(`🔴 Duplicate snapshot name "${ entry.name }" found in snapshots.yml`);
48
+ } else {
49
+ seenNames.add(entry.name);
50
+ }
51
+
52
+ if (entry.devices && entry.devices.length > 0) {
53
+ for (const d of entry.devices) {
54
+ if (!devicesConfig[d]) {
55
+ configErrors.push(`🔴 Snapshot "${ entry.name }": Device "${ d }" not found in devices.yml`);
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ if (configErrors.length > 0) {
62
+ console.error("🔴 Configuration check failed:");
63
+ for (const err of configErrors) console.error(" " + err);
64
+ process.exit(1);
65
+ } else {
66
+ console.log("🟢 Configuration check passed.");
67
+ }
68
+
69
+ return { config, devicesConfig };
70
+ }
71
+
72
+ module.exports = validateConfig;