読者です 読者をやめる 読者になる 読者になる

仮想Sierra環境を作る

f:id:masataka_k:20160924084408p:plain

Mac環境を汚さないように壊れてよい仮想なMacを作ります。どうせいまなら出たばかりのSierraで。Mac on MacVMWareをつかうと超簡単と聞きますが、追加無償でも十分!VirtualBoxで作ります。ホストはEl Capitan。トータルで2時間半ぐらいかかりましたが、ほとんどがSierraのダウンロードと、インストールの待ち時間であり、作業は正味30分もかからなかったと思います。

Sierraのイメージを手にいれる(実績1時間以上かかった)

App Storeで「macOS Sierra」の「ダウンロード」ボタンを押してダウンロード開始。回線状態にもよるでしょうが大変時間がかかります(自宅は超高速なんですが、オフィスの回線は遅い)。ダウンロード完了したらインストーラーが起動するのをキャンセルします。/Applicationに、「Install OS X Sierra.app」が得られます。

Sierraのisoファイルを作成する(15分)

Creating a bootable El Capitan ISO image - 0xcafebabe

この記事のとおり。記事はEl CapitanですがそのままSierraに読み代えれば全てOKです。一発目のhdiutil attachのときに引数をダブルクォーテーションで囲わないとエラーです。タブキー押しでファイル名補完をその後のcpではOKですが一発目のhdiutilだけはダメ。あと、二発目のhdiutil createがちょっと時間かかって不安になりましたが、待てば良い。結果として言えることは、El CaptianとSierraでインストーラの構造などまったく一緒なんですね。

$ hdiutil attach "/Applications/Install OS X Sierra.app/Contents/SharedSupport/InstallESD.dmg" -noverify -nobrowse -mountpoint /Volumes/esd
$ hdiutil create -o Sierra.cdr -size 7316m -layout SPUD -fs HFS+J
$ hdiutil attach Sierra.cdr.dmg -noverify -nobrowse -mountpoint /Volumes/iso
$ asr restore -source /Volumes/esd/BaseSystem.dmg -target /Volumes/iso -noprompt -noverify -erase
$ rm /Volumes/OS\ X\ Base\ System/System/Installation/Packages
$ cp -rp /Volumes/esd/Packages /Volumes/OS\ X\ Base\ System/System/Installation
$ cp -rp /Volumes/esd/BaseSystem.chunklist /Volumes/OS\ X\ Base\ System/
$ cp -rp /Volumes/esd/BaseSystem.dmg /Volumes/OS\ X\ Base\ System/
$ hdiutil detach /Volumes/esd
$ hdiutil detach /Volumes/OS\ X\ Base\ System
$ hdiutil convert Sierra.cdr.dmg -format UDTO -o Sierra.iso
$ mv Sierra.iso.cdr Sierra.iso 
$ rm Sierra.cdr.dmg

VirtualBoxをインストールする(10分)

https://www.virtualbox.org/ からMacインストーラをダウンロードしてきてインストール。これもダウンロード長かった。

VirtualBoxでゲストインスタンスを作る(20分)

Mac OS X(64-bit) ですべてデフォルトにて作成すればOK。先ほど作った「Sierra.iso」を指定する。コンソールに起動ログが流れるという、いままで見たことのないMacの立ち上がり方ののちにmacOSインストーラが立ち上がる。そのままではインストール先ディスクがないので、ディスクユーティリティを起動してメインのディスクを「消去」にて、Mac OS拡張(ジャーナリング)指定。ユーティリティを終了するとインストール先として選択できるようになっている。以下の記事に後半にスクショもあって詳しい。前半のisoイメージ作るところはこちらは手作業で終わってますので飛ばします。スクショで見るディスクユーティリティとSierraのディスクユーティリティは画面が結構違うのですが、ちょっと考えればすむ程度の違いです。

VirtualBoxにOS X Yosemiteをインストールする – OTTAN.XYZ

ゲストのSierraからホストのフォルダを見えるようにする(1分)

