astamuse Lab

astamuse Labとは、アスタミューゼのエンジニアとデザイナーのブログです。アスタミューゼの事業・サービスを支えている知識と舞台裏の今を発信しています。

scrollIntoView が便利だった話

f:id:astamuse:20200601151445j:plain

こんにちは、フロントエンドエンジニアの minamo です。

東京都でも緊急事態宣言が解除され、営業再開する施設が増えてきましたね。

今週はなんと2ヶ月ぶりに映画館に行きました(多い時は週3回は行っていたので、かなり久しぶり)!

公開延期作品が多い代わりに昔の名作が上映される映画館も多いので、2ヶ月通えなかったぶん、どんどん映画を見まくろうと思います。

2度目のブログはちょっとした内容ですが、 JavaScript で便利なメソッドがあったのでご紹介したいと思います。

scrollIntoView() メソッド

scrollIntoView() とは、対象の要素が ブラウザの表示範囲に入るまで スクロールする、というメソッドです。

developer.mozilla.org

事例紹介

これは私が scrollIntoView() メソッドを知るきっかけだったのですが、モーダルウィンドウの中でスクロール移動したい!というときに便利でした。

f:id:astamuse:20200601151456p:plain
元のページはスクロールしないよう固定しています(字が汚くてすみません)

最初は安直に scrollTo() で移動すればいいや〜と思ってたんですが、モーダルウィンドウ表示のように overflow-y: scroll; している中ではこの方法は使えなかったんですね。

f:id:astamuse:20200601151501p:plain
ウィンドウのスクロール位置は動かないわけですから、当然です。

そこで scrollIntoView() の出番です。

const element = document.querySelector(‘.title’); // 移動させたい要素を指定
element.scrollIntoView();

これで指定した要素が見える位置までスクロール移動してくれます!

オプション(引数)

alignToTop

デフォルトの true では表示させたい要素の上辺がブラウザの上辺に合わせて表示されますが

false を指定すると、表示させたい要素の下辺がブラウザの下辺に合わせて表示されます。

f:id:astamuse:20200601151507p:plain

element.scrollIntoView(false);

scrollIntoViewOptions

behavior: 'smooth' でスムーススクロールができます。

element.scrollIntoView({  
    behavior: 'smooth'  
});

オプションでやってくれるなんて気がきいてますね。

注意事項

一部ブラウザ (IE, Safari) では smooth オプションが有効にならないそうです。

caniuse.com

astamuse ではいつでもエンジニア&デザイナーを募集しています。

もちろんリモートでの面接にも対応していますので、お話だけでもお気軽にどうぞ!

BackstopJSではじめるビジュアルリグレッションテスト

f:id:astamuse:20170829184049p:plain こんにちは。デザイン部でフロントエンドエンジニアをしているkitoです。
今回は、BackstopJSを使ったビジュアルリグレッションテストについて書きたいと思います。
ビジュアルリグレッションテストとは視覚的な回帰テストのことで、具体的にはスクリーンショットを撮影して差分抽出して行うテストです。
近年のWebフロントエンド開発では、SassやWebpackのような開発環境が整うに従ってスタイルシートをモジュール化することが増えています。 それはスタイルの汎用性を高めることに大きく貢献していますが、一方で、あるパーツのスタイル修正が想定外の場所で悪影響を及ぼしてしまう可能性をもつようになりました。 この問題に対処するために、Enduring CSSのような新しいタイプの設計手法も考えられてはいますが、既存のサービスに導入するにはかなり敷居が高いでしょう。
そこで注目したいのが、ビジュアルリグレッションテストです。自動テストでデザインの異常を検知し、モジュール化して見通しが悪くなったスタイルを管理可能にします。

BackstopJSの導入

それではビジュアルリグレッションテストツールであるBackstopJSを導入していきましょう。 手元の環境は以下です。

node -v
v8.1.3
python --version
Python 2.7.11

Pythonは3系だとbackstopjsのインストールに失敗する場合があります。

mkdir example_backstopjs
cd example_backstopjs
npm init
npm install --save-dev backstopjs

これでインストールできました。 次にbackstop.jsの設定ファイルを作成します。

backstop init

