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 visible 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 visible 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 visible or not an HTMLElement'
256      );
257    });
258  });
259
260  describe('ElementHandle.hover', function () {
261    it('should work', async () => {
262      const { page, server } = getTestState();
263
264      await page.goto(server.PREFIX + '/input/scrollable.html');
265      const button = await page.$('#button-6');
266      await button.hover();
267      expect(
268        await page.evaluate(() => document.querySelector('button:hover').id)
269      ).toBe('button-6');
270    });
271  });
272
273  describe('ElementHandle.isIntersectingViewport', function () {
274    it('should work', async () => {
275      const { page, server } = getTestState();
276
277      await page.goto(server.PREFIX + '/offscreenbuttons.html');
278      for (let i = 0; i < 11; ++i) {
279        const button = await page.$('#btn' + i);
280        // All but last button are visible.
281        const visible = i < 10;
282        expect(await button.isIntersectingViewport()).toBe(visible);
283      }
284    });
285  });
286
287  describe('Custom queries', function () {
288    this.afterEach(() => {
289      const { puppeteer } = getTestState();
290      puppeteer.clearCustomQueryHandlers();
291    });
292    it('should register and unregister', async () => {
293      const { page, puppeteer } = getTestState();
294      await page.setContent('<div id="not-foo"></div><div id="foo"></div>');
295
296      // Register.
297      puppeteer.registerCustomQueryHandler('getById', {
298        queryOne: (element, selector) =>
299          document.querySelector(`[id="${selector}"]`),
300      });
301      const element = await page.$('getById/foo');
302      expect(
303        await page.evaluate<(element: HTMLElement) => string>(
304          (element) => element.id,
305          element
306        )
307      ).toBe('foo');
308      const handlerNamesAfterRegistering = puppeteer.customQueryHandlerNames();
309      expect(handlerNamesAfterRegistering.includes('getById')).toBeTruthy();
310
311      // Unregister.
312      puppeteer.unregisterCustomQueryHandler('getById');
313      try {
314        await page.$('getById/foo');
315        throw new Error('Custom query handler name not set - throw expected');
316      } catch (error) {
317        expect(error).toStrictEqual(
318          new Error(
319            'Query set to use "getById", but no query handler of that name was found'
320          )
321        );
322      }
323      const handlerNamesAfterUnregistering =
324        puppeteer.customQueryHandlerNames();
325      expect(handlerNamesAfterUnregistering.includes('getById')).toBeFalsy();
326    });
327    it('should throw with invalid query names', () => {
328      try {
329        const { puppeteer } = getTestState();
330        puppeteer.registerCustomQueryHandler('1/2/3', {
331          queryOne: () => document.querySelector('foo'),
332        });
333        throw new Error(
334          'Custom query handler name was invalid - throw expected'
335        );
336      } catch (error) {
337        expect(error).toStrictEqual(
338          new Error('Custom query handler names may only contain [a-zA-Z]')
339        );
340      }
341    });
342    it('should work for multiple elements', async () => {
343      const { page, puppeteer } = getTestState();
344      await page.setContent(
345        '<div id="not-foo"></div><div class="foo">Foo1</div><div class="foo baz">Foo2</div>'
346      );
347      puppeteer.registerCustomQueryHandler('getByClass', {
348        queryAll: (element, selector) =>
349          document.querySelectorAll(`.${selector}`),
350      });
351      const elements = await page.$$('getByClass/foo');
352      const classNames = await Promise.all(
353        elements.map(
354          async (element) =>
355            await page.evaluate<(element: HTMLElement) => string>(
356              (element) => element.className,
357              element
358            )
359        )
360      );
361
362      expect(classNames).toStrictEqual(['foo', 'foo baz']);
363    });
364    it('should eval correctly', async () => {
365      const { page, puppeteer } = getTestState();
366      await page.setContent(
367        '<div id="not-foo"></div><div class="foo">Foo1</div><div class="foo baz">Foo2</div>'
368      );
369      puppeteer.registerCustomQueryHandler('getByClass', {
370        queryAll: (element, selector) =>
371          document.querySelectorAll(`.${selector}`),
372      });
373      const elements = await page.$$eval(
374        'getByClass/foo',
375        (divs) => divs.length
376      );
377
378      expect(elements).toBe(2);
379    });
380    it('should wait correctly with waitForSelector', async () => {
381      const { page, puppeteer } = getTestState();
382      puppeteer.registerCustomQueryHandler('getByClass', {
383        queryOne: (element, selector) => element.querySelector(`.${selector}`),
384      });
385      const waitFor = page.waitForSelector('getByClass/foo');
386
387      // Set the page content after the waitFor has been started.
388      await page.setContent(
389        '<div id="not-foo"></div><div class="foo">Foo1</div>'
390      );
391      const element = await waitFor;
392
393      expect(element).toBeDefined();
394    });
395
396    it('should wait correctly with waitFor', async () => {
397      /* page.waitFor is deprecated so we silence the warning to avoid test noise */
398      sinon.stub(console, 'warn').callsFake(() => {});
399      const { page, puppeteer } = getTestState();
400      puppeteer.registerCustomQueryHandler('getByClass', {
401        queryOne: (element, selector) => element.querySelector(`.${selector}`),
402      });
403      const waitFor = page.waitFor('getByClass/foo');
404
405      // Set the page content after the waitFor has been started.
406      await page.setContent(
407        '<div id="not-foo"></div><div class="foo">Foo1</div>'
408      );
409      const element = await waitFor;
410
411      expect(element).toBeDefined();
412    });
413    it('should work when both queryOne and queryAll are registered', async () => {
414      const { page, puppeteer } = getTestState();
415      await page.setContent(
416        '<div id="not-foo"></div><div class="foo"><div id="nested-foo" class="foo"/></div><div class="foo baz">Foo2</div>'
417      );
418      puppeteer.registerCustomQueryHandler('getByClass', {
419        queryOne: (element, selector) => element.querySelector(`.${selector}`),
420        queryAll: (element, selector) =>
421          element.querySelectorAll(`.${selector}`),
422      });
423
424      const element = await page.$('getByClass/foo');
425      expect(element).toBeDefined();
426
427      const elements = await page.$$('getByClass/foo');
428      expect(elements.length).toBe(3);
429    });
430    it('should eval when both queryOne and queryAll are registered', async () => {
431      const { page, puppeteer } = getTestState();
432      await page.setContent(
433        '<div id="not-foo"></div><div class="foo">text</div><div class="foo baz">content</div>'
434      );
435      puppeteer.registerCustomQueryHandler('getByClass', {
436        queryOne: (element, selector) => element.querySelector(`.${selector}`),
437        queryAll: (element, selector) =>
438          element.querySelectorAll(`.${selector}`),
439      });
440
441      const txtContent = await page.$eval(
442        'getByClass/foo',
443        (div) => div.textContent
444      );
445      expect(txtContent).toBe('text');
446
447      const txtContents = await page.$$eval('getByClass/foo', (divs) =>
448        divs.map((d) => d.textContent).join('')
449      );
450      expect(txtContents).toBe('textcontent');
451    });
452  });
453});
454