ゲストへホストから色々とブツを送り込むために、フォルダ共有をかける。以下の記事の説明どおりにSMB共有をかける。

How to Access Host's Shared Folder from MacOSX Guest

記事で「何で!?って聞くだろ〜」というように、10.0.2.2につなぐってのが驚いたけどたしかに問題なく接続完了。OK!

App Storeでシステムアップデート(40分)

ゲストのApp Storeでアップデート検索をする。iTunes 12.5.1他ありました。これまたダウンロードが長い。

ところでSierraって

いろいろ試してみたら、不具合なく動くものばかりでしたが、私は唯一!愛用のキーアサインメントを変更するツールである「Karabiner」が最新の10.21.0でもSierraは対応していなく動きませんでした。サイトを見るとまだ対応していないって書いてあります。

Karabiner - Software for OS X

macOS Sierraサポート状況 Karabinerは今のところmacOS Sierraでは動作しません。 Sierra対応は、まずは単純なキーの変更を行える機能をKarabiner-Elementsとして開発中です。 (設定画面を除くとSierra上で動作しています) Karabinerのフル機能のSierra対応はKarabiner-Elementsが完成してから対応します。

ということで、Karabiner-ElementsへGO。英語キーでの左右Commandキーの空押しに英数/かなを割り当てるという一点だけなのですが、これがないと死ねる。

Karabiner-Elementsの設定

GitHub - tekezo/Karabiner-Elements: The next generation Karabiner prototyping for macOS Sierra

.karabiner.d/configuration/karabiner.json の内容を書いてみた。キーの空押し対応はドキュメントになかったのでとりあえず左commandはコピー・ペースト・編集戻しと多用しますので割り当てず、左optionで代用。commandもoptionも二個あるからなんとでもなる。

{
    "profiles": [
        {
            "name": "Default profile",
            "selected": true,
            "simple_modifications": {
                "left_option": "japanese_eisuu",
                "right_command":"japanese_kana"
            }
        }
    ]
}

追記:⌘英かな

GitHub - iMasanari/cmd-eikana

commandキーの空押し問題で調べてたら、これをみつけました。これでいい!

SONY MDR-100ABN/Yを買った

ガジェット

長らくBeats Studio WirelessのTitaniumカラーを愛用してきましたが、急に激しい物欲が発作したのと、それを正当化する理由がいくつか出てきたので新しいヘッドホンを購入しました。選んだのはSONY MDR-100ABN/Yで、外れたら返品すればいいやと他の候補とは悩んでません。/Yは色型番でLime Yellowという色名前になります。日本での実勢価格が3万円ぐらいに下がってきていたみたいなところ、初見ではAmazonでもどこでも米国定価の$348で横並びでした。今月に日本出張の予定があるので日本で買おうかと思ってたら、翌日に偶然にも(本当に偶然。本当に驚いた)Amazonで新品$300で出てたのを見つけたので即ポチ。色は5色あるうちの2色だけが$300でしたが検討していたLime Yellowがあったので反射神経で押してました。もう一つの候補だった緑は安くなってなかったのでこちらも悩みがなくなった。プライム会員の無料2Dayお届けですぐに手元へ。Sales Taxもゼロなのでポッキリ$300。

参考:Beatsを買った時のこと

Beats by Dr. Dre を買うまで - まさたか日記

Beats Studio Wireless短評 - まさたか日記

開梱の儀

f:id:masataka_k:20160712111612j:plain

f:id:masataka_k:20160712111625j:plain

外箱ですが、インターナショナル版。英語と中国語と韓国語が併記されています。日本語はありません。

f:id:masataka_k:20160712111813j:plain

f:id:masataka_k:20160712112126j:plain

箱からケーブルまで統一されたLime Yellow色は、やや緑がかかった綺麗な中間色。色としては優しい色なんですが、黒とか銀とか白とかが多いヘッドホンとしてはかなり派手ですね。今月で44歳になる地味なおっさんですから、ヘッドホンぐらい派手にして気分盛り上げようと思います。

f:id:masataka_k:20160714114641j:plain

f:id:masataka_k:20160714114649j:plain