backstop.jsonとbackstop_dataというフォルダが作成されたと思います。 backstop.jsonはbackstop.jsの設定を記述します。こちらにテストサイトのurlなど様々なオプションを追加しましょう。
今回は以下のように設定します。

{
    "id": "test",
    "viewports": [{
            "label": "phone",
            "width": 320,
            "height": 480
        },
        {
            "label": "pc",
            "width": 1024,
            "height": 768
        }
    ],
    "scenarios": [{
        "label": "BackstopJS testing",
        "url": "http://localhost:5000/",
        "hideSelectors": ["iframe"],
        "removeSelectors": [],
        "selectorExpansion": true,
        "selectors": [],
        "readyEvent": null,
        "delay": 0,
        "misMatchThreshold": 0.1,
        "requireSameDimensions": true,
        "onBeforeScript": "onBefore.js",
        "onReadyScript": "onReady.js"
    }],
    "paths": {
        "bitmaps_reference": "backstop_data/bitmaps_reference",
        "bitmaps_test": "backstop_data/bitmaps_test",
        "engine_scripts": "backstop_data/engine_scripts",
        "html_report": "backstop_data/html_report",
        "ci_report": "backstop_data/ci_report"
    },
    "engineFlags": [],
    "engine": "phantomjs",
    "report": ["browser"],
    "debug": true,
    "debugWindow": true
}

viewportsを指定できるので、レスポンシブサイトなどはこちらでPCとスマホサイト両方の画面サイズを設定できます。ユーザーエージェントでPCとスマホサイトを出し分けている場合、別の設定が必要になります。(後で説明します。)  
scenariosでは、更に細かい設定ができます。labelはスクリーンショットの名前、urlはテストするサイトのurl、hideSelectorsとremoveSelectorは、それぞれ指定セレクターを非表示にするオプションですが、hideSelectorsはvisibility: hiddenになり、removeSelectorsはdisplay: noneになる違いがあります。
selectorsは、スクリーンショットを撮影するパーツを指定します。ここではbody以下すべてを撮影する設定になっています。もちろん、"#main"のように一部パーツのスクリーンショットを撮影することができます。
readyEventは指定の文字列がconsole.logに表示するまで処理を待ちます。SPAに使えそうです。
readySelectorは、指定セレクターの表示を待ってから処理に移るオプションで、遅延ロードされるコンテンツがあるサイトで使えるでしょう。delayはミリ秒単位で処理を遅延させます。misMatchThresholdは、テストがfailになる閾値を設定できます。 onBeforeScriptは、テストサイトにcookieのようなブラウザの設定を反映するJavaScriptを記述できます。上記のサンプルでは./backstop_data/engine_scripts/onBefore.jsにファイルを読み込みます。onReadyScriptは、スクリーンショットを撮影する前にUIの状態を変化させたり、ユーザーエージェントの設定するJavaScriptを記述できます。今回は、./backstop_data/engine_scripts/onReady.jsに、下記のようにユーザーエージェントを変更するコードを書いてください。

module.exports = function(casper, scenario, vp) {
    if (vp.label === 'phone') {
        casper.userAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13F69 Safari/601.1');
    } else {
        casper.userAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.101 Safari/537.36 ');
    }
    casper.thenOpen(scenario.url);
}

casperというのは、Casper.jsというE2Eテストライブラリーのことです。ここではCasper.jsを使って、ユーザーエージェントを設定しています。Casper.jsはBacksotp.jsが依存しているライブラリーなので、npm installした際に一緒にインストールされているはずです。

ビジュアルリグレッションテスト

では簡易的なローカルサーバーを立ててテストしてみましょう。 node.jsのserveモジュールをインストールします。さらにbootstrapも入れましょう。

npm install -g serve
npm install --save-dev bootstrap

index.htmlを下記のように作成します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Backsotp.js testing</title>
    <link href="./node_modules/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div class="container">
        <div class="jumbotron">
            <h1>example</h1>
            <p>
                <a class="btn btn-lg btn-primary" href="#" role="button">button</a>
            </p>
        </div>
    </div>
    <script src="./node_modules/bootstrap/dist/js/bootstrap.min.js"></script>
</body>
</html>

