Jsdom: jsdom์€ ๋น„๋””์˜ค ๋ฐ ์˜ค๋””์˜ค ์š”์†Œ๋ฅผ ์ง€์›ํ•ฉ๋‹ˆ๊นŒ?

์— ๋งŒ๋“  2018๋…„ 02์›” 19์ผ  ยท  14์ฝ”๋ฉ˜ํŠธ  ยท  ์ถœ์ฒ˜: jsdom/jsdom

๊ธฐ๋ณธ ์ •๋ณด:

  • Node.js ๋ฒ„์ „: 8.94
  • jsdom ๋ฒ„์ „: 11.6.2

์ •๋ณด

์ด๋ฏธ์ง€, ์˜ค๋””์˜ค ๋ฐ ๋น„๋””์˜ค ์‚ฌ์ „ ๋กœ๋“œ๋ฅผ ์ง€์›ํ•˜๋Š” ์ž์‚ฐ ๋กœ๋”์— ๋Œ€ํ•ด ๋‹จ์œ„ ํ…Œ์ŠคํŠธ( ava ๋ฐ browser-env )๋ฅผ ์‹คํ–‰ํ•˜๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค. jsdom์ด ์˜ค๋””์˜ค ๋ฐ ๋น„๋””์˜ค ์š”์†Œ๋ฅผ ์ง€์›ํ•˜๋Š”์ง€ ์•Œ๊ณ  ์‹ถ์—ˆ์Šต๋‹ˆ๋‹ค. ๋น„๋””์˜ค ์š”์†Œ( HTMLVideoElement ์ž์ฒด๊ฐ€ HTMLMediaElement )์—์„œ video.load() ๋ฅผ ๋งŒ๋“ค๊ณ  ํ˜ธ์ถœํ•˜๋ ค๊ณ  ํ•˜๋ฉด jsdom์ด ๋‹ค์Œ ์˜ค๋ฅ˜๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

Error: Not implemented: HTMLMediaElement.prototype.load

๋น„๋””์˜ค ๋ฐ ์˜ค๋””์˜ค ์š”์†Œ์— ๋Œ€ํ•œ ์ง€์›์ด ์—†๋‹ค๊ณ  ๊ฐ€์ •ํ•ฉ๋‹ˆ๋‹ค. jsdom์—์„œ ๋น„๋””์˜ค ๋ฐ ์˜ค๋””์˜ค ์ง€์›์— ๋Œ€ํ•ด ์•„๋ฌด๊ฒƒ๋„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์–ด์ฉŒ๋ฉด ๋ˆ„๋ฝ๋˜์—ˆ์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๊นŒ?

๊ฐ€์žฅ ์œ ์šฉํ•œ ๋Œ“๊ธ€

jsdom ์€(๋Š”) ๋ฏธ๋””์–ด ๋กœ๋“œ ๋˜๋Š” ์žฌ์ƒ ์ž‘์—…์„ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์œผ๋กœ ํ…Œ์ŠคํŠธ ์„ค์ •์— ๋ช‡ ๊ฐ€์ง€ ์Šคํ…์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

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

๋ชจ๋“  14 ๋Œ“๊ธ€

jsdom ์€(๋Š”) ๋ฏธ๋””์–ด ๋กœ๋“œ ๋˜๋Š” ์žฌ์ƒ ์ž‘์—…์„ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์œผ๋กœ ํ…Œ์ŠคํŠธ ์„ค์ •์— ๋ช‡ ๊ฐ€์ง€ ์Šคํ…์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

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

์ด๊ฒƒ์ด ๋‚ด๊ฐ€ ํ•„์š”ํ•œ ๊ฒƒ์ž…๋‹ˆ๋‹ค. HTMLMediaElements ๋กœ๋“œ/๊ฐ€์ ธ์˜ค๊ธฐ๋ฅผ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๋Š” ๋ฐฉ๋ฒ•์ž…๋‹ˆ๋‹ค. ์ด๋ ‡๊ฒŒ ํ•˜๋ฉด ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ €์ฒ˜๋Ÿผ ์˜ค๋””์˜ค์™€ ๋น„๋””์˜ค๋ฅผ ๋ฏธ๋ฆฌ ๋กœ๋“œํ•˜์ง€ ์•Š๊ฒ ์ฃ ?