左ハウジングにはかぶって前方から、マイク・有線ケーブル接続・ミニUSB接続・電源ボタン・電源インジケータ・ノイズキャンセラ(NC)ボタン・NCインジケータと並びます。

f:id:masataka_k:20160714114701j:plain

右は前方からマイク・音量-・+、戻り・再生/停止/電話応答・送り。再生ボタンはスライドと押し込みの両方で使い分けるようになっています。左右両方にマイクがあるのは驚いた。Beatsは片方。

f:id:masataka_k:20160715035633j:plain

横からアーチ部に見えるエンボスSONYロゴは左右とも同じ箇所に刻まれています。頭頂部近くに「Wireless」と黒銀で印字されていますがこれは右だけ。手に持ってるのは愛用中のLAMY万年筆ですが、2015年限定色のネオンライムとお揃いになりました。

箱といい本体といい、まんまBeatsの雰囲気ですが、後発なのにパクリきれてない。SONYのデザイン力についてBeatsのそれよりは少々落ちると断じていいんじゃないかな。少々ですけど。

短評

www.youtube.com

このYouTube動画を見て欲しくなったのですが、この動画の冒頭で電車の騒音を消しているところの効果のほどはほとんど嘘じゃないです。このぐらい騒音消して静かにしてくれます。

使ってみての短評について、他に持ってないので比較対象がBeats Studio Wirelessしかありませんが以下の通りです。

  • Beatsよりややこちらの方が軽い。頭頂や耳周辺の圧迫感は同等ぐらい。
  • 音はこちらの方が高音も低音も断然いいと思います。Beatsは無線接続時の音が悪いように思うので普段は有線接続で使ってましたが、こちらは無線で十分以上に音質いいと満足してるのでずっと無線で使ってます。
  • ノイズキャンセラーはBeatsはもともと弱め機能で、本格的なこちらに当然の軍配あげ。無音時にうっすらとホワイトノイズが残りますがBeatsの比でなくとても静かです。エアコンや外の騒音など全てカット。しかし人の声だけは通ります。どうやら製品としてそういう調整がされているらしい。
  • 操作はBeatsがハウジング全体がボタンになってるというオシャレ設計のため、再生の送りや戻しの時にダブル・トリプルの押し込みが必要なのですが、こちらはスライドなので間違いなく便利。たくさんボタンがあるためオシャレ度は落ちますけど、あまり見えるところじゃないし関係ないかな?
  • バッテリー持ちは多分、こちらの方が長そう。届いてからまともに充電しないまま、使用三日目になります。毎日6時間以上ぐらいは使ってるんじゃないかな?朝からオフィスで作業しながら5時間音楽聴いてて、夕方になると日本とのビデオ会議で1時間以上。
  • バッテリー残量を調べる術がこちらにはない?説明書にも書いてない。Beatsはボタンを軽く押すと5つのLEDインジケータがグラデーション点灯してうまいこと残量表示してくれ、エフェクトが素敵だし便利だった。
  • 試しに充電のミニUSBケーブル繋いだら、電源落ちて使えなくなった。Beatsは充電しながら使えたのでこれはSONYの残念な点。
  • どちらも有線接続するとハウジングの操作ボタン類が効かなくなる。Beatsはその代りにケーブルにボタンが付いているけどSONYはボタンなし。ちょっとだけSONY残念な点。でも無線でしか使わないからどうでもいいかな。
  • 有線接続すると、Beatsは電源が自動で入ります。この時バッテリー残量ゼロだと音が聞こえない。でもこちらは電源OFFで有線接続しても音も聞こえるしマイクも使える!これは地味だけど大加点ポイントだなあ。有線接続では電源オンとオフどちらも可能で、オンにするとノイズキャンセラーを有効にすることができます。

f:id:masataka_k:20160714114523j:plain

さて、左右ハウジングの上部に穴(写真では黒く見えるところ)がアームに隠れて開いてます。ここから音が漏れる。ノイズキャンセラが強く効くので再生音量を小さくすればいいとクチコミサイトにはありますが、比較論としてBeatsの方が音漏れしない。まあ、私には日本で暮らしてた時のように通勤電車で聴くとかの人混みシチュエイションが全くないので問題ではないんですけど。

