Jsdom: Does jsdom support video and audio elements?

Created on 19 Feb 2018  ·  14Comments  ·  Source: jsdom/jsdom

Basic info:

  • Node.js version: 8.94
  • jsdom version: 11.6.2

Information

I want to run unit-tests (using ava and browser-env) for an asset-loader which support image, audio and video pre-loading. I wanted to know if jsdom support audio and video elements. When I try to create and call video.load() on a video element (HTMLVideoElement which itself is a HTMLMediaElement), jsdom returns this error :

Error: Not implemented: HTMLMediaElement.prototype.load

I assume there are no support for video and audio element. I have found nothing about video and audio support in jsdom, maybe it's missing?

Most helpful comment

jsdom doesn't support any loading or playback media operations. As a workaround you can add a few stubs in your test setup:

window.HTMLMediaElement.prototype.load = () => { /* do nothing */ };
window.HTMLMediaElement.prototype.play = () => { /* do nothing */ };
window.HTMLMediaElement.prototype.pause = () => { /* do nothing */ };
window.HTMLMediaElement.prototype.addTextTrack = () => { /* do nothing */ };

All 14 comments

jsdom doesn't support any loading or playback media operations. As a workaround you can add a few stubs in your test setup:

window.HTMLMediaElement.prototype.load = () => { /* do nothing */ };
window.HTMLMediaElement.prototype.play = () => { /* do nothing */ };
window.HTMLMediaElement.prototype.pause = () => { /* do nothing */ };
window.HTMLMediaElement.prototype.addTextTrack = () => { /* do nothing */ };

That's what I need here: a way to simulate the loading/fetching of HTMLMediaElements. By doing this, it will not pre-load audio and video like in a real browser, right?

Usually no fetching is required for such tests. These stubs suppress jsdom exceptions, and then you will be able to test your logic with manually dispatched events from video element (e.g. videoElement.dispatchEvent(new window.Event("loading"));).

Alright, thanks for the help, I finally fixed my tests. 👍

This has helped me get started, but in my case I want to test if certain conditions are correctly affecting playback. I have made replacement play and pause functions and I try to set the paused variable of the media element but get an error that there is only a getter for that variable. This makes mocking it out a bit challenging.

I'm somewhat new to JS. Is there a way to mock out read-only variables like this?

@BenBergman You could do:

Object.defineProperty(HTMLMediaElement.prototype, "paused", {
  get() {
    // Your own getter, where `this` refers to the HTMLMediaElement.
  }
});

Excellent, thank you! For posterity, my getter looks like this to account for a default value:

get() {
    if (this.mockPaused === undefined) {
        return true;
    }
    return this.mockPaused;
}

You can make it even simplier this way:

Object.defineProperty(mediaTag, "paused", {
  writable: true,
  value: true,
});

and then just change mediaTag.paused = true or mediaTag.paused = false in your test.

Benefit of this approach is that it is type safe in case you are using TypeScript. You don't to set your mock property somehow like (mediaTag as any).mockPaused = true.

Even better, thanks!

How can I simulate video play?
I stubbed play and load but I have no idea how to make the video start playing(or think it playing, all I need is what happens ofter).
Object.defineProperty(HTMLMediaElement.prototype, "play", { get() { document.getElementsByTagName('video')[0].dispatchEvent(new Event('play')); } });

jsdom doesn't support any loading or playback media operations. As a workaround you can add a few stubs in your test setup:

Thanks for the workaround. May I ask why this isn't supported by default, however?

Nobody has implemented a video or audio player in jsdom yet.

Here is a quick and dirty implementation (for jest and vue) of play and pause methods that also sends some of the events I needed for a test (loadedmetadata, play, pause):

// Jest's setup file, setup.js

// Mock data and helper methods
global.window.HTMLMediaElement.prototype._mock = {
  paused: true,
  duration: NaN,
  _loaded: false,
   // Emulates the audio file loading
  _load: function audioInit(audio) {
    // Note: we could actually load the file from this.src and get real duration
    // and other metadata.
    // See for example: https://github.com/59naga/mock-audio-element/blob/master/src/index.js
    // For now, the 'duration' and other metadata has to be set manually in test code.
    audio.dispatchEvent(new Event('loadedmetadata'))
    audio.dispatchEvent(new Event('canplaythrough'))
  },
  // Reset audio object mock data to the initial state
  _resetMock: function resetMock(audio) {
    audio._mock = Object.assign(
      {},
      global.window.HTMLMediaElement.prototype._mock,
    )
  },
}

// Get "paused" value, it is automatically set to true / false when we play / pause the audio.
Object.defineProperty(global.window.HTMLMediaElement.prototype, 'paused', {
  get() {
    return this._mock.paused
  },
})

// Get and set audio duration
Object.defineProperty(global.window.HTMLMediaElement.prototype, 'duration', {
  get() {
    return this._mock.duration
  },
  set(value) {
    // Reset the mock state to initial (paused) when we set the duration.
    this._mock._resetMock(this)
    this._mock.duration = value
  },
})

// Start the playback.
global.window.HTMLMediaElement.prototype.play = function playMock() {
  if (!this._mock._loaded) {
    // emulate the audio file load and metadata initialization
    this._mock._load(this)
  }
  this._mock.paused = false
  this.dispatchEvent(new Event('play'))
  // Note: we could
}

// Pause the playback
global.window.HTMLMediaElement.prototype.pause = function pauseMock() {
  this._mock.paused = true
  this.dispatchEvent(new Event('pause'))
}

