jsdomを使ってテストしていたらたまに[Symbol(SameObject caches)]と言われてテストが落ちる

なんだか、とりとめもない、解決したけど結局よくわからなかったことを書いておく。これはメモ書きです。


jsdomを使ってDOMの様子を再現するテストを書いていてハマった。テストライブラリはAVAでやっているのでAVAを例に書く。

こんな感じのことを言われた

Expected promise to be rejected, but it was resolved instead

  Resolved with:

  HTMLDivElement {
    [Symbol(SameObject caches)]: {
      childNodes: NodeList {},
    },
  }

本来はDOMが見つからなくてPromiseがrejectされることを期待しているテストケースなのに、DOMがあることになっていてresolveが返ってきてしまっている

背景

AVAのテストはこういう感じにglobalにwindowオブジェクトをブチ込んでいて、

import test from 'ava';
import delay from 'delay';
import jsdom from 'jsdom';
import m from '.';

const dom = new jsdom.JSDOM();
global.window = dom.window;
global.document = dom.window.document;

require('mutationobserver-shim');

global.MutationObserver = window.MutationObserver;

test('FOO1 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

test('FOO2 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

.
.
.

似たようなDOM操作のテストケースが羅列されている状況。

どうやらエラーが起こるテストケースとは別のテストケースで生成したDOMを読んでいるのだと予想した。

実際にlogをみてみる

FOO2テストでコケていると仮定

test('FOO1 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

test('FOO2 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

FOO1で生成した<div id="foo"></div>がFOO2で見えているのではないか?と予想

FOO2でlog

test('FOO2 ?', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'foo';
        document.body.appendChild(el);
    });

        console.log(document.querySelector('#foo'))

    const checkEl = await m('#foo');
    t.is(checkEl.id, 'foo');
});

なんとnullが返ってくる! つまりFOO2テストケースではFOO1で生成したDOMは見えてない…

document.bodyをみてみる

console.log(document.querySelector('#foo'))

console.log(document.body)

すると

HTMLBodyElement {
  [Symbol(SameObject caches)]:
   { childNodes:
      NodeList {
        '0': [HTMLDivElement],
        '1': [HTMLParagraphElement],
        '2': [HTMLSpanElement],
        '3': HTMLDivElement {} } } }

なんかいる!Same Object cachesとあるとおりキャッシュっぽい??

対策

AVAのbeforeEachフックで各テスト実行時前にbodyタグ内をリセットしてやる

test.beforeEach(t => {
    // Force resetting the Object caches for each test.
    document.body.remove();
    document.documentElement.appendChild(document.createElement('body'));
});

すると

HTMLBodyElement {}

消えた。

このキャッシュなに?

developer.mozilla.org

取り除かれた子ノードはしばらくは記憶領域上に残りますが、もう DOM の一部ではありません。取り除いたノードは、oldChild オブジェクト参照を通じて、コード中で後で再利用することができます。

Node.removeChildの頁にこういうことが書かれている。

jsdomが内部的にremoveChild()してるのかはちょっとわかってないけど、もしそうならこういうキャッシュがあるらしいし、見えないけど見えるみたいなやつかもしれない。


でもまだ直らなかった

結局のところ、エラーが出ていたFOO2はAVAのt.serial()で同期処理にしてやったらよかった。実際には非同期でPromise待機しつつsetTimeoutっていうかなりややこしい関数をテストしていて、どうやらなにかしら絡み合うものがあるっぽい?AVAは普通にtest()と書くと並列テスト実行なので、どうやらそれの影響っぽかったのでserial()にしてみたら期待通りに通るようになった。

よくわからないまま解決してスッキリはしないけどメモとして書いた。

jsdomを使ってMutationObserverをテストする方法

github.com

JavaScriptでブラウザのAPIを使ったコードをテストするときはjsdomを使うと思う。大抵のDOMのAPIは揃ってるのでテストできるけどMutationObserverをテストしようとしたところjsdomはMutationObserverを持ってないのでテストできなくて困った。「MutationObserverなんてないよ」と怒られてしまう。

Polyfillを使って回避する

ようはjsdomにMutationObserverがないからMutationObserverのそれに近いものを足せばOKということになる。

github.com

ES3ブラウザに向けて書かれたshimを作っている方がいるのでこれをありがたく使わせて頂く。

$ npm install -D mutationobserver-shim

入れたらテストをこう書く。自分はAVAを使っているのでAVAの例。

import test from 'ava';
import delay from 'delay';
import jsdom from 'jsdom';
import m from '.';

const dom = new jsdom.JSDOM();
global.window = dom.window;
global.document = dom.window.document;

require('mutationobserver-shim');

global.MutationObserver = window.MutationObserver;

test('MutationObserver test', async t => {
    delay(500).then(() => {
        const el = document.createElement('div');
        el.id = 'late';
        document.body.appendChild(el);
    });

    const checkEl = await m('#late');
    t.is(checkEl.id, 'late');
});

'm'はテストしたい自分のモジュール。mは内部でMutationObserverを利用している。

MutationObserverはブラウザではwindowオブジェクトにぶら下がっているけれどNode.jsにはそもそもwindowオブジェクトはない。ので、まずNode.jsのglobalにjsdomのwindowオブジェクトを作ってあげる。

require('mutationobserver-shim');

windowオブジェクトできたうえで、副作用requireしてMutationObserverを定義してあげる。するとこれでテストできるようになる!

めでたい

とりあえずこれでMutationObserverのテストが出来ていると思う。実際にテスト書いて通っている。

今件5年も前からIssueあるけど進捗がない。1度jsdomにPR出されてたけどパフォーマンスと実装の保守が難しいとかなんとかでリジェクトされてそのまま月日が経っている。コメントの中にAureliaのプロジェクトでTypeScriptでpolyfill書いて同じようにやっている人がいるけど、これをJS環境にもってきてもうまく動かなかった(これはたぶん自分の力量がないせいだと思う)

あくまでこの書いた方法はpolyfillを使っているので仕様策定され実際のブラウザで実装されているものとは少し挙動が違う。ので完全にテストできているとは言えないかもしれないけど、現状これしかMutationObserverのテストはできないと思う。

本来はjsdom本体に実装されて欲しいけど現状はそういうこと、ということで。

Node.jsのアップデートをするとnpmのグローバルモジュールが消える

タイトルのことで困っていた。

僕はNode.jsのパッケージマネージャにnodebrewを使っているのだけど、先日使おうと思ったノードモジュールが動かなかったのでNodeのバージョンかもしれないと思ってv8.4.0からv9.5.0に変えた。それでその時に気付いたけどタイトル通りv9.5.0にするとnpmの-gで入れていたモジュールがシェルから使えない、ということに気付いた。nodebrew use v8.4.0してv8.4.0に切り替えるとまた使えるようになる。

そういうものっぽい

github.com

これは世界ではデファクトスタンダードっぽいパッケージマネージャnvmのissueだけど、ここを見ていたらどうやらそういうものらしい。異なるnpmとnodeでは互換性が担保されないので単一のグローバルモジュールを異なるnode間で共有するのはムリが生じるからやりたくないということらしい。

自分はてっきりnpmの-gインストールはnodeと分離されて共通なものなのだという認識でいたからこれを問題だと考えてしまっていただけだった。

移行コマンドがある

github.com

nodebrewの場合は公式リポジトリのdocsのAll commandsのなかに密かに1行だけ書かれていて、上のnmvのissue読まなかったら経緯がわからないのでなんのコマンドなのかさっぱりわからないと思う。

$ nodebrew migrate-package <version>

のとこは移行元のバージョン番号を入れる(v8.4.0のように)

ちなみにnvmにも同様のコマンドは上のissueでもあるように用意されている。

github.com

$ nvm install node --reinstall-packages-from=node

シンボリックリンク??

それで、nodebrewで$ nodebrew migrate-package <version>したところ、なんとv8.4.0のグローバルモジュールにシンボリックリンクを貼っているっぽい。

/Users/username/.nodebrew/node/v8.4.0/lib/node_modules #にあるグローバルモジュールへ
/Users/username/.nodebrew/node/v9.5.0/lib/node_modules #にシンボリックリンク設置している

まあ当たり前に問題なく動いているのだけど、これだと以前のnodeのバージョンを消せないではないか!ということになっていてうーむ、と思っている。無限に増え続けるnode.jsは嫌なので困った。今のところはまあいいのだけど……。

nvmの--reinstall-packagesでどういう処理になるのかはわからない。nvmは使っていないので。

Travis CIでデプロイ処理でprovider: scriptするとき、複数の処理がうまくいかない

デプロイ処理でprovider: scriptしているときに複数のスクリプト処理をしたいがコケる

deploy:
  provider: script
  skip_cleanup: true
  script:
    - yarn run build
    - yarn run test
    - yarn run deploy
  on:
    branch: master
    tags: true

なぜ?

Issue情報

なるほど

deploy:
  provider: script
  skip_cleanup: true
  script:
    yarn run build && yarn run test && yarn run deploy # アンパサンドで書く
  on:
    branch: master
    tags: true

アンパサンド連結でこう書く。

script:
  - foo
  - bar
  - baz

なぜ他の欄のscript:の書き方では通るこの書き方で通らないのか!!となるけど、まあprovider: scriptがまだ現時点(2018-02-11)も実験的な機能実装扱いだからそういうものなんやで〜、ということになる。

いつか改善されて欲しい!

Travis CIで"SH: 1"とか言われnpm scriptsがコケる

sh: 1: gulp: not found

と言われてコケる。原因はpackage.jsonnpm scripts

"scripts: {
  "build": "gulp hoge --production"
}

