JSHandle.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. /**
  2. * Copyright 2019 Google Inc. All rights reserved.
  3. *
  4. * Licensed under the Apache License, Version 2.0 (the "License");
  5. * you may not use this file except in compliance with the License.
  6. * You may obtain a copy of the License at
  7. *
  8. * http://www.apache.org/licenses/LICENSE-2.0
  9. *
  10. * Unless required by applicable law or agreed to in writing, software
  11. * distributed under the License is distributed on an "AS IS" BASIS,
  12. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. * See the License for the specific language governing permissions and
  14. * limitations under the License.
  15. */
  16. const {helper, assert, debugError} = require('./helper');
  17. function createJSHandle(context, remoteObject) {
  18. const frame = context.frame();
  19. if (remoteObject.subtype === 'node' && frame) {
  20. const frameManager = frame._frameManager;
  21. return new ElementHandle(context, context._client, remoteObject, frameManager.page(), frameManager);
  22. }
  23. return new JSHandle(context, context._client, remoteObject);
  24. }
  25. class JSHandle {
  26. /**
  27. * @param {!Puppeteer.ExecutionContext} context
  28. * @param {!Puppeteer.CDPSession} client
  29. * @param {!Protocol.Runtime.RemoteObject} remoteObject
  30. */
  31. constructor(context, client, remoteObject) {
  32. this._context = context;
  33. this._client = client;
  34. this._remoteObject = remoteObject;
  35. this._disposed = false;
  36. }
  37. /**
  38. * @return {!Puppeteer.ExecutionContext}
  39. */
  40. executionContext() {
  41. return this._context;
  42. }
  43. /**
  44. * @param {Function|String} pageFunction
  45. * @param {!Array<*>} args
  46. * @return {!Promise<(!Object|undefined)>}
  47. */
  48. async evaluate(pageFunction, ...args) {
  49. return await this.executionContext().evaluate(pageFunction, this, ...args);
  50. }
  51. /**
  52. * @param {Function|string} pageFunction
  53. * @param {!Array<*>} args
  54. * @return {!Promise<!Puppeteer.JSHandle>}
  55. */
  56. async evaluateHandle(pageFunction, ...args) {
  57. return await this.executionContext().evaluateHandle(pageFunction, this, ...args);
  58. }
  59. /**
  60. * @param {string} propertyName
  61. * @return {!Promise<?JSHandle>}
  62. */
  63. async getProperty(propertyName) {
  64. const objectHandle = await this.evaluateHandle((object, propertyName) => {
  65. const result = {__proto__: null};
  66. result[propertyName] = object[propertyName];
  67. return result;
  68. }, propertyName);
  69. const properties = await objectHandle.getProperties();
  70. const result = properties.get(propertyName) || null;
  71. await objectHandle.dispose();
  72. return result;
  73. }
  74. /**
  75. * @return {!Promise<!Map<string, !JSHandle>>}
  76. */
  77. async getProperties() {
  78. const response = await this._client.send('Runtime.getProperties', {
  79. objectId: this._remoteObject.objectId,
  80. ownProperties: true
  81. });
  82. const result = new Map();
  83. for (const property of response.result) {
  84. if (!property.enumerable)
  85. continue;
  86. result.set(property.name, createJSHandle(this._context, property.value));
  87. }
  88. return result;
  89. }
  90. /**
  91. * @return {!Promise<?Object>}
  92. */
  93. async jsonValue() {
  94. if (this._remoteObject.objectId) {
  95. const response = await this._client.send('Runtime.callFunctionOn', {
  96. functionDeclaration: 'function() { return this; }',
  97. objectId: this._remoteObject.objectId,
  98. returnByValue: true,
  99. awaitPromise: true,
  100. });
  101. return helper.valueFromRemoteObject(response.result);
  102. }
  103. return helper.valueFromRemoteObject(this._remoteObject);
  104. }
  105. /**
  106. * @return {?Puppeteer.ElementHandle}
  107. */
  108. asElement() {
  109. return null;
  110. }
  111. async dispose() {
  112. if (this._disposed)
  113. return;
  114. this._disposed = true;
  115. await helper.releaseObject(this._client, this._remoteObject);
  116. }
  117. /**
  118. * @override
  119. * @return {string}
  120. */
  121. toString() {
  122. if (this._remoteObject.objectId) {
  123. const type = this._remoteObject.subtype || this._remoteObject.type;
  124. return 'JSHandle@' + type;
  125. }
  126. return 'JSHandle:' + helper.valueFromRemoteObject(this._remoteObject);
  127. }
  128. }
  129. class ElementHandle extends JSHandle {
  130. /**
  131. * @param {!Puppeteer.ExecutionContext} context
  132. * @param {!Puppeteer.CDPSession} client
  133. * @param {!Protocol.Runtime.RemoteObject} remoteObject
  134. * @param {!Puppeteer.Page} page
  135. * @param {!Puppeteer.FrameManager} frameManager
  136. */
  137. constructor(context, client, remoteObject, page, frameManager) {
  138. super(context, client, remoteObject);
  139. this._client = client;
  140. this._remoteObject = remoteObject;
  141. this._page = page;
  142. this._frameManager = frameManager;
  143. this._disposed = false;
  144. }
  145. /**
  146. * @override
  147. * @return {?ElementHandle}
  148. */
  149. asElement() {
  150. return this;
  151. }
  152. /**
  153. * @return {!Promise<?Puppeteer.Frame>}
  154. */
  155. async contentFrame() {
  156. const nodeInfo = await this._client.send('DOM.describeNode', {
  157. objectId: this._remoteObject.objectId
  158. });
  159. if (typeof nodeInfo.node.frameId !== 'string')
  160. return null;
  161. return this._frameManager.frame(nodeInfo.node.frameId);
  162. }
  163. async _scrollIntoViewIfNeeded() {
  164. const error = await this.evaluate(async(element, pageJavascriptEnabled) => {
  165. if (!element.isConnected)
  166. return 'Node is detached from document';
  167. if (element.nodeType !== Node.ELEMENT_NODE)
  168. return 'Node is not of type HTMLElement';
  169. // force-scroll if page's javascript is disabled.
  170. if (!pageJavascriptEnabled) {
  171. element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
  172. return false;
  173. }
  174. const visibleRatio = await new Promise(resolve => {
  175. const observer = new IntersectionObserver(entries => {
  176. resolve(entries[0].intersectionRatio);
  177. observer.disconnect();
  178. });
  179. observer.observe(element);
  180. });
  181. if (visibleRatio !== 1.0)
  182. element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
  183. return false;
  184. }, this._page._javascriptEnabled);
  185. if (error)
  186. throw new Error(error);
  187. }
  188. /**
  189. * @return {!Promise<!{x: number, y: number}>}
  190. */
  191. async _clickablePoint() {
  192. const [result, layoutMetrics] = await Promise.all([
  193. this._client.send('DOM.getContentQuads', {
  194. objectId: this._remoteObject.objectId
  195. }).catch(debugError),
  196. this._client.send('Page.getLayoutMetrics'),
  197. ]);
  198. if (!result || !result.quads.length)
  199. throw new Error('Node is either not visible or not an HTMLElement');
  200. // Filter out quads that have too small area to click into.
  201. const {clientWidth, clientHeight} = layoutMetrics.layoutViewport;
  202. const quads = result.quads.map(quad => this._fromProtocolQuad(quad)).map(quad => this._intersectQuadWithViewport(quad, clientWidth, clientHeight)).filter(quad => computeQuadArea(quad) > 1);
  203. if (!quads.length)
  204. throw new Error('Node is either not visible or not an HTMLElement');
  205. // Return the middle point of the first quad.
  206. const quad = quads[0];
  207. let x = 0;
  208. let y = 0;
  209. for (const point of quad) {
  210. x += point.x;
  211. y += point.y;
  212. }
  213. return {
  214. x: x / 4,
  215. y: y / 4
  216. };
  217. }
  218. /**
  219. * @return {!Promise<void|Protocol.DOM.getBoxModelReturnValue>}
  220. */
  221. _getBoxModel() {
  222. return this._client.send('DOM.getBoxModel', {
  223. objectId: this._remoteObject.objectId
  224. }).catch(error => debugError(error));
  225. }
  226. /**
  227. * @param {!Array<number>} quad
  228. * @return {!Array<{x: number, y: number}>}
  229. */
  230. _fromProtocolQuad(quad) {
  231. return [
  232. {x: quad[0], y: quad[1]},
  233. {x: quad[2], y: quad[3]},
  234. {x: quad[4], y: quad[5]},
  235. {x: quad[6], y: quad[7]}
  236. ];
  237. }
  238. /**
  239. * @param {!Array<{x: number, y: number}>} quad
  240. * @param {number} width
  241. * @param {number} height
  242. * @return {!Array<{x: number, y: number}>}
  243. */
  244. _intersectQuadWithViewport(quad, width, height) {
  245. return quad.map(point => ({
  246. x: Math.min(Math.max(point.x, 0), width),
  247. y: Math.min(Math.max(point.y, 0), height),
  248. }));
  249. }
  250. async hover() {
  251. await this._scrollIntoViewIfNeeded();
  252. const {x, y} = await this._clickablePoint();
  253. await this._page.mouse.move(x, y);
  254. }
  255. /**
  256. * @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options
  257. */
  258. async click(options) {
  259. await this._scrollIntoViewIfNeeded();
  260. const {x, y} = await this._clickablePoint();
  261. await this._page.mouse.click(x, y, options);
  262. }
  263. /**
  264. * @param {!Array<string>} values
  265. * @return {!Promise<!Array<string>>}
  266. */
  267. async select(...values) {
  268. for (const value of values)
  269. assert(helper.isString(value), 'Values must be strings. Found value "' + value + '" of type "' + (typeof value) + '"');
  270. return this.evaluate((element, values) => {
  271. if (element.nodeName.toLowerCase() !== 'select')
  272. throw new Error('Element is not a <select> element.');
  273. const options = Array.from(element.options);
  274. element.value = undefined;
  275. for (const option of options) {
  276. option.selected = values.includes(option.value);
  277. if (option.selected && !element.multiple)
  278. break;
  279. }
  280. element.dispatchEvent(new Event('input', { bubbles: true }));
  281. element.dispatchEvent(new Event('change', { bubbles: true }));
  282. return options.filter(option => option.selected).map(option => option.value);
  283. }, values);
  284. }
  285. /**
  286. * @param {!Array<string>} filePaths
  287. */
  288. async uploadFile(...filePaths) {
  289. const isMultiple = await this.evaluate(element => element.multiple);
  290. assert(filePaths.length <= 1 || isMultiple, 'Multiple file uploads only work with <input type=file multiple>');
  291. // These imports are only needed for `uploadFile`, so keep them
  292. // scoped here to avoid paying the cost unnecessarily.
  293. const path = require('path');
  294. const mime = require('mime-types');
  295. const fs = require('fs');
  296. const readFileAsync = helper.promisify(fs.readFile);
  297. const promises = filePaths.map(filePath => readFileAsync(filePath));
  298. const files = [];
  299. for (let i = 0; i < filePaths.length; i++) {
  300. const buffer = await promises[i];
  301. const filePath = path.basename(filePaths[i]);
  302. const file = {
  303. name: filePath,
  304. content: buffer.toString('base64'),
  305. mimeType: mime.lookup(filePath),
  306. };
  307. files.push(file);
  308. }
  309. await this.evaluateHandle(async(element, files) => {
  310. const dt = new DataTransfer();
  311. for (const item of files) {
  312. const response = await fetch(`data:${item.mimeType};base64,${item.content}`);
  313. const file = new File([await response.blob()], item.name);
  314. dt.items.add(file);
  315. }
  316. element.files = dt.files;
  317. element.dispatchEvent(new Event('input', { bubbles: true }));
  318. }, files);
  319. }
  320. async tap() {
  321. await this._scrollIntoViewIfNeeded();
  322. const {x, y} = await this._clickablePoint();
  323. await this._page.touchscreen.tap(x, y);
  324. }
  325. async focus() {
  326. await this.evaluate(element => element.focus());
  327. }
  328. /**
  329. * @param {string} text
  330. * @param {{delay: (number|undefined)}=} options
  331. */
  332. async type(text, options) {
  333. await this.focus();
  334. await this._page.keyboard.type(text, options);
  335. }
  336. /**
  337. * @param {string} key
  338. * @param {!{delay?: number, text?: string}=} options
  339. */
  340. async press(key, options) {
  341. await this.focus();
  342. await this._page.keyboard.press(key, options);
  343. }
  344. /**
  345. * @return {!Promise<?{x: number, y: number, width: number, height: number}>}
  346. */
  347. async boundingBox() {
  348. const result = await this._getBoxModel();
  349. if (!result)
  350. return null;
  351. const quad = result.model.border;
  352. const x = Math.min(quad[0], quad[2], quad[4], quad[6]);
  353. const y = Math.min(quad[1], quad[3], quad[5], quad[7]);
  354. const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x;
  355. const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y;
  356. return {x, y, width, height};
  357. }
  358. /**
  359. * @return {!Promise<?BoxModel>}
  360. */
  361. async boxModel() {
  362. const result = await this._getBoxModel();
  363. if (!result)
  364. return null;
  365. const {content, padding, border, margin, width, height} = result.model;
  366. return {
  367. content: this._fromProtocolQuad(content),
  368. padding: this._fromProtocolQuad(padding),
  369. border: this._fromProtocolQuad(border),
  370. margin: this._fromProtocolQuad(margin),
  371. width,
  372. height
  373. };
  374. }
  375. /**
  376. *
  377. * @param {!Object=} options
  378. * @returns {!Promise<string|!Buffer>}
  379. */
  380. async screenshot(options = {}) {
  381. let needsViewportReset = false;
  382. let boundingBox = await this.boundingBox();
  383. assert(boundingBox, 'Node is either not visible or not an HTMLElement');
  384. const viewport = this._page.viewport();
  385. if (viewport && (boundingBox.width > viewport.width || boundingBox.height > viewport.height)) {
  386. const newViewport = {
  387. width: Math.max(viewport.width, Math.ceil(boundingBox.width)),
  388. height: Math.max(viewport.height, Math.ceil(boundingBox.height)),
  389. };
  390. await this._page.setViewport(Object.assign({}, viewport, newViewport));
  391. needsViewportReset = true;
  392. }
  393. await this._scrollIntoViewIfNeeded();
  394. boundingBox = await this.boundingBox();
  395. assert(boundingBox, 'Node is either not visible or not an HTMLElement');
  396. assert(boundingBox.width !== 0, 'Node has 0 width.');
  397. assert(boundingBox.height !== 0, 'Node has 0 height.');
  398. const { layoutViewport: { pageX, pageY } } = await this._client.send('Page.getLayoutMetrics');
  399. const clip = Object.assign({}, boundingBox);
  400. clip.x += pageX;
  401. clip.y += pageY;
  402. const imageData = await this._page.screenshot(Object.assign({}, {
  403. clip
  404. }, options));
  405. if (needsViewportReset)
  406. await this._page.setViewport(viewport);
  407. return imageData;
  408. }
  409. /**
  410. * @param {string} selector
  411. * @return {!Promise<?ElementHandle>}
  412. */
  413. async $(selector) {
  414. const handle = await this.evaluateHandle(
  415. (element, selector) => element.querySelector(selector),
  416. selector
  417. );
  418. const element = handle.asElement();
  419. if (element)
  420. return element;
  421. await handle.dispose();
  422. return null;
  423. }
  424. /**
  425. * @param {string} selector
  426. * @return {!Promise<!Array<!ElementHandle>>}
  427. */
  428. async $$(selector) {
  429. const arrayHandle = await this.evaluateHandle(
  430. (element, selector) => element.querySelectorAll(selector),
  431. selector
  432. );
  433. const properties = await arrayHandle.getProperties();
  434. await arrayHandle.dispose();
  435. const result = [];
  436. for (const property of properties.values()) {
  437. const elementHandle = property.asElement();
  438. if (elementHandle)
  439. result.push(elementHandle);
  440. }
  441. return result;
  442. }
  443. /**
  444. * @param {string} selector
  445. * @param {Function|String} pageFunction
  446. * @param {!Array<*>} args
  447. * @return {!Promise<(!Object|undefined)>}
  448. */
  449. async $eval(selector, pageFunction, ...args) {
  450. const elementHandle = await this.$(selector);
  451. if (!elementHandle)
  452. throw new Error(`Error: failed to find element matching selector "${selector}"`);
  453. const result = await elementHandle.evaluate(pageFunction, ...args);
  454. await elementHandle.dispose();
  455. return result;
  456. }
  457. /**
  458. * @param {string} selector
  459. * @param {Function|String} pageFunction
  460. * @param {!Array<*>} args
  461. * @return {!Promise<(!Object|undefined)>}
  462. */
  463. async $$eval(selector, pageFunction, ...args) {
  464. const arrayHandle = await this.evaluateHandle(
  465. (element, selector) => Array.from(element.querySelectorAll(selector)),
  466. selector
  467. );
  468. const result = await arrayHandle.evaluate(pageFunction, ...args);
  469. await arrayHandle.dispose();
  470. return result;
  471. }
  472. /**
  473. * @param {string} expression
  474. * @return {!Promise<!Array<!ElementHandle>>}
  475. */
  476. async $x(expression) {
  477. const arrayHandle = await this.evaluateHandle(
  478. (element, expression) => {
  479. const document = element.ownerDocument || element;
  480. const iterator = document.evaluate(expression, element, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
  481. const array = [];
  482. let item;
  483. while ((item = iterator.iterateNext()))
  484. array.push(item);
  485. return array;
  486. },
  487. expression
  488. );
  489. const properties = await arrayHandle.getProperties();
  490. await arrayHandle.dispose();
  491. const result = [];
  492. for (const property of properties.values()) {
  493. const elementHandle = property.asElement();
  494. if (elementHandle)
  495. result.push(elementHandle);
  496. }
  497. return result;
  498. }
  499. /**
  500. * @returns {!Promise<boolean>}
  501. */
  502. isIntersectingViewport() {
  503. return this.evaluate(async element => {
  504. const visibleRatio = await new Promise(resolve => {
  505. const observer = new IntersectionObserver(entries => {
  506. resolve(entries[0].intersectionRatio);
  507. observer.disconnect();
  508. });
  509. observer.observe(element);
  510. });
  511. return visibleRatio > 0;
  512. });
  513. }
  514. }
  515. function computeQuadArea(quad) {
  516. // Compute sum of all directed areas of adjacent triangles
  517. // https://en.wikipedia.org/wiki/Polygon#Simple_polygons
  518. let area = 0;
  519. for (let i = 0; i < quad.length; ++i) {
  520. const p1 = quad[i];
  521. const p2 = quad[(i + 1) % quad.length];
  522. area += (p1.x * p2.y - p2.x * p1.y) / 2;
  523. }
  524. return Math.abs(area);
  525. }
  526. /**
  527. * @typedef {Object} BoxModel
  528. * @property {!Array<!{x: number, y: number}>} content
  529. * @property {!Array<!{x: number, y: number}>} padding
  530. * @property {!Array<!{x: number, y: number}>} border
  531. * @property {!Array<!{x: number, y: number}>} margin
  532. * @property {number} width
  533. * @property {number} height
  534. */
  535. module.exports = {createJSHandle, JSHandle, ElementHandle};