ターミナルで別タブを開いて、example_backstopjsのディレクトリまで移動します。「serve」で簡易サーバーが立ち上がるのでhttp://localhost:5000/にアクセスしてみましょう。

serve

下のように表示されていると思います。 f:id:astamuse:20170829183825p:plain

準備が整ったので実際にテストしてみましょう。ターミナルで下記コマンドを入力してください。 すると./backstop_data/bitmaps_reference/以下に画像が2枚撮影されていると思います。 これがテストの元になるreference画像になります。

./node_modules/.bin/backstop reference

次に下記を実行してください。

./node_modules/.bin/backstop test

ブラウザーが立ち上がって下記のようなhtmlが表示されるます。これはbackstop.jsonのreportに"browser"を指定しているからです。 f:id:astamuse:20170829183835p:plain 全く何も変更していないので、テストは成功していると思います。 そして次にに、index.htmlのテキスト「button」を「button2」などに変更してわざと失敗させてみましょう。 failになり下記のようなスクリーンショットが表示されて、差分が着色されて表示されます。

f:id:astamuse:20170829183839p:plain

html上のshow statsのチェックを入れると、右側に下記のようなREPORTが表示されます。  misMatchPercentageの値をみると、0.39%ミスマッチであることがわかります。

Report: {
  "isSameDimensions": true,
  "dimensionDifference": {
    "width": 0,
    "height": 0
  },
  "misMatchPercentage": "0.39",
  "analysisTime": 49,
  "getDiffImage": null
}
Threshold: 1

engineをphantomjsではなくて、chromeに変更することも可能です。 実際のサービスサイトに使うにはmisMatchThresholdの値や除外セレクターの設定を細かく検討する必要があるでしょう。

アスタミューゼでは、エンジニア・デザイナーを募集中です。ご興味のある方は遠慮なく採用サイトからご応募ください。お待ちしています。

TypescriptでJavascriptの這い寄る混沌からなんとか抜け出した話

f:id:astamuse:20170823011437j:plain

お久しぶりでございます。scalaでバックエンドを開発しているaxtstar(@axtstart)でございます。

前回はマクロって言ったり、関数って言ったり、Functionだったりする何か。~Google Spread Sheet~ の話をしましたが、今回は、Typescriptを使って、Javascriptで書かれたテストコードを効率化した話を書いてみようと思います。

TL;DR

Typescriptとデザインパターンの「テンプレート・メソッド・パターン」が分かる方はこの記事は読まずに、
「エンジニア・デザイナーの採用情報」のバナーからぜひ弊社にご応募ください。

テストは重要

プロダクションをデプロイする前に、テストを行うのが当然なのはご存知の通りですが、 私の担当してるプロジェクトでは、導通確認やトランザクションチェックにヘッドレスブラウザ*1を用いた試験を導入しています。

デプロイした後のプロダクションに対してもテストを実行しシステムの健全動作を定期的に確認しています。

↓こんなテスト↓

STEP1 → STEP2 → STEP3 → STEP4

STEPは各ページに相当

→は画面遷移

PhantomJS

担当プロジェクトでは経緯があって、PhantomJSを上記テストに利用しています。

PhantomJSは、

  • Javascript(ES5)互換であること
  • ヘッドレスブラウザ(Qt web kitを使用)であること
  • 画面キャプチャ機能があること
  • NodeJS上で動くわけではないこと*2
  • 残念ながら主要メンテナーの方が最近ステップダウンされた*3

などの特徴があり、よく知られたヘッドレステストツールです。

問題点

しかしながら、Javascriptで記述していたためというわけでは無いのですが、 以下の問題を抱えている状態でした。

  • 可読性が低い(あくまで処理の順番通りにコードが書かれているだけ)
  • 構造化されていない(処理が最初から終わりまで1メソッドで書かれていたりする)
  • 似て非なるテストが多くあり、メンテナンスに時間を要する
  • あるテストではAssertがあるのに他のものではなかったりする(テスト品質にバラツキがある)

這い寄る混沌

そんな中、テスト全てに影響のある修正が行われることになりました。

修正そのものは(それほど)難しくは無いのですが、それが動作する動線が多く、このためのテスト対応を、 現状のソースに対して行うのはSAN値が下がる大変だと直感しました。