And the example of the test (note that we have to manually set audio.duration:

  // Test
  it('creates audio player', async () => {
    // `page` is a wrapper for a page being tested, created in beforeEach
    let player = page.player()

    // Useful to see which properties are defined where.
    // console.log(Object.getOwnPropertyDescriptors(HTMLMediaElement.prototype))
    // console.log(Object.getOwnPropertyDescriptors(HTMLMediaElement))
    // console.log(Object.getOwnPropertyDescriptors(audio))

    let audio = player.find('audio').element as HTMLAudioElement

    let audioEventReceived = false
    audio.addEventListener('play', () => {
      audioEventReceived = true
    })

    // @ts-ignore: error TS2540: Cannot assign to 'duration' because it is a read-only property.
    audio.duration = 300

    expect(audio.paused).toBe(true)
    expect(audio.duration).toBe(300)
    expect(audio.currentTime).toBe(0)

    audio.play()
    audio.currentTime += 30

    expect(audioEventReceived).toBe(true)

    expect(audio.paused).toBe(false)
    expect(audio.duration).toBe(300)
    expect(audio.currentTime).toBe(30.02)
  })

I considered using these workarounds, but instead of reimplementing a browser like playing features, I decided to use puppeteer, i.e., to get a real browser to do the testing. This is my setup:

src/reviewer.tests.ts

jest.disableAutomock()
// Use this in a test to pause its execution, allowing you to open the chrome console
// and while keeping the express server running: chrome://inspect/#devices
// jest.setTimeout(2000000000);
// debugger; await new Promise(function(resolve) {});

test('renders test site', async function() {
    let self: any = global;
    let page = self.page;
    let address = process.env.SERVER_ADDRESS;
    console.log(`The server address is '${address}'.`);
    await page.goto(`${address}/single_audio_file.html`);
    await page.waitForSelector('[data-attibute]');

    let is_paused = await page.evaluate(() => {
        let audio = document.getElementById('silence1.mp3') as HTMLAudioElement;
        return audio.paused;
    });
    expect(is_paused).toEqual(true);
});

testfiles/single_audio_file.html

<html>
    <head>
        <title>main webview</title>
        <script src="importsomething.js"></script>
    </head>
    <body>
    <div id="qa">
        <audio id="silence1.mp3" src="silence1.mp3" data-attibute="some" controls></audio>
        <script type="text/javascript">
            // doSomething();
        </script>
    </div>
    </body>
</html>

**globalTeardown.js**

module.exports = async () => {
    global.server.close();
};
**globalSetup.js**
const express = require('express');

module.exports = async () => {
    let server;
    const app = express();

    await new Promise(function(resolve) {
        server = app.listen(0, "127.0.0.1", function() {
            let address = server.address();
            process.env.SERVER_ADDRESS = `http://${address.address}:${address.port}`;
            console.log(`Running static file server on '${process.env.SERVER_ADDRESS}'...`);
            resolve();
        });
    });

    global.server = server;
    app.get('/favicon.ico', (req, res) => res.sendStatus(200));
    app.use(express.static('./testfiles'));
};
**testEnvironment.js**
const puppeteer = require('puppeteer');

// const TestEnvironment = require('jest-environment-node'); // for server node apps
const TestEnvironment = require('jest-environment-jsdom'); // for browser js apps

class ExpressEnvironment extends TestEnvironment {
    constructor(config, context) {
        let cloneconfig = Object.assign({}, config);
        cloneconfig.testURL = process.env.SERVER_ADDRESS;
        super(cloneconfig, context);
    }

    async setup() {
        await super.setup();
        let browser = await puppeteer.launch({
            // headless: false, // show the Chrome window
            // slowMo: 250, // slow things down by 250 ms
            ignoreDefaultArgs: [
                "--mute-audio",
            ],
            args: [
                "--autoplay-policy=no-user-gesture-required",
            ],
        });
        let [page] = await browser.pages(); // reuses/takes the default blank page
        // let page = await this.global.browser.newPage();

        page.on('console', async msg => console[msg._type](
            ...await Promise.all(msg.args().map(arg => arg.jsonValue()))
        ));
        this.global.page = page;
        this.global.browser = browser;
        this.global.jsdom = this.dom;
    }

    async teardown() {
        await this.global.browser.close();
        await super.teardown();
    }

    runScript(script) {
        return super.runScript(script);
    }
}

module.exports = ExpressEnvironment;
**tsconfig.json**
{
  "compilerOptions": {
    "target": "es2017"
  },
  "include": [
    "src/**/*"
  ],
  "exclude": [
    "src/**/*.test.ts"
  ]
}
**package.json**
{
  "scripts": {
    "test": "jest",
  },
  "jest": {
    "testEnvironment": "./testEnvironment.js",
    "globalSetup": "./globalSetup.js",
    "globalTeardown": "./globalTeardown.js",
    "transform": {
      "^.+\\.(ts|tsx)$": "ts-jest"
    }
  },
  "jshintConfig": {
    "esversion": 8
  },
  "dependencies": {
    "typescript": "^3.7.3"
  },
  "devDependencies": {
    "@types/express": "^4.17.6",
    "@types/jest": "^25.2.1",
    "@types/node": "^13.11.1",
    "@types/puppeteer": "^2.0.1",
    "express": "^4.17.1",
    "jest": "^25.3.0",
    "puppeteer": "^3.0.0",
    "ts-jest": "^25.3.1"
  }
}

Was this page helpful?
0 / 5 - 0 ratings

Related issues

kentmw picture kentmw  ·  3Comments

jhegedus42 picture jhegedus42  ·  4Comments

kilianc picture kilianc  ·  4Comments

mitar picture mitar  ·  4Comments

JacksonGariety picture JacksonGariety  ·  4Comments