screenshotter.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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(hideCaret, disableAnimations) {
  27. const collectRoots = (root, roots = []) => {
  28. roots.push(root);
  29. const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
  30. do {
  31. const node = walker.currentNode;
  32. const shadowRoot = node instanceof Element ? node.shadowRoot : null;
  33. if (shadowRoot) collectRoots(shadowRoot, roots);
  34. } while (walker.nextNode());
  35. return roots;
  36. };
  37. let documentRoots;
  38. const memoizedRoots = () => {
  39. var _documentRoots;
  40. return (_documentRoots = documentRoots) !== null && _documentRoots !== void 0 ? _documentRoots : documentRoots = collectRoots(document);
  41. };
  42. const styleTags = [];
  43. if (hideCaret) {
  44. for (const root of memoizedRoots()) {
  45. const styleTag = document.createElement('style');
  46. styleTag.textContent = `
  47. *:not(#playwright-aaaaaaaaaa.playwright-bbbbbbbbbbb.playwright-cccccccccc.playwright-dddddddddd.playwright-eeeeeeeee) {
  48. caret-color: transparent !important;
  49. }
  50. `;
  51. if (root === document) document.documentElement.append(styleTag);else root.append(styleTag);
  52. styleTags.push(styleTag);
  53. }
  54. }
  55. const infiniteAnimationsToResume = new Set();
  56. const cleanupCallbacks = [];
  57. if (disableAnimations) {
  58. const handleAnimations = root => {
  59. for (const animation of root.getAnimations()) {
  60. if (!animation.effect || animation.playbackRate === 0 || infiniteAnimationsToResume.has(animation)) continue;
  61. const endTime = animation.effect.getComputedTiming().endTime;
  62. if (Number.isFinite(endTime)) {
  63. try {
  64. animation.finish();
  65. } catch (e) {
  66. // animation.finish() should not throw for
  67. // finite animations, but we'd like to be on the
  68. // safe side.
  69. }
  70. } else {
  71. try {
  72. animation.cancel();
  73. infiniteAnimationsToResume.add(animation);
  74. } catch (e) {
  75. // animation.cancel() should not throw for
  76. // infinite animations, but we'd like to be on the
  77. // safe side.
  78. }
  79. }
  80. }
  81. };
  82. for (const root of memoizedRoots()) {
  83. const handleRootAnimations = handleAnimations.bind(null, root);
  84. handleRootAnimations();
  85. root.addEventListener('transitionrun', handleRootAnimations);
  86. root.addEventListener('animationstart', handleRootAnimations);
  87. cleanupCallbacks.push(() => {
  88. root.removeEventListener('transitionrun', handleRootAnimations);
  89. root.removeEventListener('animationstart', handleRootAnimations);
  90. });
  91. }
  92. }
  93. window.__cleanupScreenshot = () => {
  94. for (const styleTag of styleTags) styleTag.remove();
  95. for (const animation of infiniteAnimationsToResume) {
  96. try {
  97. animation.play();
  98. } catch (e) {
  99. // animation.play() should never throw, but
  100. // we'd like to be on the safe side.
  101. }
  102. }
  103. for (const cleanupCallback of cleanupCallbacks) cleanupCallback();
  104. delete window.__cleanupScreenshot;
  105. };
  106. }
  107. class Screenshotter {
  108. constructor(page) {
  109. this._queue = new TaskQueue();
  110. this._page = void 0;
  111. this._page = page;
  112. this._queue = new TaskQueue();
  113. }
  114. async _originalViewportSize(progress) {
  115. const originalViewportSize = this._page.viewportSize();
  116. let viewportSize = originalViewportSize;
  117. if (!viewportSize) viewportSize = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({
  118. width: window.innerWidth,
  119. height: window.innerHeight
  120. }));
  121. return {
  122. viewportSize,
  123. originalViewportSize
  124. };
  125. }
  126. async _fullPageSize(progress) {
  127. const fullPageSize = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => {
  128. if (!document.body || !document.documentElement) return null;
  129. return {
  130. width: Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.body.clientWidth, document.documentElement.clientWidth),
  131. height: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight)
  132. };
  133. });
  134. return fullPageSize;
  135. }
  136. async screenshotPage(progress, options) {
  137. const format = validateScreenshotOptions(options);
  138. return this._queue.postTask(async () => {
  139. progress.log('taking page screenshot');
  140. const {
  141. viewportSize
  142. } = await this._originalViewportSize(progress);
  143. await this._preparePageForScreenshot(progress, options.caret !== 'initial', options.animations === 'disabled');
  144. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  145. if (options.fullPage) {
  146. const fullPageSize = await this._fullPageSize(progress);
  147. let documentRect = {
  148. x: 0,
  149. y: 0,
  150. width: fullPageSize.width,
  151. height: fullPageSize.height
  152. };
  153. const fitsViewport = fullPageSize.width <= viewportSize.width && fullPageSize.height <= viewportSize.height;
  154. if (options.clip) documentRect = trimClipToSize(options.clip, documentRect);
  155. const buffer = await this._screenshot(progress, format, documentRect, undefined, fitsViewport, options);
  156. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  157. await this._restorePageAfterScreenshot();
  158. return buffer;
  159. }
  160. const viewportRect = options.clip ? trimClipToSize(options.clip, viewportSize) : {
  161. x: 0,
  162. y: 0,
  163. ...viewportSize
  164. };
  165. const buffer = await this._screenshot(progress, format, undefined, viewportRect, true, options);
  166. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  167. await this._restorePageAfterScreenshot();
  168. return buffer;
  169. });
  170. }
  171. async screenshotElement(progress, handle, options) {
  172. const format = validateScreenshotOptions(options);
  173. return this._queue.postTask(async () => {
  174. progress.log('taking element screenshot');
  175. const {
  176. viewportSize
  177. } = await this._originalViewportSize(progress);
  178. await this._preparePageForScreenshot(progress, options.caret !== 'initial', options.animations === 'disabled');
  179. progress.throwIfAborted(); // Do not do extra work.
  180. await handle._waitAndScrollIntoViewIfNeeded(progress, true /* waitForVisible */);
  181. progress.throwIfAborted(); // Do not do extra work.
  182. const boundingBox = await handle.boundingBox();
  183. (0, _utils.assert)(boundingBox, 'Node is either not visible or not an HTMLElement');
  184. (0, _utils.assert)(boundingBox.width !== 0, 'Node has 0 width.');
  185. (0, _utils.assert)(boundingBox.height !== 0, 'Node has 0 height.');
  186. const fitsViewport = boundingBox.width <= viewportSize.width && boundingBox.height <= viewportSize.height;
  187. progress.throwIfAborted(); // Avoid extra work.
  188. const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({
  189. x: window.scrollX,
  190. y: window.scrollY
  191. }));
  192. const documentRect = {
  193. ...boundingBox
  194. };
  195. documentRect.x += scrollOffset.x;
  196. documentRect.y += scrollOffset.y;
  197. const buffer = await this._screenshot(progress, format, _helper.helper.enclosingIntRect(documentRect), undefined, fitsViewport, options);
  198. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  199. await this._restorePageAfterScreenshot();
  200. return buffer;
  201. });
  202. }
  203. async _preparePageForScreenshot(progress, hideCaret, disableAnimations) {
  204. if (!hideCaret && !disableAnimations) return;
  205. if (disableAnimations) progress.log(' disabled all CSS animations');
  206. await Promise.all(this._page.frames().map(async frame => {
  207. await frame.nonStallingEvaluateInExistingContext('(' + inPagePrepareForScreenshots.toString() + `)(${hideCaret}, ${disableAnimations})`, false, 'utility').catch(() => {});
  208. }));
  209. progress.cleanupWhenAborted(() => this._restorePageAfterScreenshot());
  210. }
  211. async _restorePageAfterScreenshot() {
  212. await Promise.all(this._page.frames().map(async frame => {
  213. frame.nonStallingEvaluateInExistingContext('window.__cleanupScreenshot && window.__cleanupScreenshot()', false, 'utility').catch(() => {});
  214. }));
  215. }
  216. async _maskElements(progress, options) {
  217. const framesToParsedSelectors = new _multimap.MultiMap();
  218. const cleanup = async () => {
  219. await Promise.all([...framesToParsedSelectors.keys()].map(async frame => {
  220. await frame.hideHighlight();
  221. }));
  222. };
  223. if (!options.mask || !options.mask.length) return cleanup;
  224. await Promise.all((options.mask || []).map(async ({
  225. frame,
  226. selector
  227. }) => {
  228. const pair = await frame.selectors.resolveFrameForSelector(selector);
  229. if (pair) framesToParsedSelectors.set(pair.frame, pair.info.parsed);
  230. }));
  231. progress.throwIfAborted(); // Avoid extra work.
  232. await Promise.all([...framesToParsedSelectors.keys()].map(async frame => {
  233. await frame.maskSelectors(framesToParsedSelectors.get(frame), options.maskColor);
  234. }));
  235. progress.cleanupWhenAborted(cleanup);
  236. return cleanup;
  237. }
  238. async _screenshot(progress, format, documentRect, viewportRect, fitsViewport, options) {
  239. var _options$quality;
  240. if (options.__testHookBeforeScreenshot) await options.__testHookBeforeScreenshot();
  241. progress.throwIfAborted(); // Screenshotting is expensive - avoid extra work.
  242. const shouldSetDefaultBackground = options.omitBackground && format === 'png';
  243. if (shouldSetDefaultBackground) {
  244. await this._page._delegate.setBackgroundColor({
  245. r: 0,
  246. g: 0,
  247. b: 0,
  248. a: 0
  249. });
  250. progress.cleanupWhenAborted(() => this._page._delegate.setBackgroundColor());
  251. }
  252. progress.throwIfAborted(); // Avoid extra work.
  253. const cleanupHighlight = await this._maskElements(progress, options);
  254. progress.throwIfAborted(); // Avoid extra work.
  255. const quality = format === 'jpeg' ? (_options$quality = options.quality) !== null && _options$quality !== void 0 ? _options$quality : 80 : undefined;
  256. const buffer = await this._page._delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device');
  257. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  258. await cleanupHighlight();
  259. progress.throwIfAborted(); // Avoid restoring after failure - should be done by cleanup.
  260. if (shouldSetDefaultBackground) await this._page._delegate.setBackgroundColor();
  261. progress.throwIfAborted(); // Avoid side effects.
  262. if (options.__testHookAfterScreenshot) await options.__testHookAfterScreenshot();
  263. return buffer;
  264. }
  265. }
  266. exports.Screenshotter = Screenshotter;
  267. class TaskQueue {
  268. constructor() {
  269. this._chain = void 0;
  270. this._chain = Promise.resolve();
  271. }
  272. postTask(task) {
  273. const result = this._chain.then(task);
  274. this._chain = result.catch(() => {});
  275. return result;
  276. }
  277. }
  278. function trimClipToSize(clip, size) {
  279. const p1 = {
  280. x: Math.max(0, Math.min(clip.x, size.width)),
  281. y: Math.max(0, Math.min(clip.y, size.height))
  282. };
  283. const p2 = {
  284. x: Math.max(0, Math.min(clip.x + clip.width, size.width)),
  285. y: Math.max(0, Math.min(clip.y + clip.height, size.height))
  286. };
  287. const result = {
  288. x: p1.x,
  289. y: p1.y,
  290. width: p2.x - p1.x,
  291. height: p2.y - p1.y
  292. };
  293. (0, _utils.assert)(result.width && result.height, 'Clipped area is either empty or outside the resulting image');
  294. return result;
  295. }
  296. function validateScreenshotOptions(options) {
  297. let format = null;
  298. // options.type takes precedence over inferring the type from options.path
  299. // because it may be a 0-length file with no extension created beforehand (i.e. as a temp file).
  300. if (options.type) {
  301. (0, _utils.assert)(options.type === 'png' || options.type === 'jpeg', 'Unknown options.type value: ' + options.type);
  302. format = options.type;
  303. }
  304. if (!format) format = 'png';
  305. if (options.quality !== undefined) {
  306. (0, _utils.assert)(format === 'jpeg', 'options.quality is unsupported for the ' + format + ' screenshots');
  307. (0, _utils.assert)(typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + typeof options.quality);
  308. (0, _utils.assert)(Number.isInteger(options.quality), 'Expected options.quality to be an integer');
  309. (0, _utils.assert)(options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality);
  310. }
  311. if (options.clip) {
  312. (0, _utils.assert)(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + typeof options.clip.x);
  313. (0, _utils.assert)(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + typeof options.clip.y);
  314. (0, _utils.assert)(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + typeof options.clip.width);
  315. (0, _utils.assert)(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + typeof options.clip.height);
  316. (0, _utils.assert)(options.clip.width !== 0, 'Expected options.clip.width not to be 0.');
  317. (0, _utils.assert)(options.clip.height !== 0, 'Expected options.clip.height not to be 0.');
  318. }
  319. return format;
  320. }