↓ちなみにこのような修正↓

STEP1 → STEP2 → STEP3 → STEP4

STEP1 → STEP1-2 → STEP2 → STEP3 → STEP4

そこで今回このコードを一気にモダン化しようと思い至りました。

AltJS導入

かつては、CoffeeScriptのソースが直接読み込めたPhantomJSですが、version 2以降はメンテナンスの関係からその対応は外されています。*4

ですので、モダン化を行うには、現状、AltJSと呼ばれる、対象の言語を、PhantomJSが動作するJavascriptにトランスパイルする言語・環境が必要になってきます。

こちらのエントリでBabel、ES6の話を書いているので初めはBabelによるES5へのトランスパイルを試そうかな*5と思っていました。

しかし、@typesの記事を目にして、Typecriptを採用してみることにしました。

※これ以前はtsdとかTypingsとかNodeJSの設定以外にも色々手を加える部分が必要なTypescriptはとっつきにくいなと感じていました。

修正前

さて、

「そのもの」のソースはお見せできないので、今回、大幅に簡略、概念化した、修正前のソースを用意しました。

Webページの遷移において、ユーザが好きな何かを選択して画面遷移するもの、としています。

※そうは言っても、あくまで今回のブログ用に作成したものなので、「そのもの」よりかなり可読性は高いです。

phantom.casperPath = './node_modules/casperjs';
phantom.injectJs('./node_modules/casperjs/bin/bootstrap.js');
phantom.casperTest = true;

var casper = require('casper').create();
var fs = require('fs');

var url = "http://sample.com/";
var wait_time= 300;
var title;

casper.test.begin('test start', function suite(test) {

    //UserAgent指定
    casper.userAgent('Mozilla/5.0 (Macintosh略');

    casper.start().viewport(1024, 768).thenOpen(url, function() {
        test.assertTitle("タイトル", "title is タイトル");
        title = this.getTitle();
    });

    casper.thenEvaluate(function() {
            document.querySelector('input[name="ログインID"]').value = 'ログインID';
            document.querySelector('input[name="パスワード"]').value = 'パスワード';
            document.querySelector('#clickable').click();
        }, '')
        // STEP 1
        .then(function() {
            this.test.assertTitleMatch(/STEP1/, "title contains STEP1");
            this.wait(wait_time, function() {
                this.fillSelectors('form', {
                    'select[name="好きな映画"]': 'マッドマックス 怒りのデス・ロード',
                    'select[name="好きな車"]': 'Peugeot 106'
                }, false);

                this.click('#clickable');//Next Page
            });
        })
        // STEP 2
        .then(function() {
            this.wait(wait_time, function() {
                this.test.assertTitleMatch(/STEP2/, "title contains STEP2");

                this.fillSelectors('form', {
                    'select[name="好きなチーム"]': '阪神タイガース',
                    'select[name="好きな選手"]': 'ジネディーヌ・ジダン'
                }, false);

                this.click('#clickable');//Next Page
            });
        })
        // STEP 3
        .then(function() {
            this.wait(wait_time, function() {
                this.test.assertTitleMatch(/STEP3/, "title contains STEP3");

                this.fillSelectors('form', {
                    'select[name="好きな国"]': 'フランス',
                    'select[name="好きな曲"]': '愛なんだ'
                }, false);

                this.click('#clickable');//Next Page
            });
        })
        // STEP 4
        .then(function() {
            this.wait(wait_time, function() {
                this.test.assertTitleMatch(/STEP4/, "title contains STEP4");

                this.fillSelectors('form', {
                    'select[name="好きな番組"]': '正解するカド',
                    'select[name="好きな本"]': 'The Three Body Problem'
                }, false);

                this.click('#clickable');//Next Page
            });
        });

  casper.run(function () {
    test.done();
  })
  .then(function(){
    this.exit();
  });

});

このようなJSファイルが数十個あるような状態でした。

このソース群一個一個個別に、同じ追加の処理を書いて行くのは、やはり遠い目になるのは間違いないので、 今見てもこの選択は良かったなと思っています。

目標

Typescript導入による目標を下記のごとく定めました。

  • 構造化による差分プログラムを可能にすること
  • Enum化によるインテリセンスの活用、値入力の効率化、ランダム取得対応

