JSDOM script element being overwritten in Mocha

Viewed 456

I have two nearly identical JS files that I cannot change that I want to add tests for.

file 1:

const url = "https://file-1.js";

(function () {
  "use strict";
  window.onload = () => {
    const script = document.createElement("script");
    script.src = url;
    document.head.appendChild(script);
  };
})();

file 2:

const url = "https://file-2.js";

(function () {
  "use strict";
  window.onload = () => {
    const script = document.createElement("script");
    script.src = url;
    document.head.appendChild(script);
  };
})();

Then test 1:

const chai = require("chai");
const { expect } = chai;
const jsdom = require("jsdom");
const { JSDOM } = jsdom;

const { window } = new JSDOM(`<!DOCTYPE html><head></head><p>Fake document</p>`, {
  resources: "usable",
});

global.document = window.document;
global.window = window;

const myFile = require("../src/myFile");

describe("Test 1", function () {
    it("Loads a file from an external source", function (done) {
      console.log(window.document.head.children); // See what's going on
      expect(window.document.head.children[0].src).to.equal("https://file-1.js");
    });
});

test 2:

const chai = require("chai");
const { expect } = chai;
const jsdom = require("jsdom");
const { JSDOM } = jsdom;

const myFile2 = require("../src/myFile2");

describe("Test 2", function () {
    it("Loads a file from an external source", function (done) {
      console.log(window.document.head.children); // See what's going on
      expect(window.document.head.children[0].src).to.equal("https://file-2.js");
    });
});

Test 2 passes but test 1 fails. The value of both console.logs is:

HTMLCollection { '0': HTMLScriptElement {} }

And console.log(window.document.head.children[0].src) produces:

https://file-2.js

I'd expect there to be two children in window.document.head but there's only 1, per the above. It appears Mocha is loading all the required files in all tests first, and the appendChild in the 2nd file is overwriting the value from the first.

Is there a way around this? I experimented with done() or moving around where the require is called but it results in the same outcome.

2 Answers

After reviewing the repo in the answer from Christian I realized I needed to fire the window.onload event after importing each file.

Also, I do not want to run ('dangerously') the scripts, just ensure that they are appended a document as a script element. That's all.

The following works:

const chai = require("chai");
const { expect } = chai;
const jsdom = require("jsdom");
const { JSDOM } = jsdom;

const { window } = new JSDOM(`<!DOCTYPE html><head></head><p>Fake document</p>`, {
  resources: "usable",
});

global.document = window.document;
global.window = window;

const downloaderPopup = require("../src/MyFile");
window.dispatchEvent(new window.Event("load"));
const downloaderMain = require("../src/MyFile2");
window.dispatchEvent(new window.Event("load"));

describe("Both tests", function () {
  describe("Test 1", function () {
    it("Dynamocally loads file 1", function () {
      expect(window.document.head.children[1].src).to.equal("https://file-1.js");
    });
  });
  describe("Test 2", function () {
    it("Dynamically loads file 2", function () {
      expect(window.document.head.children[0].src).to.equal("https://file-2.js");
    });
  });
});

I created a repo for you to look at. A few notes:

  1. We need to set the runScripts: "dangerously" flag for JSDOM if we want to load external scripts (see this issue).

  2. We need to manually re-fire the load event ourselves - basically, by the time your script is executed, the status of document.readyState is "complete", i.e., the load event has already fired.

    What's happening here is that window is ready as soon as JSDOM is done compiling the HTML script we pass it on initialization. We can import what we need and then fire the load event manually - as long as we do not pass any scripts to the initial JSDOM call, we can be sure that we will not be triggering anything twice.

  3. When the load event fires, the generated <script> tags are actually added to the DOM, but since they contain dummy URLs with nothing to actually load, the process throws: Error: Could not load script: "https://file-1.js/". I changed those URLs to the jQuery library and Hammer.js for the sake of testing, and you will need to add logic to make sure that URL is safe.

  4. Since both scripts set window.onload = function() {...}, if we run them both and then fire the load event (which we would normally do), only the last one will be triggered because each window.onload set overwrites the former.

    We can get around this, but only because we know what the script contains. See the test files for the workaround: just require, fire the onload, and then use delete window.onload. I used dispatchEvent just to show the form for that, but since the overwrite issue isn't a problem for window.addEventListener (just for naively setting the window.onload property), it would probably be better to call window.onload() and then deleting it. It's hairy but it's not unmanageable.

I have actually been working on something close to this for the past few days, and have recently put up two packages to help with similar scenarios: enable-window-document (which exposes window and document globals) and enable-browser-mode (which aims to completely simulate the browser runtime, setting the global object to window and exposing a window.include function to evaluate an imported script in the global context, i.e. include('jquery.min.js'), with no errors).

For this situation (and the low complexity of the test scripts), enable-window-document will suffice. When running in full browser compatibility mode, we actually get failures because of the const url = ... declaration in both scripts - those are evaluated in the global context when full browser compatibility is enabled, which results in trying to re-set the window.url variable which is declared as const. Simply setting the window and document globals will work for this use case, but if you start to load complex scripts you may run into issues.

What I would recommend is to use enable-browser-mode if your scripts could run in the browser (i.e., no conflicting global const variables), and then replace any require calls to browser JS (test1.js and test2.js) with include(). This will make sure that window refers to the global object and your average wild browser JS will execute as expected.

After loading all the scripts and hacking around the onload conflicts, we run the tests:

// inside index.js
...
console.log(document.head.outerHTML);
console.log("jQuery:", window.$);
$ node .

RUNNING TESTS...
<head><script src="https://code.jquery.com/jquery-3.5.1.min.js"></script><script src="https://hammerjs.github.io/dist/hammer.min.js"></script></head>
jQuery: undefined

And weirdly we can't access window.jQuery at runtime. Yet, in the console:

$ node

Welcome to Node.js v14.4.0.
Type ".help" for more information.

> require('.')
RUNNING TESTS...
<head><script src="https://code.jquery.com/jquery-3.5.1.min.js"></script><script src="https://hammerjs.github.io/dist/hammer.min.js"></script></head>
jQuery: undefined
{}

> window.jQuery
<ref *1> [Function: S] {
  fn: S {
    jquery: '3.5.1',
    constructor: [Circular *1],
    length: 0,
    toArray: [Function: toArray]
    ...

So I would recommend toying around to see what you can and cannot get to work.

Footnote: Jest is hot Facebook garbage and I'm not going to concern myself with debugging it (claims window global doesn't exist in myFile.js and so on). What we're doing here is pretty hacky and seems out of the suite's scope, or else conflicts with its native JSDOM interfacing somehow, though I might be missing something. If you want to spend time debugging it, be my guest: I left the project structure so that you can run jest and see what it's complaining about, but you'll need to uncomment out the describe statements etc.

Anyway, hope this helped.

Related