1/** 2 * Copyright 2018 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 17import expect from 'expect'; 18import sinon from 'sinon'; 19import { 20 getTestState, 21 setupTestBrowserHooks, 22 setupTestPageAndContextHooks, 23 describeFailsFirefox, 24 itFailsFirefox, 25} from './mocha-utils'; // eslint-disable-line import/extensions 26 27import utils from './utils.js'; 28import { ElementHandle } from '../lib/cjs/puppeteer/common/JSHandle.js'; 29 30describe('ElementHandle specs', function () { 31 setupTestBrowserHooks(); 32 setupTestPageAndContextHooks(); 33 34 describe('ElementHandle.boundingBox', function () { 35 it('should work', async () => { 36 const { page, server } = getTestState(); 37 38 await page.setViewport({ width: 500, height: 500 }); 39 await page.goto(server.PREFIX + '/grid.html'); 40 const elementHandle = await page.$('.box:nth-of-type(13)'); 41 const box = await elementHandle.boundingBox(); 42 expect(box).toEqual({ x: 100, y: 50, width: 50, height: 50 }); 43 }); 44 it('should handle nested frames', async () => { 45 const { page, server, isChrome } = getTestState(); 46 47 await page.setViewport({ width: 500, height: 500 }); 48 await page.goto(server.PREFIX + '/frames/nested-frames.html'); 49 const nestedFrame = page.frames()[1].childFrames()[1]; 50 const elementHandle = await nestedFrame.$('div'); 51 const box = await elementHandle.boundingBox(); 52 if (isChrome) 53 expect(box).toEqual({ x: 28, y: 182, width: 264, height: 18 }); 54 else expect(box).toEqual({ x: 28, y: 182, width: 254, height: 18 }); 55 }); 56 it('should return null for invisible elements', async () => { 57 const { page } = getTestState(); 58 59 await page.setContent('<div style="display:none">hi</div>'); 60 const element = await page.$('div'); 61 expect(await element.boundingBox()).toBe(null); 62 }); 63 it('should force a layout', async () => { 64 const { page } = getTestState(); 65 66 await page.setViewport({ width: 500, height: 500 }); 67 await page.setContent( 68 '<div style="width: 100px; height: 100px">hello</div>' 69 ); 70 const elementHandle = await page.$('div'); 71 await page.evaluate<(element: HTMLElement) => void>( 72 (element) => (element.style.height = '200px'), 73 elementHandle 74 ); 75 const box = await elementHandle.boundingBox(); 76 expect(box).toEqual({ x: 8, y: 8, width: 100, height: 200 }); 77 }); 78 it('should work with SVG nodes', async () => { 79 const { page } = getTestState(); 80 81 await page.setContent(` 82 <svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"> 83 <rect id="theRect" x="30" y="50" width="200" height="300"></rect> 84 </svg> 85 `); 86 const element = await page.$('#therect'); 87 const pptrBoundingBox = await element.boundingBox(); 88 const webBoundingBox = await page.evaluate((e: HTMLElement) => { 89 const rect = e.getBoundingClientRect(); 90 return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; 91 }, element); 92 expect(pptrBoundingBox).toEqual(webBoundingBox); 93 }); 94 }); 95 96 describe('ElementHandle.boxModel', function () { 97 it('should work', async () => { 98 const { page, server } = getTestState(); 99 100 await page.goto(server.PREFIX + '/resetcss.html'); 101 102 // Step 1: Add Frame and position it absolutely. 103 await utils.attachFrame(page, 'frame1', server.PREFIX + '/resetcss.html'); 104 await page.evaluate(() => { 105 const frame = document.querySelector<HTMLElement>('#frame1'); 106 frame.style.position = 'absolute'; 107 frame.style.left = '1px'; 108 frame.style.top = '2px'; 109 }); 110 111 // Step 2: Add div and position it absolutely inside frame. 112 const frame = page.frames()[1]; 113 const divHandle = ( 114 await frame.evaluateHandle(() => { 115 const div = document.createElement('div'); 116 document.body.appendChild(div); 117 div.style.boxSizing = 'border-box'; 118 div.style.position = 'absolute'; 119 div.style.borderLeft = '1px solid black'; 120 div.style.paddingLeft = '2px'; 121 div.style.marginLeft = '3px'; 122 div.style.left = '4px'; 123 div.style.top = '5px'; 124 div.style.width = '6px'; 125 div.style.height = '7px'; 126 return div; 127 }) 128 ).asElement(); 129 130 // Step 3: query div's boxModel and assert box values. 131 const box = await divHandle.boxModel(); 132 expect(box.width).toBe(6); 133 expect(box.height).toBe(7); 134 expect(box.margin[0]).toEqual({ 135 x: 1 + 4, // frame.left + div.left 136 y: 2 + 5, 137 }); 138 expect(box.border[0]).toEqual({ 139 x: 1 + 4 + 3, // frame.left + div.left + div.margin-left 140 y: 2 + 5, 141 }); 142 expect(box.padding[0]).toEqual({ 143 x: 1 + 4 + 3 + 1, // frame.left + div.left + div.marginLeft + div.borderLeft 144 y: 2 + 5, 145 }); 146 expect(box.content[0]).toEqual({ 147 x: 1 + 4 + 3 + 1 + 2, // frame.left + div.left + div.marginLeft + div.borderLeft + dif.paddingLeft 148 y: 2 + 5, 149 }); 150 }); 151 152 it('should return null for invisible elements', async () => { 153 const { page } = getTestState(); 154 155 await page.setContent('<div style="display:none">hi</div>'); 156 const element = await page.$('div'); 157 expect(await element.boxModel()).toBe(null); 158 }); 159 }); 160 161 describe('ElementHandle.contentFrame', function () { 162 it('should work', async () => { 163 const { page, server } = getTestState(); 164 165 await page.goto(server.EMPTY_PAGE); 166 await utils.attachFrame(page, 'frame1', server.EMPTY_PAGE); 167 const elementHandle = await page.$('#frame1'); 168 const frame = await elementHandle.contentFrame(); 169 expect(frame).toBe(page.frames()[1]); 170 }); 171 }); 172 173 describe('ElementHandle.click', function () { 174 // See https://github.com/puppeteer/puppeteer/issues/7175 175 it('should work', async () => { 176 const { page, server } = getTestState(); 177 178 await page.goto(server.PREFIX + '/input/button.html'); 179 const button = await page.$('button'); 180 await button.click(); 181 expect(await page.evaluate(() => globalThis.result)).toBe('Clicked'); 182 }); 183 it('should work for Shadow DOM v1', async () => { 184 const { page, server } = getTestState(); 185 186 await page.goto(server.PREFIX + '/shadow.html'); 187 const buttonHandle = await page.evaluateHandle<ElementHandle>( 188 // @ts-expect-error button is expected to be in the page's scope. 189 () => button 190 ); 191 await buttonHandle.click(); 192 expect( 193 await page.evaluate( 194 // @ts-expect-error clicked is expected to be in the page's scope. 195 () => clicked 196 ) 197 ).toBe(true); 198 }); 199 it('should work for TextNodes', async () => { 200 const { page, server } = getTestState(); 201 202 await page.goto(server.PREFIX + '/input/button.html'); 203 const buttonTextNode = await page.evaluateHandle<ElementHandle>( 204 () => document.querySelector('button').firstChild 205 ); 206 let error = null; 207 await buttonTextNode.click().catch((error_) => (error = error_)); 208 expect(error.message).toBe('Node is not of type HTMLElement'); 209 }); 210 it('should throw for detached nodes', async () => { 211 const { page, server } = getTestState(); 212 213 await page.goto(server.PREFIX + '/input/button.html'); 214 const button = await page.$('button'); 215 await page.evaluate((button: HTMLElement) => button.remove(), button); 216 let error = null; 217 await button.click().catch((error_) => (error = error_)); 218 expect(error.message).toBe('Node is detached from document'); 219 }); 220 it('should throw for hidden nodes', async () => { 221 const { page, server } = getTestState(); 222 223 await page.goto(server.PREFIX + '/input/button.html'); 224 const button = await page.$('button'); 225 await page.evaluate( 226 (button: HTMLElement) => (button.style.display = 'none'), 227 button 228 ); 229 const error = await button.click().catch((error_) => error_); 230 expect(error.message).toBe( 231 'Node is either not clickable or not an HTMLElement' 232 ); 233 }); 234 it('should throw for recursively hidden nodes', async () => { 235 const { page, server } = getTestState(); 236 237 await page.goto(server.PREFIX + '/input/button.html'); 238 const button = await page.$('button'); 239 await page.evaluate( 240 (button: HTMLElement) => (button.parentElement.style.display = 'none'), 241 button 242 ); 243 const error = await button.click().catch((error_) => error_); 244 expect(error.message).toBe( 245 'Node is either not clickable or not an HTMLElement' 246 ); 247 }); 248 it('should throw for <br> elements', async () => { 249 const { page } = getTestState(); 250 251 await page.setContent('hello<br>goodbye'); 252 const br = await page.$('br'); 253 const error = await br.click().catch((error_) => error_); 254 expect(error.message).toBe( 255 'Node is either not clickable or not an HTMLElement' 256 ); 257 }); 258 }); 259 260 describe('Element.waitForSelector', () => { 261 it('should wait correctly with waitForSelector on an element', async () => { 262 const { page } = getTestState(); 263 const waitFor = page.waitForSelector('.foo'); 264 // Set the page content after the waitFor has been started. 265 await page.setContent( 266 '<div id="not-foo"></div><div class="bar">bar2</div><div class="foo">Foo1</div>' 267 ); 268 let element = await waitFor; 269 expect(element).toBeDefined(); 270 271 const innerWaitFor = element.waitForSelector('.bar'); 272 await element.evaluate((el) => { 273 el.innerHTML = '<div class="bar">bar1</div>'; 274 }); 275 element = await innerWaitFor; 276 expect(element).toBeDefined(); 277 expect( 278 await element.evaluate((el: HTMLElement) => el.innerText) 279 ).toStrictEqual('bar1'); 280 }); 281 }); 282 283 describe('ElementHandle.hover', function () { 284 it('should work', async () => { 285 const { page, server } = getTestState(); 286 287 await page.goto(server.PREFIX + '/input/scrollable.html'); 288 const button = await page.$('#button-6'); 289 await button.hover(); 290 expect( 291 await page.evaluate(() => document.querySelector('button:hover').id) 292 ).toBe('button-6'); 293 }); 294 }); 295 296 describe('ElementHandle.isIntersectingViewport', function () { 297 it('should work', async () => { 298 const { page, server } = getTestState(); 299 300 await page.goto(server.PREFIX + '/offscreenbuttons.html'); 301 for (let i = 0; i < 11; ++i) { 302 const button = await page.$('#btn' + i); 303 // All but last button are visible. 304 const visible = i < 10; 305 expect(await button.isIntersectingViewport()).toBe(visible); 306 } 307 }); 308 it('should work with threshold', async () => { 309 const { page, server } = getTestState(); 310 311 await page.goto(server.PREFIX + '/offscreenbuttons.html'); 312 // a button almost cannot be seen 313 // sometimes we expect to return false by isIntersectingViewport1 314 const button = await page.$('#btn11'); 315 expect( 316 await button.isIntersectingViewport({ 317 threshold: 0.001, 318 }) 319 ).toBe(false); 320 }); 321 it('should work with threshold of 1', async () => { 322 const { page, server } = getTestState(); 323 324 await page.goto(server.PREFIX + '/offscreenbuttons.html'); 325 // a button almost cannot be seen 326 // sometimes we expect to return false by isIntersectingViewport1 327 const button = await page.$('#btn0'); 328 expect( 329 await button.isIntersectingViewport({ 330 threshold: 1, 331 }) 332 ).toBe(true); 333 }); 334 }); 335 336 describe('Custom queries', function () { 337 this.afterEach(() => { 338 const { puppeteer } = getTestState(); 339 puppeteer.clearCustomQueryHandlers(); 340 }); 341 it('should register and unregister', async () => { 342 const { page, puppeteer } = getTestState(); 343 await page.setContent('<div id="not-foo"></div><div id="foo"></div>'); 344 345 // Register. 346 puppeteer.registerCustomQueryHandler('getById', { 347 queryOne: (element, selector) => 348 document.querySelector(`[id="${selector}"]`), 349 }); 350 const element = await page.$('getById/foo'); 351 expect( 352 await page.evaluate<(element: HTMLElement) => string>( 353 (element) => element.id, 354 element 355 ) 356 ).toBe('foo'); 357 const handlerNamesAfterRegistering = puppeteer.customQueryHandlerNames(); 358 expect(handlerNamesAfterRegistering.includes('getById')).toBeTruthy(); 359 360 // Unregister. 361 puppeteer.unregisterCustomQueryHandler('getById'); 362 try { 363 await page.$('getById/foo'); 364 throw new Error('Custom query handler name not set - throw expected'); 365 } catch (error) { 366 expect(error).toStrictEqual( 367 new Error( 368 'Query set to use "getById", but no query handler of that name was found' 369 ) 370 ); 371 } 372 const handlerNamesAfterUnregistering = 373 puppeteer.customQueryHandlerNames(); 374 expect(handlerNamesAfterUnregistering.includes('getById')).toBeFalsy(); 375 }); 376 it('should throw with invalid query names', () => { 377 try { 378 const { puppeteer } = getTestState(); 379 puppeteer.registerCustomQueryHandler('1/2/3', { 380 queryOne: () => document.querySelector('foo'), 381 }); 382 throw new Error( 383 'Custom query handler name was invalid - throw expected' 384 ); 385 } catch (error) { 386 expect(error).toStrictEqual( 387 new Error('Custom query handler names may only contain [a-zA-Z]') 388 ); 389 } 390 }); 391 it('should work for multiple elements', async () => { 392 const { page, puppeteer } = getTestState(); 393 await page.setContent( 394 '<div id="not-foo"></div><div class="foo">Foo1</div><div class="foo baz">Foo2</div>' 395 ); 396 puppeteer.registerCustomQueryHandler('getByClass', { 397 queryAll: (element, selector) => 398 document.querySelectorAll(`.${selector}`), 399 }); 400 const elements = await page.$$('getByClass/foo'); 401 const classNames = await Promise.all( 402 elements.map( 403 async (element) => 404 await page.evaluate<(element: HTMLElement) => string>( 405 (element) => element.className, 406 element 407 ) 408 ) 409 ); 410 411 expect(classNames).toStrictEqual(['foo', 'foo baz']); 412 }); 413 it('should eval correctly', async () => { 414 const { page, puppeteer } = getTestState(); 415 await page.setContent( 416 '<div id="not-foo"></div><div class="foo">Foo1</div><div class="foo baz">Foo2</div>' 417 ); 418 puppeteer.registerCustomQueryHandler('getByClass', { 419 queryAll: (element, selector) => 420 document.querySelectorAll(`.${selector}`), 421 }); 422 const elements = await page.$$eval( 423 'getByClass/foo', 424 (divs) => divs.length 425 ); 426 427 expect(elements).toBe(2); 428 }); 429 it('should wait correctly with waitForSelector', async () => { 430 const { page, puppeteer } = getTestState(); 431 puppeteer.registerCustomQueryHandler('getByClass', { 432 queryOne: (element, selector) => element.querySelector(`.${selector}`), 433 }); 434 const waitFor = page.waitForSelector('getByClass/foo'); 435 436 // Set the page content after the waitFor has been started. 437 await page.setContent( 438 '<div id="not-foo"></div><div class="foo">Foo1</div>' 439 ); 440 const element = await waitFor; 441 442 expect(element).toBeDefined(); 443 }); 444 445 it('should wait correctly with waitForSelector on an element', async () => { 446 const { page, puppeteer } = getTestState(); 447 puppeteer.registerCustomQueryHandler('getByClass', { 448 queryOne: (element, selector) => element.querySelector(`.${selector}`), 449 }); 450 const waitFor = page.waitForSelector('getByClass/foo'); 451 452 // Set the page content after the waitFor has been started. 453 await page.setContent( 454 '<div id="not-foo"></div><div class="bar">bar2</div><div class="foo">Foo1</div>' 455 ); 456 let element = await waitFor; 457 expect(element).toBeDefined(); 458 459 const innerWaitFor = element.waitForSelector('getByClass/bar'); 460 461 await element.evaluate((el) => { 462 el.innerHTML = '<div class="bar">bar1</div>'; 463 }); 464 465 element = await innerWaitFor; 466 expect(element).toBeDefined(); 467 expect( 468 await element.evaluate((el: HTMLElement) => el.innerText) 469 ).toStrictEqual('bar1'); 470 }); 471 472 it('should wait correctly with waitFor', async () => { 473 /* page.waitFor is deprecated so we silence the warning to avoid test noise */ 474 sinon.stub(console, 'warn').callsFake(() => {}); 475 const { page, puppeteer } = getTestState(); 476 puppeteer.registerCustomQueryHandler('getByClass', { 477 queryOne: (element, selector) => element.querySelector(`.${selector}`), 478 }); 479 const waitFor = page.waitFor('getByClass/foo'); 480 481 // Set the page content after the waitFor has been started. 482 await page.setContent( 483 '<div id="not-foo"></div><div class="foo">Foo1</div>' 484 ); 485 const element = await waitFor; 486 487 expect(element).toBeDefined(); 488 }); 489 it('should work when both queryOne and queryAll are registered', async () => { 490 const { page, puppeteer } = getTestState(); 491 await page.setContent( 492 '<div id="not-foo"></div><div class="foo"><div id="nested-foo" class="foo"/></div><div class="foo baz">Foo2</div>' 493 ); 494 puppeteer.registerCustomQueryHandler('getByClass', { 495 queryOne: (element, selector) => element.querySelector(`.${selector}`), 496 queryAll: (element, selector) => 497 element.querySelectorAll(`.${selector}`), 498 }); 499 500 const element = await page.$('getByClass/foo'); 501 expect(element).toBeDefined(); 502 503 const elements = await page.$$('getByClass/foo'); 504 expect(elements.length).toBe(3); 505 }); 506 it('should eval when both queryOne and queryAll are registered', async () => { 507 const { page, puppeteer } = getTestState(); 508 await page.setContent( 509 '<div id="not-foo"></div><div class="foo">text</div><div class="foo baz">content</div>' 510 ); 511 puppeteer.registerCustomQueryHandler('getByClass', { 512 queryOne: (element, selector) => element.querySelector(`.${selector}`), 513 queryAll: (element, selector) => 514 element.querySelectorAll(`.${selector}`), 515 }); 516 517 const txtContent = await page.$eval( 518 'getByClass/foo', 519 (div) => div.textContent 520 ); 521 expect(txtContent).toBe('text'); 522 523 const txtContents = await page.$$eval('getByClass/foo', (divs) => 524 divs.map((d) => d.textContent).join('') 525 ); 526 expect(txtContents).toBe('textcontent'); 527 }); 528 }); 529}); 530