構造化

上記を基本として、必要に応じてクラスをオーバライドすることで、 各処理に対応するようにしました。

また管理したい単位でクラス設計を行いました。

  • People ユーザの属性を保持しておくクラス
  export class People{
    /** ログインID */
    public loginID:string
    /** パスワード */
    public passwd:string
    /** 好きな映画 */
    public movie:string = MovieEnum.fury
    /** 好きな車 */
    public car:string = CarEnum.peugeot106
    //以下略
  }

入力のものはstring、選択式のものはenumを使用しました(後述)

↓構造↓

基本構造として、URLを開いてログインするだけで終わる処理をBASEとして記述します。 そのフローの中で、何もしない処理を記述します

Start → Login → Step1(何もしない) → Step2(何もしない) → Step3(何もしない) → Step4(何もしない)

export module TestModule {
  export class Register {
      //開始URL
      public url = "http://sample.com"

      //人物設定
      public people:People = new OrdinalPeople()

      protected beginningString:string = "start"
      protected assertTitle:string
      protected sizeWidth:number = 1024
      protected sizeHeight = 768

       /**
        * スタート処理(は共通として扱う)
        */
       protected emitStart = () => {
          var url = `${this.url}`
          var assertTitle = this.assertTitle
          var sizeWidth = this.sizeWidth
          var sizeHeight = this.sizeHeight

          casper.start().viewport(sizeWidth, sizeHeight).thenOpen(url, function() {
              var title = this.getTitle()
              casper.test.assertTitle(assertTitle, "title is " + title)
          })
       }

       /**
        * ログイン処理(は共通として扱う)
        */
       protected emitLogin = () => {
          var loginID = this.people.loginID
          var passwd = this.people.passwd

          casper.thenEvaluate(function(loginID:any,passwd:any) {
                  (<HTMLInputElement>document.querySelector('input[name="ログインID"]')).value = loginID
                  (<HTMLInputElement>document.querySelector('input[name="パスワード"]')).value = passwd
                  (<HTMLButtonElement> document.querySelector("#clickable")).click()
              }, loginID, passwd)
       }

      /** STEP1処理 */
      protected emitStep1 = () => {}

      /** STEP2処理 */
      protected emitStep2 = () => {}

      /** STEP3処理 */
      protected emitStep3 = () => {}

      /** STEP4処理 */
      protected emitStep4 = () => {}

       /**
        * casperの実行
        */
       protected emitRun = () => {
          casper.run(function () {
              casper.test.done()
          })
          .then(function(){
              this.exit()
          })
       }

      /** 実際に呼び出す処理 */
      protected casperFunc = () => { 
        var emitStart = this.emitStart //スタート
        var emitLogin = this.emitLogin //ログイン
        var emitStep1 = this.emitStep1 //Step1入力を想定
        var emitStep2 = this.emitStep2 //Step2入力を想定
        var emitStep3 = this.emitStep3 //Step3入力を想定
        var emitStep4 = this.emitStep4 //Step4入力を想定

        var emitRun = this.emitRun //テストの実行

          casper.test.begin(this.beginningString, function suite(test:any) {
              emitStart();//スタート
              emitLogin();//ログイン
              emitStep1();//Step1入力を想定
              emitStep2();//Step2入力を想定
              emitStep3();//Step3入力を想定
              emitStep4();//Step4入力を想定

              emitRun();//テストの実行
          });
       }
  }
}

上記のクラスを継承し、Step1の処理を差分で書き換えるようにしました

Start → Login → Step1(映画、車) → Step2(何もしない) → Step3(何もしない) → Step4(何もしない)

export class MovieRegister extends Register {
    //人物設定
    people = new OrdinalPeople()

      /**
      * step1の設定JSON
      */
      getStep1 = (people:People) =>{
                      return {
                            'input[name="好きな映画"]':people.movie,
                            'input[name="好きな車"]': people.car
                        }
      }

      /**
      * step1入力時のcasper処理
      */
      emitStep1 = () => {
        var step1 = this.getStep1(this.people)
          //↑ここでクラスプロパティをvarに詰め替える
        casper.then(function() {
                this.fillSelectors('form', step1, false)
                this.click('#clickable');//Next Page
        })
    }
  }

