0

I'm using Puppeteer to test a client function within a react environment - the function itself doesn't use React, but is meant to be imported in es6 react modules and run inside a end user DOM environment. I need Puppeteer since this function relies on properties such as innerText, that aren't available in jsdom.

This function takes a DOM element as an argument, however I am having trouble writing test files for it. Here is a sample of my code:

import path from 'path';
import puppeteer from 'puppeteer';
import {getSelectionRange, setSelectionRange} from './selection';

describe(
  'getSelection should match setSelection',
  () => {
    let browser;
    let page;

    beforeAll(async done => {
      try {
        browser = await puppeteer.launch();
        page = await browser.newPage();
        await page.goto(
          `file://${path.join(process.env.ROOT,
          'testFiles/selection_range_test.html')}`
        );
        await page.exposeFunction(
          'setSelectionRange', 
          (el, start, end) => setSelectionRange(el, start, end)
        );
        await page.exposeFunction(
          'getSelectionRange', 
          el => getSelectionRange(el)
        );
      } catch(error) {
        console.error(error);
      }

      done();
    });

    afterAll(async done => {
      await browser.close();
      done();
    });

    it('should match on a node with only one text node children', async () => {
      const {selection, element, argEl} = await page.evaluate(async () => {
        const stn = document.getElementById('single-text-node');

        // Since console.log will output in the Puppeteer browser and not in node console,
        // I added a line inside the selectionRange function to return the element it receives
        // as an argument.
        const argEl = await window.setSelectionRange(stn, 1, 10);

        const selectionRange = await window.getSelectionRange(stn);
        return {selection: selectionRange, element: stn, argEl};
      });

      // Outputs <div id="single-text-node">...</div> 
      // (the content is long so I skipped it, but it displays the correct value here)
      console.log(element.outerHTML);

      // Outputs {}
      console.log(argEl);
    });
  }
);

As described in the comments, the element that is directly returned from page.evaluate() is correct, but when passed as an argument, the function receives an empty object. I suspect a scope issue but I am totally out of solutions here.

KawaLo
  • 1,783
  • 3
  • 16
  • 34
  • What happens if you `console.log(argEl)` _inside_ your `evaluate` block? What does it print? – Sam R. Oct 06 '20 at 23:56
  • 1
    First of all, you cannot trust console output. It outputs own enumerable properties of an object. If you want to debug a value then debug it with real debugger. – Estus Flask Oct 07 '20 at 07:54
  • 1
    If I understand correctly, neither exposed functions, nor page.evaluste() can transfer non-serializable values (including DOM elements) between browser and Node.js contexts. – vsemozhebuty Oct 07 '20 at 08:14
  • @SamR. It doesn't print anything to the console, I have to return the value outside evaluate to be able to see it (even printing a hardcoded string wont work). – KawaLo Oct 07 '20 at 12:24
  • @EstusFlask I'm trying to set up a debugger for jest but it will take some time since it doesn't seem quite easy, I'll do an update if it gives me some useful information – KawaLo Oct 07 '20 at 12:38
  • 1
    It's actually really simple once you know the exact command. See https://stackoverflow.com/questions/63671251/unable-to-debug-test-case-using-jest-in-chrome-under-node-devtools – Estus Flask Oct 07 '20 at 12:49
  • @vsemozhebuty I actually tried to return a serializable value (in my case .outerHTML, which is a string) from both my exposed function and `page.evaluate`, and they all return undefined. Plus I was able to return `stn` (which is a DOM element and not a serialized sub-property) from `page.evaluate`, and it works like a charm from Node context (`console.log(element.outerHTML)` is called from the test function, which operates in Node). The problem is that, when `stn` is passed as an argument to my exposed function, the exposed function receives an empty object instead of the expected DOM element. – KawaLo Oct 07 '20 at 12:49
  • @vsemozhebuty I have misunderstood something that has been clarified in this post : https://stackoverflow.com/questions/48281130/why-cant-i-access-window-in-an-exposefunction-function-with-puppeteer . Turns out `exposeFunction` will run them from Node context and not from page context. I still have issues since calling my functions directly within evaluate fails because of a missing "_selection" reference (which may be used by one of the DOM function I call since I don't have any reference to this property in any of my functions) – KawaLo Oct 07 '20 at 13:15
  • 1
    It should print something but in the chrome devtool and not in your terminal. – Sam R. Oct 07 '20 at 15:09