総合的に、Beatsより満足。買ってよかった。毎日ビデオ会議があるからマイク付きのヘッドホンは必需品なのです。そして毎日使うところに気に入ったものが手に入ったので、癒された。7/20米国発、7/21〜8/4に日本滞在ですが往復の飛行機での機内静音ぶりが今から楽しみ。

material-uiのSVGアイコンを作る

JavaScript

Googleはマテリアルデザインのガイドラインを提供するほかに、CSS+ JavaScriptのライブラリ(Material Design Lite)やフォント(Roboto/Notoなど)も用意し、アイコンもまたまとまった数のものがあります。

design.google.com

このアイコンは最も使われるだろうPNG形式、ついでアイコンフォント形式のほか、SVG形式も一緒に配布されています。取り扱いとして他より工夫がいるけれども拡大縮小に強い上でHTML文書に直接埋め込めるSVGは、私は最近理解して使い始めたばかりですが、アプリケーションも構成しやすくコードで直接コントロールもできるので重宝し始めました。

承前としてSVG

<html>
<body>
<svg>
    <path d="M11.5 17l4-8v-2h-6v2h4l-4 8h2z"/>
</svg>
</body>
</html>

まず承前として上記のHTML断片は数字の「7」をSVGで書いてます。描画はsvg>path@dで設定されているコマンド文字列でベクタ描画。これの読み方が知らないと訳わかりませんが順を追っていくと結構単純なことの組み合わせでした。まず"M11.5 17l4-8v-2h-6v2h4l-4 8h2z"はアルファベット+数字の組み合わせで、区切り文字はカンマか空白か符号というルールで分解します。以下は独自ですが関数呼び出しのフォーマットで書き直してみます。

  • M(11.5, 17)
  • l(4, -8)
  • v(-2)
  • h(-6)
  • v(2)
  • h(4)
  • l(-4, 8)
  • h(2)
  • z

分解するとあとはアルファベットが命令、数字が座標です。命令アルファベットが大文字だと続く数字は絶対座標で、小文字だと相対座標になります。Mは始点の指定コマンドのためまず(11.5, 17)から続くl(小文字のエル)コマンドで相対座標(+4, -8)へ直線を引きます。vは垂直線、hは水平線ですから、vで相対座標(0, -2)、hで相対座標(-6, 0)へ。。。以下続く。最後にzはパスを閉じるコマンドで、ここで始めにMで決めた絶対座標(11.5, 17)へ戻ってきてなければいけません。他、ベジェ曲線を書くこともできます。

Paths - SVG | MDN

material-ui

materilal-uiでは、このSVGを保持するReactコンポーネントがあり、Material Iconsをそのまま取り込んでくれています。

'use strict';
import React from 'react';
import {ToggleStar, ToggleStarHalf, ToggleStarBorder} from 'material-ui/lib/svg-icons';

const Stars = React.createClass({
    render() {
        return (
            <div>
                <div><ToggleStarBorder/></div>
                <div><ToggleStarHalf/></div>
                <div><ToggleStar/></div>
            <div>
        );
    }
});
export default Stars; 

f:id:masataka_k:20160228064049p:plain

こんな感じ。このToggleStarとかは見るとSvgIconコンポーネントにMaterial Iconからそのままもらってきたpathデータを流し込んで作ってます。

SVGアイコン自作

ということで、さっきの7をコンポネント化します。そもそも7を作り8も9も作った理由はMaterial IconでImage-Looksという角丸の四角に素朴な味わいの数字が入ったアイコンが1から6までしかなかったからでした(looks one 〜 looks 6まで)。

'use strict';