としつつ、.travis.yml

deploy:
  provider: script
  skip_cleanup: true
  script:
    yarn build # run省略
  on:
    branch: master
    tags: true

yarn buildというようにrunを省略していたからだった。

deploy:
  provider: script
  skip_cleanup: true
  script:
    yarn run build # run追加
  on:
    branch: master
    tags: true

これでコケなくなった。

Issue情報

github.com

参考になった。

Netlifyには連携先のリポジトリURLを格納している環境変数がある

Netlifyはデプロイ時にprocess.envにREPOSITORY_URLというキーでgitリポジトリを格納している。

process.env.REPOSITORY_URL // 連携先がGitHubならGitHubのリポジトリURLが入っている

なのでNetlifyでのビルド時だけリポジトリURLを参照してなんかやりたいっていうときなどに便利。以前使ったけど書くの忘れていた。

前はドキュメントに書いてなかったからNetlifyのコントリビューターの人のGitリポジトリでたまたま存在知ったのだったけど、今みたらドキュメントに環境変数のことがしっかり書かれていた。

www.netlify.com

これはメモ書きです。

CSS variablesで変数をマイナス値で出力したい

(当記事は2018-01-20現在の情報です、仕様が変わって情報が古くなっているかもしれません)


CSS variablesを触っていて、定義した変数の値をマイナス値(negative value)で扱いたかった。

こんな感じでいけるだろうと思ったが…

:root {--block-position-top: 50px;}

.block {
  top: - var(--block-position-top); // -50pxとしたかった
}

いけなかった

github.com

なんかこういう扱いはダメっぽくて、calc()を使えということだった

マイナス値で出力するにはこう

:root {--block-position-top: 50px;}

.block {
  top: calc(-1 * var(--block-position-top));
}

なるほどcalc()で-1をかけてやれば確かに…。

正直なところ「これ正気か?」という気持ちがあるけどまだまだ策定中の技術なので仕方なさそう。

とはいえCSS Variables使ってみると結構便利なのでそこまで嫌いではない。