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