import React from 'react';
import {SvgIcon} from 'material-ui';
const backgroundRoundRect = 'M19 3c1.1 0 2 .9 2 2v14c0 1.1-.9 2-2 2h-14c-1.1 0-2-.9-2-2v-14c0-1.1.9-2 2-2h14z';
const seven = 'M11.5 17l4-8v-2h-6v2h4l-4 8h2z';
function Looks7(props) {
    return (
        <SvgIcon {...props}>
            <path d={backgroundRoundRect + seven}/>
        </SvgIcon>
    );
}

const eight = 'M11 17h2c1.1 0 2-.89 2-2v-1.5c0-.83-.67-1.5-1.5-1.5.83 0 1.5-.67 1.5-1.5v-1.5c0-1.11-.9-2-2-2h-2c-1.1 0-2 .89-2 2v1.5c0 .83.67 1.5 1.5 1.5-.83 0-1.5.67-1.5 1.5v1.5c0 1.11.9 2 2 2zM11 9h2v2h-2v-2zM11 13h2v2h-2v-2z';
function Looks8(props) {
    return (
        <SvgIcon {...props}>
            <path d={backgroundRoundRect + eight}/>
        </SvgIcon>
    );
}

const nine = 'M13 7h-2c-1.1 0-2 .89-2 2v2c0 1.11.9 2 2 2h2v2h-4v2h4c1.1 0 2-.89 2-2v-6c0-1.11-.9-2-2-2zm0 4h-2v-2h2v2z';
function Looks9(props) {
    return (
        <SvgIcon {...props}>
            <path d={backgroundRoundRect + nine}/>
        </SvgIcon>
    );
}

苦労した点は、Material Iconのデータが”汚い”コードだってことが一つ。多分自動生成した際のロジックが悪いのか、描画内で相対座標と絶対座標を雑に混在したものになっているために、既存アイコンのパスデータの一部を持ってきて新しいアイコンを作ろうとすると、すぐ座標系が壊れちゃう。そのためにパス要素毎にまず始めにMコマンドで絶対座標を決めてからそこからzで閉じるまでは相対パスで書き通すように変更して再利用しました。

苦労したもう一つは、7/8/9のアイコンを作るにあたって、背景の ”backgroundRoundRect” と "seven"/'eight"/"nine" をつなげて数字がくり抜きできなかったこと。苦労の結果、今は白抜きできています。こちらの原因はパスの書き方で、同じ図形でも時計回りにパスを辿っても反時計周りに辿っても見かけは一緒なのですが、同じ巻きを重ねてもくり抜かれないというルールがありました。よって数字は反時計まわりだったので背景は時計まわりにしなければならない。超はまった。

しかし、わかると最強。これは便利だ。

JsDOM+React TestUtilsの使い方を改良

JavaScript

TestUtilsにはいろいろ便利なメソッドがあるのを見つけてテストの書き方を変えたら、UserAgentが設定されていないということでエラーが出るようになってしまいました。かといって直接にnavigator.userAgentを設定しようにもGetterしかないと弾かれてしまいます。調べるうちにJsDOMの使い方を変えるとOKなことを発見。

gulp.task('test', () => {
    jsdom.env({
        html: '',
        userAgent: 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 ' +
                           '(KHTML, like Gecko) Chrome/49.0.2454.85 Safari/537.36',
        done: (err, window) => {
            if(err) throw err;
            for (let key in window) {
                if (window.hasOwnProperty(key) && !global[key]) {
                    global[key] = window[key];
                }
            }
            console.debug = console.log; // console.debugがundefinedだった
            gulp.src('src/**/__tests__/*Test.js')
                .pipe(jasmine({reporter: new reporter()}));
        }
    });
});

gulpfile.jsの中ではJsDOMのenv()を使うようにしました。結局はglobalにプロパティを書き出しているのでテスト環境が隔離されているのではないのは変わりませんが、useAgentは設定できます。この環境ではconsole.debugがundefinedだったので足してあげたりもしています。値は、inline-style-prefixer/prefixer-test.js at master · rofrischmann/inline-style-prefixer · GitHub から持ってきました。そもそもこのライブラリがエラー出してたのです。