1 Answers1

0

Sadly I couldn't find any solution that wouldn't invoke transpiling my files, but hopefully I managed to make it work correctly.

The key point was to create a second transpile configuration that will generate a code directly usable by a web browser, using UMD format. Since I use rollup, here is my rollup,config.js file:

import babel from 'rollup-plugin-babel';
import commonjs from 'rollup-plugin-commonjs';
import resolve from 'rollup-plugin-node-resolve';
import pkg from './package.json';

// The content that is actually exported to be used within a React or Node application.
const libConfig = [
    {
        inlineDynamicImports: true,
        input: './src/index.js',
        output: [
            {
                file: './lib/index.js',
                format: 'cjs'
            },
        ],
        external: [...Object.keys(pkg.dependencies || {})],
        plugins: [
            commonjs(),
            resolve(),
            babel({exclude: 'node_modules/**'})
        ]
    }
];

// Used to generate a bundle that is directly executable within a browser environment, for E2E testing.
const testConfig = [
    {
        inlineDynamicImports: true,
        input: './src/index.js',
        output: [
            {
                file: './dist/index.js',
                format: 'umd',
                name: 'tachyon'
            },
        ],
        external: [...Object.keys(pkg.dependencies || {})],
        plugins: [
            commonjs(),
            resolve(),
            babel({runtimeHelpers: true})
        ]
    }
];

const config = process.env.NODE_ENV === 'test' ? testConfig : libConfig;
export default config;

I then rewrote my scripts a bit so my test bundle is generated on each test run.

package.json

{
  "scripts": {
    "build:test": "NODE_ENV=test rollup -c && NODE_ENV=",
    "build": "rollup -c",
    "test": "yarn build:test && jest"
  },
}

Finally, I added the transpiled script to my selection_test.html file:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Selection range test</title>
    <script src="../dist/index.js"></script>
</head>
...

Which lets me write my test file like this:

import path from 'path';
import puppeteer from 'puppeteer';
import {describe, beforeAll, afterAll, it} from '@jest/globals';

describe(
    'getSelection should match setSelection',
    () => {
        let browser;
        let page;

        beforeAll(async done => {
            try {
                browser = await puppeteer.launch({
                    headless: true,
                    args: ['--disable-web-security', '--disable-features=SameSiteByDefaultCookies,CookiesWithoutSameSiteMustBeSecure'],
                });
                page = await browser.newPage();
                await page.goto(`file://${path.join(process.env.ROOT, 'tests/selection_test.html')}`, {waitUntil: 'networkidle0'});
                await page.setBypassCSP(true);
            } catch(error) {
                console.error(error);
            }

            done();
        });

        afterAll(async done => {
            await browser.close();
            done();
        });

        it('should match on a node with only one text node children', async () => {
            const data = await page.evaluate(() => {
                // Fix eslint warnings.
                window.tachyon = window.tachyon || null;
                if (window.tachyon == null) {
                    return new Error(`cannot find tachyon module`);
                }

                const stn = document.getElementById('single-text-node');
                const witnessRange = tachyon.setRange(stn, 1, 10);
                const selectionRange = tachyon.getRange(stn);

                return {witnessRange, selectionRange, element: stn.outerHTML};
            });

            console.log(data); // Outputs the correct values
            /*
            {
                witnessRange: { start: 1, end: 10 },
                selectionRange: {
                    absolute: { start: 1, end: 10 },
                    start: { container: {}, offset: 1 },
                    end: { container: {}, offset: 10 }
                },
                element: '<div id="single-text-node">Lorem ... sem.</div>'
            }
             */
        });
    }
);

The only remaining issue is that start.container and end.container within the results of getRange are undefined, but it seems more likely an issue from puppeteer that cannot handle the Range startContainer and endContainer properties - I was able to pass DOM references between the content of page.evaluate and my module function without any issues, so it doesn't look like the problem anymore.

KawaLo
  • 1,783
  • 3
  • 16
  • 34