์ผ๋ฐ˜์ ์œผ๋กœ ์ด๋Ÿฌํ•œ ํ…Œ์ŠคํŠธ์—๋Š” ๊ฐ€์ ธ์˜ค๊ธฐ๊ฐ€ ํ•„์š”ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด๋Ÿฌํ•œ ์Šคํ…์€ jsdom ์˜ˆ์™ธ๋ฅผ ์–ต์ œํ•˜๊ณ  ๋น„๋””์˜ค ์š”์†Œ(์˜ˆ: videoElement.dispatchEvent(new window.Event("loading")); )์—์„œ ์ˆ˜๋™์œผ๋กœ ์ „๋‹ฌ๋œ ์ด๋ฒคํŠธ๋กœ ๋…ผ๋ฆฌ๋ฅผ ํ…Œ์ŠคํŠธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์•Œ๊ฒ ์Šต๋‹ˆ๋‹ค. ๋„์›€์„ ์ฃผ์…”์„œ ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค. ๋งˆ์นจ๋‚ด ํ…Œ์ŠคํŠธ๋ฅผ ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ๐Ÿ‘

์ด๊ฒƒ์€ ์‹œ์ž‘ํ•˜๋Š” ๋ฐ ๋„์›€์ด ๋˜์—ˆ์ง€๋งŒ ์ œ ๊ฒฝ์šฐ์—๋Š” ํŠน์ • ์กฐ๊ฑด์ด ์žฌ์ƒ์— ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์˜ํ–ฅ์„ ๋ฏธ์น˜๋Š”์ง€ ํ…Œ์ŠคํŠธํ•˜๊ณ  ์‹ถ์Šต๋‹ˆ๋‹ค. ๋Œ€์ฒด play ๋ฐ pause ํ•จ์ˆ˜๋ฅผ ๋งŒ๋“ค๊ณ  ๋ฏธ๋””์–ด ์š”์†Œ์˜ paused ๋ณ€์ˆ˜๋ฅผ ์„ค์ •ํ•˜๋ ค๊ณ  ํ•˜์ง€๋งŒ ํ•ด๋‹น ๋ณ€์ˆ˜์— ๋Œ€ํ•œ getter๋งŒ ์žˆ๋‹ค๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ์ด๊ฒƒ์€ ๊ทธ๊ฒƒ์„ ์กฐ๋กฑํ•˜๋Š” ๊ฒƒ์„ ์•ฝ๊ฐ„ ์–ด๋ ต๊ฒŒ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

๋‚˜๋Š” JS์— ๋‹ค์†Œ ์ต์ˆ™ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด์™€ ๊ฐ™์€ ์ฝ๊ธฐ ์ „์šฉ ๋ณ€์ˆ˜๋ฅผ ์กฐ๋กฑํ•˜๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ์Šต๋‹ˆ๊นŒ?

@BenBergman ๋‹น์‹ ์€ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค :

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

ํ›Œ๋ฅญ ํ•ด์š”, ๊ณ ๋ง™์Šต๋‹ˆ๋‹ค! ํ›„์†์„ ์œ„ํ•ด ๋‚ด getter๋Š” ๊ธฐ๋ณธ๊ฐ’์„ ์„ค๋ช…ํ•˜๊ธฐ ์œ„ํ•ด ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋ณด์ž…๋‹ˆ๋‹ค.

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

๋‹ค์Œ๊ณผ ๊ฐ™์ด ๋” ๊ฐ„๋‹จํ•˜๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

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

๊ทธ๋Ÿฐ ๋‹ค์Œ ํ…Œ์ŠคํŠธ์—์„œ mediaTag.paused = true ๋˜๋Š” mediaTag.paused = false ๋ฅผ ๋ณ€๊ฒฝํ•˜์‹ญ์‹œ์˜ค.

์ด ์ ‘๊ทผ ๋ฐฉ์‹์˜ ์ด์ ์€ TypeScript๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ ์œ ํ˜•์ด ์•ˆ์ „ํ•˜๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. (mediaTag as any).mockPaused = true ์™€ ๊ฐ™์ด ๋ชจ์˜ ์†์„ฑ์„ ์„ค์ •ํ•˜์ง€ ๋งˆ์‹ญ์‹œ์˜ค.

