screenshotter.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. "use strict";
  2. Object.defineProperty(exports, "__esModule", {
  3. value: true
  4. });
  5. exports.Screenshotter = void 0;
  6. exports.validateScreenshotOptions = validateScreenshotOptions;
  7. var _helper = require("./helper");
  8. var _utils = require("../utils");
  9. var _multimap = require("../utils/multimap");
  10. /**
  11. * Copyright 2019 Google Inc. All rights reserved.
  12. * Modifications copyright (c) Microsoft Corporation.
  13. *
  14. * Licensed under the Apache License, Version 2.0 (the "License");
  15. * you may not use this file except in compliance with the License.
  16. * You may obtain a copy of the License at
  17. *
  18. * http://www.apache.org/licenses/LICENSE-2.0
  19. *
  20. * Unless required by applicable law or agreed to in writing, software
  21. * distributed under the License is distributed on an "AS IS" BASIS,
  22. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  23. * See the License for the specific language governing permissions and
  24. * limitations under the License.
  25. */
  26. function inPagePrepareForScreenshots(screenshotStyle, hideCaret, disableAnimations, syncAnimations) {
  27. // In WebKit, sync the animations.
  28. if (syncAnimations) {
  29. const style = document.createElement('style');
  30. style.textContent = 'body {}';
  31. document.head.appendChild(style);
  32. document.documentElement.getBoundingClientRect();
  33. style.remove();
  34. }
  35. if (!screenshotStyle && !hideCaret && !disableAnimations) return;
  36. const collectRoots = (root, roots = []) => {
  37. roots.push(root);
  38. const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
  39. do {
  40. const node = walker.currentNode;
  41. const shadowRoot = node instanceof Element ? node.shadowRoot : null;
  42. if (shadowRoot) collectRoots(shadowRoot, roots);
  43. } while (walker.nextNode());
  44. return roots;
  45. };
  46. const roots = collectRoots(document);
  47. const cleanupCallbacks = [];
  48. if (screenshotStyle) {
  49. for (const root of roots) {
  50. const styleTag = document.createElement('style');
  51. styleTag.textContent = screenshotStyle;
  52. if (root === document) document.documentElement.append(styleTag);else root.append(styleTag);
  53. cleanupCallbacks.push(() => {
  54. styleTag.remove();
  55. });
  56. }
  57. }
  58. if (hideCaret) {
  59. const elements = new Map();
  60. for (const root of roots) {
  61. root.querySelectorAll('input,textarea,[contenteditable]').forEach(element => {
  62. elements.set(element, {
  63. value: element.style.getPropertyValue('caret-color'),
  64. priority: element.style.getPropertyPriority('caret-color')
  65. });
  66. element.style.setProperty('caret-color', 'transparent', 'important');
  67. });
  68. }
  69. cleanupCallbacks.push(() => {
  70. for (const [element, value] of elements) element.style.setProperty('caret-color', value.value, value.priority);
  71. });
  72. }
  73. if (disableAnimations) {
  74. const infiniteAnimationsToResume = new Set();
  75. const handleAnimations = root => {
  76. for (const animation of root.getAnimations()) {
  77. if (!animation.effect || animation.playbackRate === 0 || infiniteAnimationsToResume.has(animation)) continue;
  78. const endTime = animation.effect.getComputedTiming().endTime;
  79. if (Number.isFinite(endTime)) {
  80. try {
  81. animation.finish();
  82. } catch (e) {
  83. // animation.finish() should not throw for
  84. // finite animations, but we'd like to be on the
  85. // safe side.
  86. }
  87. } else {
  88. try {
  89. animation.cancel();
  90. infiniteAnimationsToResume.add(animation);
  91. } catch (e) {
  92. // animation.cancel() should not throw for
  93. // infinite animations, but we'd like to be on the
  94. // safe side.
  95. }
  96. }
  97. }
  98. };
  99. for (const root of roots) {
  100. const handleRootAnimations = handleAnimations.bind(null, root);
  101. handleRootAnimations();
  102. root.addEventListener('transitionrun', handleRootAnimations);
  103. root.addEventListener('animationstart', handleRootAnimations);
  104. cleanupCallbacks.push(() => {
  105. root.removeEventListener('transitionrun', handleRootAnimations);
  106. root.removeEventListener('animationstart', handleRootAnimations);
  107. });
  108. }
  109. cleanupCallbacks.push(() => {
  110. for (const animation of infiniteAnimationsToResume) {
  111. try {
  112. animation.play();
  113. } catch (e) {
  114. // animation.play() should never throw, but
  115. // we'd like to be on the safe side.
  116. }
  117. }
  118. });
  119. }
  120. window.__pwCleanupScreenshot = () => {
  121. for (const cleanupCallback of cleanupCallbacks) cleanupCallback();
  122. delete window.__pwCleanupScreenshot;
  123. };
  124. }
  125. class Screenshotter {
  126. constructor(page) {
  127. this._queue = new TaskQueue();
  128. this._page = void 0;
  129. this._page = page;
  130. this._queue = new TaskQueue();
  131. }
  132. async _originalViewportSize(progress) {
  133. const originalViewportSize = this._page.viewportSize();
  134. let viewportSize = originalViewportSize;
  135. if (!viewportSize) viewportSize = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({
  136. width: window.innerWidth,
  137. height: window.innerHeight
  138. }));
  139. return {
  140. viewportSize,
  141. originalViewportSize
  142. };
  143. }
  144. async _fullPageSize(progress) {
  145. const fullPageSize = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => {
  146. if (!document.body || !document.documentElement) return null;
  147. return {
  148. width: Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.body.clientWidth, document.documentElement.clientWidth),
  149. height: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight)
  150. };
  151. });
  152. return fullPageSize;
  153. }
  154. async screenshotPage(progress, options) {
  155. const format = validateScreenshotOptions(options);
  156. return this._queue.postTask(async () => {
  157. progress.log('taking page screenshot');
  158. const {
  159. viewportSize
  160. } = await this._originalViewportSize(progress);
  161. await this._preparePageForScreenshot(progress, options.style, options.caret !== 'initial', options.animations === 'disabled');
  162. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  163. if (options.fullPage) {
  164. const fullPageSize = await this._fullPageSize(progress);
  165. let documentRect = {
  166. x: 0,
  167. y: 0,
  168. width: fullPageSize.width,
  169. height: fullPageSize.height
  170. };
  171. const fitsViewport = fullPageSize.width <= viewportSize.width && fullPageSize.height <= viewportSize.height;
  172. if (options.clip) documentRect = trimClipToSize(options.clip, documentRect);
  173. const buffer = await this._screenshot(progress, format, documentRect, undefined, fitsViewport, options);
  174. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  175. await this._restorePageAfterScreenshot();
  176. return buffer;
  177. }
  178. const viewportRect = options.clip ? trimClipToSize(options.clip, viewportSize) : {
  179. x: 0,
  180. y: 0,
  181. ...viewportSize
  182. };
  183. const buffer = await this._screenshot(progress, format, undefined, viewportRect, true, options);
  184. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  185. await this._restorePageAfterScreenshot();
  186. return buffer;
  187. });
  188. }
  189. async screenshotElement(progress, handle, options) {
  190. const format = validateScreenshotOptions(options);
  191. return this._queue.postTask(async () => {
  192. progress.log('taking element screenshot');
  193. const {
  194. viewportSize
  195. } = await this._originalViewportSize(progress);
  196. await this._preparePageForScreenshot(progress, options.style, options.caret !== 'initial', options.animations === 'disabled');
  197. progress.throwIfAborted(); // Do not do extra work.
  198. await handle._waitAndScrollIntoViewIfNeeded(progress, true /* waitForVisible */);
  199. progress.throwIfAborted(); // Do not do extra work.
  200. const boundingBox = await handle.boundingBox();
  201. (0, _utils.assert)(boundingBox, 'Node is either not visible or not an HTMLElement');
  202. (0, _utils.assert)(boundingBox.width !== 0, 'Node has 0 width.');
  203. (0, _utils.assert)(boundingBox.height !== 0, 'Node has 0 height.');
  204. const fitsViewport = boundingBox.width <= viewportSize.width && boundingBox.height <= viewportSize.height;
  205. progress.throwIfAborted(); // Avoid extra work.
  206. const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({
  207. x: window.scrollX,
  208. y: window.scrollY
  209. }));
  210. const documentRect = {
  211. ...boundingBox
  212. };
  213. documentRect.x += scrollOffset.x;
  214. documentRect.y += scrollOffset.y;
  215. const buffer = await this._screenshot(progress, format, _helper.helper.enclosingIntRect(documentRect), undefined, fitsViewport, options);
  216. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  217. await this._restorePageAfterScreenshot();
  218. return buffer;
  219. });
  220. }
  221. async _preparePageForScreenshot(progress, screenshotStyle, hideCaret, disableAnimations) {
  222. if (disableAnimations) progress.log(' disabled all CSS animations');
  223. const syncAnimations = this._page._delegate.shouldToggleStyleSheetToSyncAnimations();
  224. await Promise.all(this._page.frames().map(async frame => {
  225. await frame.nonStallingEvaluateInExistingContext('(' + inPagePrepareForScreenshots.toString() + `)(${JSON.stringify(screenshotStyle)}, ${hideCaret}, ${disableAnimations}, ${syncAnimations})`, false, 'utility').catch(() => {});
  226. }));
  227. if (!process.env.PW_TEST_SCREENSHOT_NO_FONTS_READY) {
  228. progress.log('waiting for fonts to load...');
  229. await Promise.all(this._page.frames().map(async frame => {
  230. await frame.nonStallingEvaluateInExistingContext('document.fonts.ready', false, 'utility').catch(() => {});
  231. }));
  232. progress.log('fonts loaded');
  233. }
  234. progress.cleanupWhenAborted(() => this._restorePageAfterScreenshot());
  235. }
  236. async _restorePageAfterScreenshot() {
  237. await Promise.all(this._page.frames().map(async frame => {
  238. frame.nonStallingEvaluateInExistingContext('window.__pwCleanupScreenshot && window.__pwCleanupScreenshot()', false, 'utility').catch(() => {});
  239. }));
  240. }
  241. async _maskElements(progress, options) {
  242. const framesToParsedSelectors = new _multimap.MultiMap();
  243. const cleanup = async () => {
  244. await Promise.all([...framesToParsedSelectors.keys()].map(async frame => {
  245. await frame.hideHighlight();
  246. }));
  247. };
  248. if (!options.mask || !options.mask.length) return cleanup;
  249. await Promise.all((options.mask || []).map(async ({
  250. frame,
  251. selector
  252. }) => {
  253. const pair = await frame.selectors.resolveFrameForSelector(selector);
  254. if (pair) framesToParsedSelectors.set(pair.frame, pair.info.parsed);
  255. }));
  256. progress.throwIfAborted(); // Avoid extra work.
  257. await Promise.all([...framesToParsedSelectors.keys()].map(async frame => {
  258. await frame.maskSelectors(framesToParsedSelectors.get(frame), options.maskColor || '#F0F');
  259. }));
  260. progress.cleanupWhenAborted(cleanup);
  261. return cleanup;
  262. }
  263. async _screenshot(progress, format, documentRect, viewportRect, fitsViewport, options) {
  264. var _options$quality;
  265. if (options.__testHookBeforeScreenshot) await options.__testHookBeforeScreenshot();
  266. progress.throwIfAborted(); // Screenshotting is expensive - avoid extra work.
  267. const shouldSetDefaultBackground = options.omitBackground && format === 'png';
  268. if (shouldSetDefaultBackground) {
  269. await this._page._delegate.setBackgroundColor({
  270. r: 0,
  271. g: 0,
  272. b: 0,
  273. a: 0
  274. });
  275. progress.cleanupWhenAborted(() => this._page._delegate.setBackgroundColor());
  276. }
  277. progress.throwIfAborted(); // Avoid extra work.
  278. const cleanupHighlight = await this._maskElements(progress, options);
  279. progress.throwIfAborted(); // Avoid extra work.
  280. const quality = format === 'jpeg' ? (_options$quality = options.quality) !== null && _options$quality !== void 0 ? _options$quality : 80 : undefined;
  281. const buffer = await this._page._delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device');
  282. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  283. await cleanupHighlight();
  284. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  285. if (shouldSetDefaultBackground) await this._page._delegate.setBackgroundColor();
  286. progress.throwIfAborted(); // Avoid side effects.
  287. if (options.__testHookAfterScreenshot) await options.__testHookAfterScreenshot();
  288. return buffer;
  289. }
  290. }
  291. exports.Screenshotter = Screenshotter;
  292. class TaskQueue {
  293. constructor() {
  294. this._chain = void 0;
  295. this._chain = Promise.resolve();
  296. }
  297. postTask(task) {
  298. const result = this._chain.then(task);
  299. this._chain = result.catch(() => {});
  300. return result;
  301. }
  302. }
  303. function trimClipToSize(clip, size) {
  304. const p1 = {
  305. x: Math.max(0, Math.min(clip.x, size.width)),
  306. y: Math.max(0, Math.min(clip.y, size.height))
  307. };
  308. const p2 = {
  309. x: Math.max(0, Math.min(clip.x + clip.width, size.width)),
  310. y: Math.max(0, Math.min(clip.y + clip.height, size.height))
  311. };
  312. const result = {
  313. x: p1.x,
  314. y: p1.y,
  315. width: p2.x - p1.x,
  316. height: p2.y - p1.y
  317. };
  318. (0, _utils.assert)(result.width && result.height, 'Clipped area is either empty or outside the resulting image');
  319. return result;
  320. }
  321. function validateScreenshotOptions(options) {
  322. let format = null;
  323. // options.type takes precedence over inferring the type from options.path
  324. // because it may be a 0-length file with no extension created beforehand (i.e. as a temp file).
  325. if (options.type) {
  326. (0, _utils.assert)(options.type === 'png' || options.type === 'jpeg', 'Unknown options.type value: ' + options.type);
  327. format = options.type;
  328. }
  329. if (!format) format = 'png';
  330. if (options.quality !== undefined) {
  331. (0, _utils.assert)(format === 'jpeg', 'options.quality is unsupported for the ' + format + ' screenshots');
  332. (0, _utils.assert)(typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + typeof options.quality);
  333. (0, _utils.assert)(Number.isInteger(options.quality), 'Expected options.quality to be an integer');
  334. (0, _utils.assert)(options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality);
  335. }
  336. if (options.clip) {
  337. (0, _utils.assert)(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + typeof options.clip.x);
  338. (0, _utils.assert)(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + typeof options.clip.y);
  339. (0, _utils.assert)(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + typeof options.clip.width);
  340. (0, _utils.assert)(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + typeof options.clip.height);
  341. (0, _utils.assert)(options.clip.width !== 0, 'Expected options.clip.width not to be 0.');
  342. (0, _utils.assert)(options.clip.height !== 0, 'Expected options.clip.height not to be 0.');
  343. }
  344. return format;
  345. }