Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Does jsdom support video and audio elements? #2155

Closed
TotomInc opened this issue Feb 19, 2018 · 15 comments
Closed

Does jsdom support video and audio elements? #2155

TotomInc opened this issue Feb 19, 2018 · 15 comments

Comments

@TotomInc
Copy link

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?

@atsikov
Copy link
Contributor

atsikov commented Feb 19, 2018

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 */ };

@TotomInc
Copy link
Author

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?

@atsikov
Copy link
Contributor

atsikov commented Feb 19, 2018

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"));).

@TotomInc
Copy link
Author

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

@BenBergman
Copy link

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?

@TimothyGu
Copy link
Member

@BenBergman You could do:

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

@BenBergman
Copy link

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;
}

@atsikov
Copy link
Contributor

atsikov commented Mar 2, 2018

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.

@BenBergman
Copy link

Even better, thanks!

@traffisco
Copy link

traffisco commented May 31, 2018

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')); } });

@mulholo
Copy link

mulholo commented Nov 18, 2019

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?

@domenic
Copy link
Member

domenic commented Nov 18, 2019

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

@serebrov
Copy link

serebrov commented Feb 4, 2020

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)
  })

@evandrocoan
Copy link

evandrocoan commented Apr 19, 2020

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"
  }
}

@oren-l
Copy link

oren-l commented Aug 20, 2023

I think I found a nicer workaround:

const videoEl = screen.getByTestId('video') as HTMLVideoElement;
jest.spyOn(videoEl, 'paused', 'get').mockReturnValue(false);

I'm using react-testing-library to get the video element (screen.getByTestId), if you use something else, use whatever is available in your testing library to get the video element, the rest is plain Jest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

10 participants