describe('TestTable', () => {
    it('表示', () => {
        const root = TestUtils.renderIntoDocument(<TestTable value={data}/>);
        const trs = TestUtils.scryRenderedDOMComponentsWithTag(root, 'tr');
        const node = ReactDOM.findDOMNode(trs[0]).childNodes;
        expect(node[1].textContent).toBe('test-test-test');
    });
});

scryRenderedDOMComponentsWithTag / scryRenderedDOMComponentsWithClass / scryRenderedDOMComponentsWithType がそれぞれ何かと使える。

素のnodeでES6の多くが動く

JavaScript

ECMAScript 2015 (ES6) | Node.js

今、BabelでES6とJSXをトランスパイルしてますが、そのBabelを起動するGulp(gulpfile.js)についてはES5で書いてました。しかしすでにnode v5.xではV8エンジンの対応状況が進んできているためにES6の仕様の多くが動いちゃうんですね。

$ node --v8-options | grep "in progress"
  --harmony_modules (enable "harmony modules" (in progress))
  --harmony_regexps (enable "harmony regular expression extensions" (in progress))
  --harmony_proxies (enable "harmony proxies" (in progress))
  --harmony_sloppy_function (enable "harmony sloppy function block scoping" (in progress))
  --harmony_sloppy_let (enable "harmony let in sloppy mode" (in progress))
  --harmony_unicode_regexps (enable "harmony unicode regexps" (in progress))
  --harmony_reflect (enable "harmony Reflect API" (in progress))
  --harmony_destructuring (enable "harmony destructuring" (in progress))

ただ、modulesがまだダメだった。import/exportはまだin progress。でもlet/constやArrow Functionはレディなのでモジュールシステムだけrequireにしておけば他は大体ES6に書けました。ここでは出てこないけどES6-classもすでに使えるわけね。

'use strict';

const gulp = require('gulp');
const jasmine = require('gulp-jasmine');
const reporter = require('jasmine-terminal-reporter');
const jsdom = require('jsdom').jsdom;
require('babel-register');

gulp.task('test', () => {
    global.document = jsdom('');
    global.window = global.document.defaultView;
    for (let key in global.window) {
        if (global.window.hasOwnProperty(key) && !global[key]) {
            global[key] = global.window[key];
        }
    }
    gulp.src('src/**/__tests__/*.js')
        .pipe(jasmine({reporter: new reporter()}));
});

そもそもObject.assign()とかES6とは知らずに使ってた。

Reactコンポーネント内からkeyが取れない仕様だった

JavaScript

http://facebook.github.io/react/blog/2014/10/16/react-v0.12-rc1.html#breaking-change-key-and-ref-removed-from-this.props

すでに0.12での仕様変更でしたから、私がReactに触れてからはずっとそうなってたのですが、自分で書いたコードの謎バグに悩まされて初めて知りました。this.props.ref及びthis.props.keyはコンポーネント内から参照できない。。。常にundefined。

gulp-jasmineでES6で書いたReactコンポーネントとReactステートレスコンポネントさらにはExpressルーターを同時にテストできるようにする

JavaScript

一通りハマって模索した結果、それぞれしっかり原因判明やりきって解決するまでの質ではないのですが、ES6 で書いたReactコンポーネントとステートレスコンポーネントをテストするとともに、ES6で書いたExpressルーターもまとめてテストできるプロジェクト設定・構成が一つ用意できました。

まず前提。

  • ES6で書いて、Babelでトランスパイルしている
    • jestで作ったテスト環境では、SuperTestを用いたExpressルーターテストがどうにも動かなかった
    • jest.autoMockOff()をかけても、あちらこちらでモックになっちまう。
    • 先にBabelトランスパイルしてから、出力ファイルでjest実行してみてもなぜか改善せず
    • jestは難しいことを簡単にできて素敵に思っていたけど、意外に簡単と思ってたところでエラーが出始め。解決がどうにも難しくてjestにギブアップしてしまった
  • Reactステートレスコンポーネントはテストが動かない。
    • 普通のReactコンポーネントはテスト可能
    • 後で判明するけどこれはjestのせいじゃなかった

