デザイン仕様書をCIで無理やり出力してた話

こんにちは、iOSアプリを開発してるcrexistaです。
GUIアプリの開発されているみなさん、デザイン仕様書をどのように管理してますでしょうか？
[Zeplin](https://zeplin.io)のような有料サービスとかですかね？
今回は大人の事情でそのようなサービスが使えなかった際に無理やりSketch Measureを使って仕様書をCIで出力するようにした話を書きます。

# Sketch Measure とは？
最近のアプリのUIデザインの現場では Sketch というものがよく使われております。
コマンドライン向けのAPIも用意されてたりなど plugin 開発もできる高機能なツールです。

[Sketch Measure](https://github.com/utom/sketch-measure) はそのSketchのプラグインで
こんなイカした感じの仕様書を出力してくれます。
![仕様書サンプル](https://camo.githubusercontent.com/87118bae9cca2b18441601d87ca8d6682984afa7/687474703a2f2f75746f6d2e64657369676e2f6578706f72744032782e706e67)

ただし、出力中はSketchの操作ができなくなるため、プロジェクトが進みartboardの量が多くなってくると
仕様書出力のために作業が止まってしまうという問題が出てきました。
というわけでCIで仕様書出力したくなったわけですが
Sketch Measure自体にはコマンドライン向けAPIがない(GUIからしか操作できない)のです。

そこでJXAをつかってCIから無理やり動かすということをやってみました。

# JXA on Jenkins
## JXAとは？
GUIの操作をJavascirptでスクリプティングするmacOSの機能です。
AppleScriptでできてましたがYosemite以降Javascriptでも可能になりました

詳しくはこちら https://developer.apple.com/videos/play/wwdc2014/306/

## 下準備
1. Sketch Measure のインストール
当たり前ですが、GUIを操作するわけですがSketchtoolだけでなくSketch本体とそれにインストールする[Sketch Measure](https://github.com/utom/sketch-measure)が必要になります。
プラグインのインストールについてはここでは本題からずれるため本記事では書きません。

2. SketchのUIの要素名調査
いざ操作しようと思ってもGUI要素が分からなければ操作ができません。
なのでまず要素をdumpします。
terminalから対話モードで起動できるので
対話モードで起動し、dump用のコマンドを打ちます。

```shell
$ osascript -l JavaScript -i 
  >> Application("System Events").processes["Sketch"].entireContents()
```
すると

```Javascript
Application("System Events").applicationProcesses.byName("Sketch").menuBars.at(0).menuBarItems.byName("Help"),
Application("System Events").applicationProcesses.byName("Sketch").menuBars.at(0).menuBarItems.byName("Help").menus.byName("Help"),
Application("System Events").applicationProcesses.byName("Sketch").menuBars.at(0).menuBarItems.byName("Help").menus.byName("Help").menuItems.at(0), 
Application("System Events").applicationProcesses.byName("Sketch").menuBars.at(0).menuBarItems.byName("Help").menus.byName("Help").menuItems.at(0).staticTexts.byName("Search"), 
....
```
てな感じで要素が大量に出力されます。
ここから適当に必要なものを探し出しましょう。

## JXAの記述
### 実際の操作をシミュレートして行く

**1. Sketch Measureの起動**

Sketchのメニュー項目にあるPluginsからSketch Measureをクリックし
仕様書出力設定画面を開かせます

```Javascript
  var process = Application("System Events").processes["Sketch"]
  process
    .menuBars[0].menuBarItems["Plugins"]
    .menus[0].menuItems["Sketch Measure"]
    .menus[0].menuItems["Spec Export"]
    .click();
```

**2. 仕様書出力設定画面の操作**
Sketch Measureはのどのスライスを仕様書として出力するか設定する必要があります。
以下のスクリプトで全てのスライスを仕様書として出力するようにしてたので全部のボックスにチェックを入れます

```Javascript
  var sketchMeasure = process.windows.at(0).scrollAreas.at(0).uiElements.at(0);
  var count = sketchMeasure.groups.length;
	systemEvent.keystroke('\t');
	sketchMeasure.groups.at(count - 5).groups.at(0).groups.at(0).click();
	sketchMeasure.groups.at(count - 1).buttons.byName("Export").click();
```

**3. 仕様書の保存**

保存ダイアログを操作し保存します

```Javascript
        // 英数字入力に変換してる
	systemEvent.keyCode(102);
	// 保存先ディレクトリ名入力
	systemEvent.keystroke("DesignSpecs");
	// Enterキー入力
	systemEvent.keyCode(76);
```

以上、上のスクリプトを順繰りに実行して行くと以下のgifのようにSketchが自動操作され仕様書が保存されます
![qiita.gif](https://qiita-image-store.s3.amazonaws.com/0/14567/482ed27f-74c5-54b4-42f1-9adc59fafd57.gif)

### CIから使えるようにする
terminalからJXAを呼ぶ場合
記述形式は決まっており以下のような

```Javascript
function run(argument) {...}
```
`run` というメソッド内に処理を書いて行き、コマンドライン引数は `argument`  に渡されて行きます。
というわけで書いたのが下のこちら


```Javascript:spec.js

/**
 * Sketch Measureを起動させます(Dialogを出します
 */
function invokeSketchMeasureOnProcess(process) {
  process.menuBars[0].menuBarItems["Plugins"]
         .menus[0].menuItems["Sketch Measure"]
		 .menus[0].menuItems["Spec Export"]
		.click();
	delay(1);
	return process.windows.at(0).scrollAreas.at(0).uiElements.at(0);
}

/**
 * Sketch Measureを使って仕様書の出力を行います
 */
function exportSpecBySkechMeasure(sketchMeasure, tmpPath, dirName, systemEvent) {
	var count = sketchMeasure.groups.length;
	systemEvent.keystroke('\t');
	delay(3);
	sketchMeasure.groups.at(count - 5).groups.at(0).groups.at(0).click();
	delay(2);
	sketchMeasure.groups.at(count - 1).buttons.byName("Export").click();
	return setupSpecSaveDirectory(tmpPath, dirName, systemEvent);
}

/**
 * 仕様書を指定のディレクトリに保存します
 *
 * @return 保存先のディレクトリ名を返します
 */
function setupSpecSaveDirectory(path, dirName, systemEvent) {
	var name = getFileNameByDirName(dirName);
	// 英数字入力に変換してる
	systemEvent.keyCode(102);
	delay(2);
	systemEvent.keystroke(path);
	delay(3);
	systemEvent.keyCode(76);
	delay(3);
	systemEvent.keystroke(name);
	delay(3);
	systemEvent.keyCode(76);
	delay(3);
	return path + "/" + name;
}

function getFileNameByDirName(dirName) {
	var paths = dirName.split("/");
	var lastPath = paths[paths.length - 1];
	var names = lastPath.split(".");
	return names[0];
}

function isFileExists(filePath) {
	return $.NSFileManager.defaultManager.fileExistsAtPath($(filePath).stringByStandardizingPath);
}

/**
 * Sketch Measureの仕様書出力の時間がかかる & 平行して仕様書出力ができないため
 * 出力予定ファイルの存在を1分毎に確認して無事仕様書が吐き出されているかをチェックします
 *
 * timeOutを過ぎても仕様書が出力されていない場合エラーを返します
 */
function checkOutputComplete(timeOut, filePath) {
	var i = 0;
	while(true) {
		i++;
		if(timeOut < i)	{
			throw new Error("仕様書出力が指定時間以内に終わりませんでした\n");
		}

		var d = $(filePath + " の存在を確認します\n").dataUsingEncoding($.NSUTF8StringEncoding);
		$.NSFileHandle.fileHandleWithStandardOutput.writeData(d);
		if(isFileExists(filePath)) break;
		var d = $(filePath + " はまだありませんでした。1分待ちます\n").dataUsingEncoding($.NSUTF8StringEncoding);
		$.NSFileHandle.fileHandleWithStandardOutput.writeData(d);
		delay(60);
	}
	var d = $("仕様書が出力されました: " + filePath + "\n").dataUsingEncoding($.NSUTF8StringEncoding);
	$.NSFileHandle.fileHandleWithStandardOutput.writeData(d);
}

function validateArgument(arguments) {
	var fileIndex = arguments.indexOf("--file");
    var outputIndex = arguments.indexOf("--output");
	var timeOutIndex = arguments.indexOf("--timeout");

	if (fileIndex == -1) {
		throw new Error("--file は必須パラメータです, 仕様書出力したいSketchファイルを指定してください");
	}
	if (outputIndex == -1) {
		throw new Error("--output は必須パラメータです, 仕様書出力先のディレクトリを指定してください");
	}

	var timeOut = 10;
	var file = arguments[fileIndex + 1];
	var output = arguments[outputIndex + 1];

	if (timeOutIndex != -1) {
		timeOut = parseInt(arguments[timeOutIndex + 1]);
		if(isNaN(timeOut)) throw new Error("--timeout には数値を指定してください");
	}
	return {
		timeOut: timeOut,
		output: output,
		file: file
	};

}

function run(arguments) {
	var args = validateArgument(arguments);
	var app = Application.currentApplication();
	var sketchApp = Application("Sketch");
	var systemEvent = Application("System Events");

	app.includeStandardAdditions = true;
	// 指定のファイルを開く
	app.doShellScript(`open ${args.file}`);
	// 開いてすぐSketch Measureを動かそうとすると失敗するのでとりあえず10秒まつ(なお、10秒は勘
	delay(10);
	// Skechに入っているSketchMeasureを起動する
	var sketchMeasure = invokeSketchMeasureOnProcess(systemEvent.processes[sketchApp.name()]);
	sketchApp.activate();
	// 出力を開始
	var fileName = exportSpecBySkechMeasure(sketchMeasure, args.output, args.file, systemEvent);
	// 出力を開始できたらwidowを閉じる
	sketchApp.windows.at(0).close({saving: false});
	try {
		// 仕様書出力には時間がかかるので指定した場所に出力されてるか毎分チェックする
		checkOutputComplete(args.timeOut, fileName + "/" + "index.html");
	} catch(error) {
		sketchApp.quit({saving: false});
		throw error;
	}
}
```
上のスクリプトをterminalから

```sh
$ osascript -l JavaScript spec.js --file  #{sketchのファイル} --output #{ディレクトリパス} --timeout #{タイムアウト時間}
```
と実行すると仕様書が出力されます。

# 問題点
上のシェルをCIから実行し、問題点がなかった訳ではないです

1. モニタ繋がないと動かない
GUIを操作するものなのでモニタがないと動きません。
そしてモニタを繋いだとしても画面がスリープモード入ってしまうと動きません。
なので、CIなのにモニタつけっぱなしという謎運用に。

2. よくわからないwaitを入れる必要がある
スクリプトを見てもらえばわかりますがところどころ `delay` があります。
画面操作なのでno waitで処理を走らせると操作対象のUIの表示が完了しておらず
失敗してしまうんですよね。
試行錯誤の結果、上のスクリプトのようなwaitになりましたがCIマシンの性能によって変わってくるでしょうし
あまりいいものではないですね。

3. Sketchアップデート
SketchのアップデートでUIが変わる可能性がある、という意味ではなく（それももちろんありますが)
Software Update を確認する画面が出てUI操作を邪魔しちゃうことがありました。
問題はそのUpdateが出るタイミングが掴めないためハンドリングができないということです。

# 終わりに
とまぁ問題点について述べましたが、運用できないほどかと言われるとそんなことはなく、
プルリクビルダーにフックすることにより
デザイナさんが更新されたSketchファイルをgithubにpushするたびに仕様書が出力されまぁまぁ便利には運用されてました。

ただし、問題点の3つめ、Sketchのアップデート確認画面が出て止まるというのか月一程度で起きてしまうのは
如何ともしがたいというのと
社内的な調整も進みついに先月とうとう [Zeplin](https://zeplin.io) に移行しました。
有料サービスということもありとても使いやすい良いサービスです。
当たり前ですが、基本的にはそちらを使う方をおすすめします。

ただ、何らかの大人の事情で使えないという場合このJXAを使う方法を取ってみるのもなしではないかなと思います。