๋” ์ข‹์Šต๋‹ˆ๋‹ค. ๊ฐ์‚ฌํ•ฉ๋‹ˆ๋‹ค!

๋น„๋””์˜ค ์žฌ์ƒ์„ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ํ•˜๋ ค๋ฉด ์–ด๋–ป๊ฒŒ ํ•ด์•ผ ํ•ฉ๋‹ˆ๊นŒ?
์žฌ์ƒ ๋ฐ ๋กœ๋“œ๋ฅผ ์Šคํ…ํ–ˆ์ง€๋งŒ ๋น„๋””์˜ค ์žฌ์ƒ์„ ์‹œ์ž‘ํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๋ชจ๋ฅด๊ฒ ์Šต๋‹ˆ๋‹ค(๋˜๋Š” ์žฌ์ƒ ์ค‘์ธ ๊ฒƒ์œผ๋กœ ์ƒ๊ฐํ•˜๋ฉด ์ž์ฃผ ๋ฐœ์ƒํ•˜๋Š” ์ผ๋งŒ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค).
Object.defineProperty(HTMLMediaElement.prototype, "play", { get() { document.getElementsByTagName('video')[0].dispatchEvent(new Event('play')); } });

jsdom์€ ๋ฏธ๋””์–ด ๋กœ๋“œ ๋˜๋Š” ์žฌ์ƒ ์ž‘์—…์„ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์œผ๋กœ ํ…Œ์ŠคํŠธ ์„ค์ •์— ๋ช‡ ๊ฐ€์ง€ ์Šคํ…์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์— ๊ฐ์‚ฌ๋“œ๋ฆฝ๋‹ˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์ด๊ฒƒ์ด ๊ธฐ๋ณธ์ ์œผ๋กœ ์ง€์›๋˜์ง€ ์•Š๋Š” ์ด์œ ๋ฅผ ์—ฌ์ญค๋ด๋„ ๋ ๊นŒ์š”?

์•„์ง jsdom์—์„œ ๋น„๋””์˜ค ๋˜๋Š” ์˜ค๋””์˜ค ํ”Œ๋ ˆ์ด์–ด๋ฅผ ๊ตฌํ˜„ํ•œ ์‚ฌ๋žŒ์€ ์—†์Šต๋‹ˆ๋‹ค.

๋‹ค์Œ์€ ํ…Œ์ŠคํŠธ์— ํ•„์š”ํ•œ ์ผ๋ถ€ ์ด๋ฒคํŠธ๋ฅผ ๋ณด๋‚ด๋Š” play ๋ฐ pause ๋ฉ”์„œ๋“œ( 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'))
}

๊ทธ๋ฆฌ๊ณ  ํ…Œ์ŠคํŠธ์˜ ์˜ˆ( 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)
  })

์ด๋Ÿฌํ•œ ํ•ด๊ฒฐ ๋ฐฉ๋ฒ•์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ–ˆ์ง€๋งŒ ์žฌ์ƒ ๊ธฐ๋Šฅ๊ณผ ๊ฐ™์€ ๋ธŒ๋ผ์šฐ์ €๋ฅผ ๋‹ค์‹œ ๊ตฌํ˜„ํ•˜๋Š” ๋Œ€์‹  puppeteer ๋ฅผ ์‚ฌ์šฉํ•˜๊ธฐ๋กœ ๊ฒฐ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ฆ‰, ํ…Œ์ŠคํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•  ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ €๋ฅผ ๊ฐ€์ ธ์™”์Šต๋‹ˆ๋‹ค. ์ด๊ฒƒ์€ ๋‚ด ์„ค์ •์ž…๋‹ˆ๋‹ค.

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"
  ]
}
**ํŒจํ‚ค์ง€.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"
  }
}

์ด ํŽ˜์ด์ง€๊ฐ€ ๋„์›€์ด ๋˜์—ˆ๋‚˜์š”?
0 / 5 - 0 ๋“ฑ๊ธ‰