ぐるぐる回ってみて、ちょっとjestでは層が深くて追いきれなくなってきたのでJasmine2でくみなおしました。もちろんいきなり結論には辿りつけずにいろいろ入れたり外したりしましたよ。。。以下は、シンプル化したpackage.jsonです。Reactテストには、gulp-jasmineとreact-addons-test-utilsとjsdomが関わります。Expressテストの方ではsupertest。ビルド及びテストの前処理は、gulp-babelとbabel-preset-*。

{
  "name": "sample",
  "version": "0.0.1",
  "private": true,
  "dependencies": {
    "express": "^4.13.4",
    "react": "^0.14.7",
    "react-dom": "^0.14.7"
  },
  "devDependencies": {
    "babel-preset-es2015": "^6.5.0",
    "babel-preset-react": "^6.5.0",
    "gulp": "^3.9.1",
    "gulp-babel": "^6.1.2",
    "gulp-jasmine": "^2.2.1",
    "jsdom": "^8.0.2",
    "react-addons-test-utils": "^0.14.7",
    "supertest": "^1.2.0"
  },
  "babel": {
    "presets": [
      "es2015",
      "react"
    ]
  },
  "scripts": {
    "build": "gulp build",
    "start": "cd dest; node app.js",
    "test": "gulp test"
  }
}

gulpでトランスパイルもしくはテスト実行という流れで、今後、もうチョイと便利に改良していく予定。gulpfile.jsだけはES5で書いてます。

// gulpfile.js
'use strict';
var gulp = require('gulp');
var babel = require('gulp-babel');
var jasmine = require('gulp-jasmine');
var reporter = require('jasmine-terminal-reporter');
var jsdom = require('jsdom').jsdom;
require('babel-register'); // Jasmineで事前トランスパイルなしに動かすために必要

gulp.task('build', function() {
    gulp.src(['src/**/*.js', '!src/**/__tests__/*.js']) //テストは出力しない
        .pipe(babel())
        .pipe(gulp.dest('dest'));
});

gulp.task('test', function () {
    global.document = jsdom(''); // 空白文字列でOK。好きなHTMLを書いても支障なし。
    global.window = global.document.defaultView; // defaultViewにするのは最近の仕様変更。
    for (var key in global.window) {
        if (global.window.hasOwnProperty(key) && !global[key]) {
            global[key] = global.window[key]; //念のため上書きなしで参照をコピーしておく
        }
    }
    gulp.src('src/**/__tests__/*.js')  //テストの配置方法は直前まで試していたjestの名残りです。
        .pipe(jasmine({reporter: new reporter()}));
});

トランスパイルの方はいいとして、テストの方について。Reactのテストはやり方によってはDOMが要らないやり方もあるみたいですが、jestを通じて知ったTestUtilsのrenderIntoDocumentを使う直感的なビヘイビアテストをするために、JsDOMでヘッドレスなDOM環境を作ります。その際、テストのルートたるgulpタスクで作っちゃった。コード中で「typeof window === 'undefined'」とサーバ環境判定していたりするとダメですが、これだけは自分で書かないと決めりゃいいかな。jestはざっくりとその辺はうまくやってるっぽい(jest/JSDOMEnvironment.js at master · facebook/jest · GitHub と jest/NodeEnvironment.js at master · facebook/jest · GitHub)。

ちなみに、React Test Utilsのドキュメントの該当箇所「NOTE」を読んでも全くわからなかったため相当費やしましたよ。。。解決のためにReactだけでなくjestやJsDOMの見当違いなソース箇所ばかり読んでた(Test Utilities | React)。

node環境のグローバルスコープの名前であるglobalのプロパティとしてdocumentを作り、windowを付け足します。その後にfor...inループではwindowからglobalへ上書きしないようにしながら参照をコピーしました。本当はnavigatorだけ手当てすれば良いみたいですけど念のためです。次いでgulp-jasmineでsrcフォルダのテストを一切実行します。jestの名残でテストのフォルダは各所の__tests__フォルダとなってます。

buildタスク、testタスクともにBabelのプリセット設定はpackage.jsonを使ってくれてますのでES6とJSXどちらも対応しています。