Enum化

設定値をEnum化することで、テストコーディング時にインテリセンス(タブ補完、オートコンプリーション)による、効率的なコーディングを可能にしました。

export enum UserAgentEnum {
    /**
    * UserAgent指定PC
    */
    PC  = <any>'Mozilla/5.0 略',
    /**
    * UserAgent指定SP
    */
    SP = <any>"Mozilla /5.0 (iPhone;略"
}

f:id:astamuse:20170822221038p:plain

Stringのenum型が許容されないため、文字列の前に<any>を指定する必要がありました。

トランスパイルされたJavascriptは下記のようになっていました。

    /**
     * User Agnet指定
     */
    var UserAgentEnum;
    (function (UserAgentEnum) {
        /**
         * UserAgent指定PC
         */
        UserAgentEnum[UserAgentEnum["PC"] = 'Mozilla/5.0 略'] = "PC";
        /**
         * UserAgent指定SP
         */
        UserAgentEnum[UserAgentEnum["SP"] = "Mozilla /5.0 (iPhone;略"] = "SP";
    })(UserAgentEnum = TenCasper.UserAgentEnum || (TenCasper.UserAgentEnum = {}));

このため、例えばランダムで値を取得したい(ランダムでUserAgentを変更したい)場合サイズ取得時に2で割る必要がありました。

    export function getRandomEnum(o:any) {
        var len = Object.keys(o).length / 2// size of Object
        var x = Math.floor(Math.random() * len) // generate random value till the max size
        return <any>Object.keys(o)[x]
    }

このような形で選択式の部分をEnum化しTypescriptの特徴である、型システムを最大限利用できるようにしました。

また、Enum値のランダム取得を行うことで、網羅率を高める取り組みが比較的容易になったかなと思います。

朗報

Typescript2.4 から stringのenumがサポートされるようになりました。*6

export enum UserAgentEnum {
    /**
    * UserAgent指定PC
    */
    PC  = 'Mozilla/5.0 略',
    /**
        * UserAgent指定SP
        */
    SP = "Mozilla /5.0 (iPhone;略"
}

↓トランスパイル↓

/**
    * User Agnet指定
    */
var UserAgentEnum;
(function (UserAgentEnum) {
    /**
        * UserAgent指定PC
        */
    UserAgentEnum["PC"] = "Mozilla/5.0 \u7565";
    /**
        * UserAgent指定SP
        */
    UserAgentEnum["SP"] = "Mozilla /5.0 (iPhone;\u7565";
})(UserAgentEnum = TenCasper.UserAgentEnum || (TenCasper.UserAgentEnum = {}));

さきほどのランダムの取得もシンプルになりました。

export function getRandomStringEnum(an:any) {
    //String Enum may be just same size of definition?
    var len = Object.keys(an).length  // size of Object

    var x = Math.floor(Math.random() * len) // generate random value till the max size

    var key = <any>Object.keys(an)[x]
    target = an[key]

    return target
}

まとめ

Typescriptで プログラムができるようになり、以下のメリットがありました。

  • 型システムの恩恵により、ミスペルなどのケアレスミスの軽減
  • enumをランダムで回すテストによる、網羅率の向上
  • 動線でのキャプションの一貫性の無さ、css-selectorの命名の違いの検出
  • SAN値の低下を防ぐ

最後に

アスタミューゼでは現在、エンジニア・デザイナーを募集中です。 興味のある方はぜひ下記バナーからご応募ください。

*1:テスト用途のディスプレイを必要としないブラウザのことです

*2:そのため、NodeJSで使用可能なものが使えない、機能が違うなどがあります。またリモートデバッグ機能はありますが、ステップバイステップの実行はできないようです

*3:https://www.infoq.com/jp/news/2017/04/Phantomjs-future-uncertain

*4:https://github.com/ariya/phantomjs/issues/12410

*5:PhantomJSそのもののES6対応もversion 2.5で予定はされている模様です https://github.com/ariya/phantomjs/issues/14506

*6:https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-4.html#string-enums

Copyright © astamuse company, ltd. all rights reserved.