phantomas 2.9.0 → 2.11.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/program.js CHANGED
@@ -131,6 +131,7 @@ function getProgram() {
131
131
 
132
132
  // Output and reporting
133
133
  .option("--analyze-css", "emit in-depth CSS metrics")
134
+ .option("--analyze-images", "emit in-depth image metrics")
134
135
  .option("--colors", "forces ANSI colors even when output is piped")
135
136
  .option(
136
137
  "--film-strip",
@@ -5,8 +5,8 @@
5
5
 
6
6
  module.exports = function (phantomas) {
7
7
  const puppeteer = require("puppeteer"),
8
- devices = puppeteer.devices,
9
- // @see https://github.com/GoogleChrome/puppeteer/blob/master/DeviceDescriptors.js
8
+ devices = puppeteer.KnownDevices,
9
+ // @see https://github.com/puppeteer/puppeteer/blob/main/packages/puppeteer-core/src/common/Device.ts
10
10
  availableDevices = {
11
11
  phone: "Galaxy S5", // 360x640
12
12
  "phone-landscape": "Galaxy S5 landscape", // 640x360
@@ -14,7 +14,7 @@ module.exports = function (phantomas) {
14
14
  "tablet-landscape": "Kindle Fire HDX landscape", // 1280x800
15
15
  };
16
16
 
17
- var device;
17
+ let device;
18
18
 
19
19
  // check if --phone or --tablet option was passed
20
20
  Object.keys(availableDevices).forEach(function (item) {
@@ -3,6 +3,8 @@
3
3
  */
4
4
  "use strict";
5
5
 
6
+ const { setTimeout } = require("timers/promises");
7
+
6
8
  module.exports = function (phantomas) {
7
9
  // e.g. --post-load-delay 5
8
10
  var delay = parseInt(phantomas.getParam("post-load-delay"), 10);
@@ -14,9 +16,9 @@ module.exports = function (phantomas) {
14
16
  // https://github.com/GoogleChrome/puppeteer/blob/v1.11.0/docs/api.md#framewaitforselectororfunctionortimeout-options-args
15
17
  phantomas.log("Will wait %d second(s) after load", delay);
16
18
 
17
- phantomas.on("beforeClose", (page) => {
19
+ phantomas.on("beforeClose", async () => {
18
20
  phantomas.log("Sleeping for %d seconds", delay);
19
21
 
20
- return page.waitForTimeout(delay * 1000);
22
+ return setTimeout(delay * 1000);
21
23
  });
22
24
  };
package/lib/browser.js CHANGED
@@ -221,13 +221,17 @@ Browser.prototype.init = async (phantomasOptions) => {
221
221
  const resp = await this.cdp.send("Network.getResponseBody", {
222
222
  requestId: data.requestId,
223
223
  });
224
+
225
+ // If the data is binary, decode from base64 and respond with a buffer
226
+ body = resp.base64Encoded
227
+ ? Buffer.from(resp.body, "base64")
228
+ : resp.body;
229
+
224
230
  networkDebug(
225
231
  "Content for #%s received (%d bytes)",
226
232
  data.requestId,
227
- resp.body.length
233
+ body.length
228
234
  );
229
-
230
- body = resp.body;
231
235
  } catch (err) {
232
236
  // In case the resource was dumped after a redirect
233
237
  // https://github.com/puppeteer/puppeteer/issues/2258
@@ -243,6 +243,19 @@
243
243
  "cssInlineStyles"
244
244
  ]
245
245
  },
246
+ "analyzeImages": {
247
+ "file": "/modules/analyzeImages/analyzeImages.js",
248
+ "desc": "Adds Responsive Images metrics using analyze-images npm module.\nRun phantomas with --analyze-images option to use this module",
249
+ "events": [],
250
+ "metrics": [
251
+ "imagesWithoutDimensions",
252
+ "imagesNotOptimized",
253
+ "imagesScaledDown",
254
+ "imagesOldFormat",
255
+ "imagesExcessiveDensity",
256
+ "imagesWithIncorrectSizesParam"
257
+ ]
258
+ },
246
259
  "assetsTypes": {
247
260
  "file": "/modules/assetsTypes/assetsTypes.js",
248
261
  "desc": "Analyzes number of requests and sizes of different types of assets",
@@ -361,8 +374,6 @@
361
374
  "DOMelementMaxDepth",
362
375
  "nodesWithInlineCSS",
363
376
  "iframesCount",
364
- "imagesScaledDown",
365
- "imagesWithoutDimensions",
366
377
  "DOMidDuplicated"
367
378
  ]
368
379
  },
@@ -977,6 +988,48 @@
977
988
  "module": "analyzeCss",
978
989
  "testsCovered": true
979
990
  },
991
+ "imagesWithoutDimensions": {
992
+ "desc": "number of <img> nodes without both width and height attribute",
993
+ "offenders": true,
994
+ "unit": "number",
995
+ "module": "analyzeImages",
996
+ "testsCovered": true
997
+ },
998
+ "imagesNotOptimized": {
999
+ "desc": "number of loaded images that could be lighter it optimized",
1000
+ "offenders": true,
1001
+ "unit": "number",
1002
+ "module": "analyzeImages",
1003
+ "testsCovered": true
1004
+ },
1005
+ "imagesScaledDown": {
1006
+ "desc": "number of loaded images scaled down when displayed",
1007
+ "offenders": true,
1008
+ "unit": "number",
1009
+ "module": "analyzeImages",
1010
+ "testsCovered": true
1011
+ },
1012
+ "imagesOldFormat": {
1013
+ "desc": "number of loaded images that could benefit from new generation formats (WebP or AVIF)",
1014
+ "offenders": true,
1015
+ "unit": "number",
1016
+ "module": "analyzeImages",
1017
+ "testsCovered": true
1018
+ },
1019
+ "imagesExcessiveDensity": {
1020
+ "desc": "number of images that could be served smaller as the human eye can hardly see the difference",
1021
+ "offenders": true,
1022
+ "unit": "number",
1023
+ "module": "analyzeImages",
1024
+ "testsCovered": false
1025
+ },
1026
+ "imagesWithIncorrectSizesParam": {
1027
+ "desc": "number of responsive images with an improperly set sizes parameter",
1028
+ "offenders": true,
1029
+ "unit": "number",
1030
+ "module": "analyzeImages",
1031
+ "testsCovered": true
1032
+ },
980
1033
  "htmlCount": {
981
1034
  "desc": "number of HTML responses",
982
1035
  "offenders": true,
@@ -1304,20 +1357,6 @@
1304
1357
  "module": "domComplexity",
1305
1358
  "testsCovered": true
1306
1359
  },
1307
- "imagesScaledDown": {
1308
- "desc": "number of <img> nodes that have images scaled down in HTML",
1309
- "offenders": true,
1310
- "unit": "number",
1311
- "module": "domComplexity",
1312
- "testsCovered": true
1313
- },
1314
- "imagesWithoutDimensions": {
1315
- "desc": "number of <img> nodes without both width and height attribute",
1316
- "offenders": true,
1317
- "unit": "number",
1318
- "module": "domComplexity",
1319
- "testsCovered": true
1320
- },
1321
1360
  "DOMidDuplicated": {
1322
1361
  "desc": "number of duplicated IDs found in DOM",
1323
1362
  "offenders": true,
@@ -1876,8 +1915,8 @@
1876
1915
  "testsCovered": false
1877
1916
  }
1878
1917
  },
1879
- "metricsCount": 187,
1880
- "modulesCount": 36,
1918
+ "metricsCount": 191,
1919
+ "modulesCount": 37,
1881
1920
  "extensionsCount": 14,
1882
- "version": "2.4.0"
1921
+ "version": "2.9.0"
1883
1922
  }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Adds Responsive Images metrics using analyze-images npm module.
3
+ *
4
+ * Run phantomas with --analyze-images option to use this module
5
+ */
6
+ "use strict";
7
+
8
+ module.exports = function (phantomas) {
9
+ phantomas.setMetric("imagesWithoutDimensions"); // @desc number of <img> nodes without both width and height attribute @offenders
10
+ phantomas.setMetric("imagesNotOptimized"); // @desc number of loaded images that could be lighter it optimized @offenders
11
+ phantomas.setMetric("imagesScaledDown"); // @desc number of loaded images scaled down when displayed @offenders
12
+ phantomas.setMetric("imagesOldFormat"); // @desc number of loaded images that could benefit from new generation formats (WebP or AVIF) @offenders
13
+ phantomas.setMetric("imagesExcessiveDensity"); // @desc number of images that could be served smaller as the human eye can hardly see the difference @offenders
14
+ phantomas.setMetric("imagesWithIncorrectSizesParam"); // @desc number of responsive images with an improperly set sizes parameter @offenders
15
+
16
+ if (phantomas.getParam("analyze-images") !== true) {
17
+ phantomas.log(
18
+ "To enable images in-depth metrics please run phantomas with --analyze-images option"
19
+ );
20
+ return;
21
+ }
22
+
23
+ const analyzer = require("analyze-image");
24
+ phantomas.log("Using version %s", analyzer.version);
25
+
26
+ async function analyzeImage(body, context) {
27
+ phantomas.log("Starting analyze-image on %j", context);
28
+ const results = await analyzer(body, context, {});
29
+ phantomas.log("Response from analyze-image: %j", results);
30
+
31
+ for (const offenderName in results.offenders) {
32
+ phantomas.log(
33
+ "Offender %s found: %j",
34
+ offenderName,
35
+ results.offenders[offenderName]
36
+ );
37
+
38
+ const newOffenderName = offenderName.replace("image", "images");
39
+ phantomas.incrMetric(newOffenderName);
40
+ phantomas.addOffender(newOffenderName, {
41
+ url: context.url || shortenDataUri(context.inline),
42
+ ...results.offenders[offenderName],
43
+ });
44
+ }
45
+ }
46
+
47
+ // prepare a list of images (both external and inline)
48
+ const images = [];
49
+
50
+ phantomas.on("recv", async (entry, res) => {
51
+ if (entry.isImage) {
52
+ images.push({
53
+ contentPromise: res.getContent, // defer getting the response content
54
+ url: entry.url,
55
+ htmlTags: [],
56
+ });
57
+ }
58
+ });
59
+
60
+ phantomas.on("base64recv", async (entry) => {
61
+ if (entry.isImage) {
62
+ images.push({
63
+ inline: entry.url,
64
+ htmlTags: [],
65
+ });
66
+ }
67
+ });
68
+
69
+ phantomas.on("imgtag", (context) => {
70
+ phantomas.log("Image tag found: %j", context);
71
+
72
+ // If we previously found a network/inline request that matches the currentSrc, attach tag to it.
73
+ const correspondingResp = images.find(
74
+ (resp) =>
75
+ resp.url === context.currentSrc || resp.inline === context.currentSrc
76
+ );
77
+ if (correspondingResp) {
78
+ phantomas.log(
79
+ "Attached to previously found network image %s",
80
+ correspondingResp.url || "[inline]"
81
+ );
82
+ correspondingResp.htmlTags.push(context);
83
+ } else {
84
+ phantomas.log("Can't attach to previously found network image");
85
+ }
86
+ });
87
+
88
+ // ok, now let's analyze the collected images
89
+ phantomas.on("beforeClose", () => {
90
+ const promises = [];
91
+
92
+ images.forEach((entry) => {
93
+ promises.push(
94
+ new Promise(async (resolve) => {
95
+ phantomas.log("Analyzing %s", entry.url || "inline image");
96
+ let imageBody;
97
+
98
+ if (entry.inline) {
99
+ imageBody = extractImageFromDataUri(entry.inline);
100
+ }
101
+
102
+ if (entry.contentPromise) {
103
+ imageBody = await entry.contentPromise();
104
+ }
105
+
106
+ if (imageBody && imageBody.length > 0) {
107
+ // If several img tags use the same source, then we only treat the largest one
108
+ // (because it's not a perf issue when an image is re-used on the page on a smaller size)
109
+ let largestTag;
110
+
111
+ entry.htmlTags.forEach((tag) => {
112
+ if (
113
+ !largestTag ||
114
+ tag.displayWidth * tag.displayHeight >
115
+ largestTag.displayWidth * largestTag.displayHeight
116
+ ) {
117
+ largestTag = tag;
118
+ }
119
+ });
120
+
121
+ await analyzeImage(imageBody, {
122
+ url: entry.url,
123
+ inline: entry.inline,
124
+ ...largestTag,
125
+ });
126
+ }
127
+
128
+ resolve();
129
+ })
130
+ );
131
+ });
132
+
133
+ return Promise.all(promises);
134
+ });
135
+
136
+ function extractImageFromDataUri(str) {
137
+ const result = str.match(/^data:image\/[a-z+]*(?:;[a-z0-9]*)?,(.*)$/);
138
+ if (result) {
139
+ // Inline SVGs might be urlencoded
140
+ if (str.startsWith("data:image/svg+xml") && str.includes("%3Csvg")) {
141
+ return decodeURIComponent(result[1]);
142
+ }
143
+ return result[1];
144
+ }
145
+ return null;
146
+ }
147
+
148
+ function shortenDataUri(str) {
149
+ if (str.length > 100) {
150
+ return str.substring(0, 50) + " [...] " + str.substring(str.length - 50);
151
+ }
152
+ return str;
153
+ }
154
+ };
@@ -0,0 +1,60 @@
1
+ (function analyzeImageScope(phantomas) {
2
+ window.addEventListener("load", function () {
3
+ phantomas.spyEnabled(false, "Checking images");
4
+ const images = document.querySelectorAll("img");
5
+ phantomas.spyEnabled(true);
6
+
7
+ images.forEach((node) => {
8
+ var imgWidth = node.hasAttribute("width")
9
+ ? parseInt(node.getAttribute("width"), 10)
10
+ : false,
11
+ imgHeight = node.hasAttribute("height")
12
+ ? parseInt(node.getAttribute("height"), 10)
13
+ : false;
14
+
15
+ // get dimensions from inline CSS (issue #399)
16
+ if (imgWidth === false || imgHeight === false) {
17
+ imgWidth = parseInt(node.style.width, 10) || false;
18
+ imgHeight = parseInt(node.style.height, 10) || false;
19
+ }
20
+
21
+ if (imgWidth === false || imgHeight === false) {
22
+ phantomas.incrMetric("imagesWithoutDimensions");
23
+ phantomas.addOffender("imagesWithoutDimensions", {
24
+ path: phantomas.getDOMPath(node, true /* dontGoUpTheDom */),
25
+ src: node.currentSrc,
26
+ });
27
+ }
28
+
29
+ const html =
30
+ node.parentNode.tagName === "PICTURE"
31
+ ? node.parentNode.outerHTML
32
+ : node.outerHTML;
33
+
34
+ // Check if the image or one of its parents is in display:none
35
+ // If it is the case, node.width and node.height values are not reliable.
36
+ // https://stackoverflow.com/a/53068496/4716391
37
+ const isVisible = !!node.offsetParent;
38
+ phantomas.log(
39
+ "analyzeImg: ignoring displayWidth and displayHeight because image is not visible"
40
+ );
41
+
42
+ if (node.currentSrc) {
43
+ phantomas.emit("imgtag", {
44
+ html: html,
45
+ displayWidth: isVisible ? node.width : undefined,
46
+ displayHeight: isVisible ? node.height : undefined,
47
+ viewportWidth: window.innerWidth,
48
+ viewportHeight: window.innerHeight,
49
+ currentSrc: node.currentSrc,
50
+ dpr: window.devicePixelRatio,
51
+ });
52
+ } else {
53
+ phantomas.log(
54
+ "analyzeImg: image tag found without currentSrc: %s",
55
+ html
56
+ );
57
+ }
58
+ });
59
+ });
60
+ })(window.__phantomas);
@@ -21,35 +21,6 @@ module.exports = function (phantomas) {
21
21
 
22
22
  phantomas.setMetric("iframesCount"); // @desc number of iframe nodes @offenders
23
23
 
24
- // images
25
- // TODO: move to a separate module
26
- phantomas.setMetric("imagesScaledDown"); // @desc number of <img> nodes that have images scaled down in HTML @offenders
27
- phantomas.setMetric("imagesWithoutDimensions"); // @desc number of <img> nodes without both width and height attribute @offenders
28
-
29
- // keep the track of SVG graphics (#479)
30
- var svgResources = [];
31
- phantomas.on("recv", (entry) => {
32
- if (entry.isSVG) {
33
- svgResources.push(entry.url);
34
- phantomas.log(
35
- "imagesScaledDown: will ignore <%s> [%s]",
36
- entry.url,
37
- entry.contentType
38
- );
39
- }
40
- });
41
-
42
- phantomas.on("imagesScaledDown", (image) => {
43
- if (svgResources.indexOf(image.url) === -1) {
44
- phantomas.log("Scaled down image: %j", image);
45
-
46
- phantomas.incrMetric("imagesScaledDown");
47
- phantomas.addOffender("imagesScaledDown", image);
48
- } else {
49
- phantomas.log("imagesScaledDown: ignored <%s> (is SVG)", image.url);
50
- }
51
- });
52
-
53
24
  // duplicated ID (issue #392)
54
25
  phantomas.setMetric("DOMidDuplicated"); // @desc number of duplicated IDs found in DOM
55
26
 
@@ -75,52 +75,6 @@
75
75
  return false;
76
76
  }
77
77
 
78
- // images
79
- if (node.nodeName === "IMG") {
80
- var imgWidth = node.hasAttribute("width")
81
- ? parseInt(node.getAttribute("width"), 10)
82
- : false,
83
- imgHeight = node.hasAttribute("height")
84
- ? parseInt(node.getAttribute("height"), 10)
85
- : false;
86
-
87
- // get dimensions from inline CSS (issue #399)
88
- if (imgWidth === false || imgHeight === false) {
89
- imgWidth = parseInt(node.style.width, 10) || false;
90
- imgHeight = parseInt(node.style.height, 10) || false;
91
- }
92
-
93
- if (imgWidth === false || imgHeight === false) {
94
- phantomas.incrMetric("imagesWithoutDimensions");
95
- phantomas.addOffender(
96
- "imagesWithoutDimensions",
97
- "%s <%s>",
98
- path,
99
- node.src
100
- );
101
- }
102
-
103
- if (
104
- node.naturalHeight &&
105
- node.naturalWidth &&
106
- imgHeight &&
107
- imgWidth
108
- ) {
109
- if (
110
- node.naturalHeight > imgHeight ||
111
- node.naturalWidth > imgWidth
112
- ) {
113
- phantomas.emit("imagesScaledDown", {
114
- url: node.src,
115
- naturalWidth: node.naturalWidth,
116
- naturalHeight: node.naturalHeight,
117
- imgWidth,
118
- imgHeight,
119
- });
120
- }
121
- }
122
- }
123
-
124
78
  break;
125
79
 
126
80
  case Node.TEXT_NODE:
@@ -12,6 +12,7 @@
12
12
  len = images.length,
13
13
  offset,
14
14
  path,
15
+ native,
15
16
  processedImages = {},
16
17
  src,
17
18
  viewportHeight = window.innerHeight;
@@ -30,6 +31,9 @@
30
31
  // @see https://stackoverflow.com/questions/35586728/detect-used-srcset-or-picture-tag-source-with-javascript
31
32
  src = images[i].currentSrc;
32
33
 
34
+ // Chrome headless loads images with native lazyloading, therefore we need to filter by ourself.
35
+ native = images[i].loading === "lazy";
36
+
33
37
  // ignore base64-encoded images
34
38
  if (src === null || src === "" || /^data:/.test(src)) {
35
39
  continue;
@@ -42,6 +46,7 @@
42
46
  processedImages[src] = {
43
47
  offset: offset,
44
48
  path: path,
49
+ native: native,
45
50
  };
46
51
  }
47
52
 
@@ -50,6 +55,7 @@
50
55
  processedImages[src] = {
51
56
  offset: offset,
52
57
  path: path,
58
+ native: native,
53
59
  };
54
60
  }
55
61
  }
@@ -62,7 +68,7 @@
62
68
  Object.keys(processedImages).forEach((src) => {
63
69
  var img = processedImages[src];
64
70
 
65
- if (img.offset > viewportHeight) {
71
+ if (img.offset > viewportHeight && !img.native) {
66
72
  phantomas.log(
67
73
  "lazyLoadableImages: <%s> image (%s) is below the fold (at %dpx)",
68
74
  src,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phantomas",
3
- "version": "2.9.0",
3
+ "version": "2.11.0",
4
4
  "author": "macbre <maciej.brencz@gmail.com> (http://macbre.net)",
5
5
  "description": "Headless Chromium-based web performance metrics collector and monitoring tool",
6
6
  "main": "./lib/index.js",
@@ -27,13 +27,14 @@
27
27
  "node": ">=16.0"
28
28
  },
29
29
  "dependencies": {
30
- "analyze-css": "^2.0.0",
30
+ "analyze-css": "^2.1.89",
31
+ "analyze-image": "^1.0.0",
31
32
  "commander": "^9.0.0",
32
33
  "debug": "^4.1.1",
33
34
  "decamelize": "^5.0.0",
34
35
  "fast-stats": "0.0.6",
35
36
  "js-yaml": "^4.0.0",
36
- "puppeteer": "^20.9.0"
37
+ "puppeteer": "^22.4.1"
37
38
  },
38
39
  "devDependencies": {
39
40
  "@jest/globals": "^28.0.0",