'use strict';
import React from 'react';

class CheckboxWithLabel extends React.Component {
    constructor(props) {
        super(props);
        this.state = {isChecked: false};
    }

    handleChange() {
        this.setState({isChecked: !this.state.isChecked});
    }

    render() {
        const sender = this;
        return (
            <label>
                <input
                    type="checkbox"
                    checked={this.state.isChecked}
                    onChange={() => sender.handleChange()}
                />
                {this.state.isChecked ? this.props.labelOn : this.props.labelOff}
            </label>
        );
    }
}

CheckboxWithLabel.propTypes = {
    labelOn: React.PropTypes.string.isRequired,
    labelOff: React.PropTypes.string.isRequired
};

export default CheckboxWithLabel;

上記のテスト対象はjestでのチュートリアルのものです。ESLintに怒られたのでちょっと整えました。

'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-addons-test-utils';
import Express from 'express';
import SuperTest from 'supertest';
import CheckboxWithLabel from '../CheckboxWithLabel';
import {ResponsiveCol} from '../BootstrapResponsive';
import WorkableRouter from '../WorkableRouter';

describe('React Components and Express' , () => {
    it('changes the text after click', () => {
        var checkbox = TestUtils.renderIntoDocument(
            <CheckboxWithLabel labelOn="On" labelOff="Off" />
        );
        var checkboxNode = ReactDOM.findDOMNode(checkbox);
        expect(checkboxNode.textContent).toEqual('Off');
        TestUtils.Simulate.change(
            TestUtils.findRenderedDOMComponentWithTag(checkbox, 'input')
        );
        expect(checkboxNode.textContent).toEqual('On');
    });

    // 下はReactステートレスコンポーネントのために動かない。
    it('hidden setting', () => {
        const col = TestUtils.renderIntoDocument(
            <ResponsiveCol xs={{hidden: true}} sm={{hidden: true}} md={{hidden: false}}>
                <div></div>
            </ResponsiveCol>
        );
        const colNode = ReactDOM.findDOMNode(col);
        expect(colNode.getAttribute('class').trim()).toEqual('hidden-xs hidden-sm');
    });

    it('get jobs', (done) => {
        const app = Express();
        app.use(WorkableRouter);
        SuperTest(app)
            .get('/jobs')
            .expect(200, done);
    });

});

もちろんテストもjestから。これと2/10の記事に書いたReactステートレスコンポーネントのテストも並べておきます。さらにSuperTestでルータの非同期テストも。

F..

events.js:154
      throw er; // Unhandled 'error' event
      ^
Failures: 
Error: Tests failed
1) React Components and Express hidden setting
1.1) TypeError: Cannot read property 'getAttribute' of null

3 specs, 1 failure
Finished in 0.4 seconds

結果は、ES6-classのReactコンポーネントはOK、SuperTestはOK。でもReactステートレスコンポーネントはNG。TestUtils.renderIntoDocumentでnullを返してます。しばしトランスパイルされた結果を眺めていて気がつきました。そこで下記の工夫。

    it('hidden setting', () => {
        const Component = ResponsiveCol({xs: {hidden: true}, sm: {hidden: true}, md: {hidden: false}, children: ''});
        const col = TestUtils.renderIntoDocument(Component);
        const colNode = ReactDOM.findDOMNode(col);
        expect(colNode.getAttribute('class').trim()).toEqual('hidden-xs hidden-sm');
    });

3種とも通りました!ステートレスコンポーネントはこう書けばいいのね。ステートレスコンポーネントは引数にプロパティを渡す単なる関数で、戻りはReact ElementになるのでそのままrenderIntoDocumentに渡してあげればよかった!まあrenderIntoDocumentの実装が追いついていないってことで確定だと思います。

...

3 specs, 0 failures
Finished in 0.3 seconds

そろそろgulpでちゃんとしたワークフローを組むべきかな。gulpのgithubにレシピ集があった( gulp/docs/recipes at master · gulpjs/gulp · GitHub)。いろいろ参考になる。