TeamSpirit Advent Calendar 2021 Day11:Visual Studio CodeのBracket Pair Colorizer拡張機能が、ネイティブ実装されました

この記事は チームスピリット Advent Calendar 2021 の11日目の記事です。

adventar.org

こんにちは、開発チームの里石です!

今年もアドベントカレンダーでは、vscodeネタを語らせていただこうかと思います。

もはや普通のエディタとして、一日中使用しているため、毎月の機能アップデートには興味津々です。

今回はその中でも、最近実装されたBracket Pair Colorizerについてのご紹介です。

拡張機能時代

vscodeでは、ソース内の「対」になるブラケット同士を認識しています。そのブラケット同士を同じ色でカラーハイライトし、視認性を大きく高めてくれる拡張機能がありました。

f:id:satoishi_ts:20211205174422p:plain

Bracket Pair Colorizer拡張機能

以降、「BPC1」と略記します。

690万回もインストールされており、恐らく、ほとんどのvscodeユーザがこの拡張機能をインストールしているのではないかと思います。

BPC1では、カラーハイライト以外に、ブラケットが示すブロックスコープにガイド線を引いたり、水平線を引いたりするなど、視認性をより向上してくれるキメ細かい機能が実装されていました。 特に水平線は、ちょうどif文の下に引かれるため、ブロックスコープに入る条件に目が行きやすく、些細ながらとても便利な機能でした。

拡張機能からvscodeネイティブ実装へ

そんなBPC1でしたが、作者の環境の変化により、開発が止まっていました。 特に、BPC1の後継だった「Bracket Pair Colorizer 2拡張機能(以降「BPC2」略記し、BPC1とBPC2を総称して「BPC」と略記)」は、十分な機能実装を前に止まってしまい、かつプチフリーズが発生するパフォーマンス問題が解消されないままとなっていました。

BPC2では、ブラケット認識アルゴリズムが刷新され、vscode本体に実装されている字句解析ライブラリ「vscode-textmate(以降、「textmate」と略記)」と同じライブラリを利用して、ブラケット認識を行っています。これが仇となって、textmateトークン情報の更新に時間がかかるようになり、パフォーマンス問題が発生するようになってしまったようです。なおBPC1では、Prism.jsを使ってブラケット認識を行っています。

こちらで、BPC1とBPC2のパフォーマンス問題解決法を議論するissueが起票されています。Issue #128465 | Investigate into the performance issues of the bracket pair colorizer extension(BPCのパフォーマンスの問題を調査します)

拡張機能向けAPIには、vscode本体のtextmateが生成するトークン情報へのアクセス手段が用意されていません。このため、拡張機能トークン情報を利用するには、vscode本体に実装されているの同じ「vscode-textmateライブラリ」を拡張機能側で読み込んで実装してトークン情報を生成し、さらにそこから自前でブロックスコープ情報を構築して、カラーハイライトするしかありませんでした(BPC2はこの手法を取っています)。

また、大きなファイルになると、textmateトークン情報が正確ではなくなるといった問題も発生していたようです。

そこで、この拡張機能の機能をvscodeに取り込んでしまおうというプロジェクトが始まりました。

f:id:satoishi_ts:20211205174419p:plain

まず、Issue #96899 | Include a bracket pair colorizing feature(BPC機能を取り込みます)チケットとPull Request #129231 | Performant Bracket Pair Colorization(BPC処理の効率化)プルリクエストで、vscodeのブラケット認識アルゴリズムの実装と、カラーハイライトができるようになりました。

これは大きなアップデートでしたが、ブロックスコープの水平線表示など、BPCにあったキメ細かい機能はまだ実装されておらず、それを望む声が多くありました。BPCの作者自身も望んでいました。

その後、Issue #131001 | Feature Request: Colored line scopes(色付きガイド線によるスコープ)チケットと、Pull Request #134259 | Implements horizontal guides for bracket guides(ブラケットの水平線の実装)プルリクエストで、いよいよ水平線も実装され、BPCの持つ機能の大部分が移植され、BPCから移行しても概ね問題のない状況が整いました。

vscodeネイティブのブロックスコープ構造解析アルゴリズムの改良

今回のBPC機能取り込みプロジェクトにより、不完全だったvscodeネイティブのブロックスコープ構造解析アルゴリズムにも改良が加えられました。詳しくは、下記の記事で解説してくれています。

How We Made Bracket Pair Colorization 10,000x Faster In Visual Studio Code

なお、こちらの記事でも言及されているように、vscode内のtextmateで生成したトークン情報へアクセスする拡張機能APIは、パフォーマンスの問題から提供されることは無さそうです。残念・・・。

How We Made Bracket Pair Colorization 10,000x Faster In Visual Studio Code | tokens-to-the-rescue

We thought long about how we could efficiently and reliably expose token information to extensions, but came to the conclusion that we cannot do this without a lot of implementation details leaking into the extension API. Because the extension still has to send over a list of color decorations for each bracket in the document, such an API alone would not even solve the performance problem.

トークン情報を拡張機能に効率的かつ確実に公開する方法について、長い間考えていましたが、実装の詳細が大量にリークでもしない限り、拡張機能APIに含めることはできないという結論に達しました。拡張機能は、ドキュメント内の各ブラケットに色付けのリストを送信する必要があるため、パフォーマンス問題が発生し、APIだけでは解決することができません。

また、今回のプロジェクトでvscode内部ではブロックスコープ構造情報を持つようになったわけですが、こちらもまだ拡張機能向けAPIは用意されておらず、アクセスすることはできません。しかしすでに、そのAPIの要望は上がっており、issueも起票されています。将来実装されることを期待しましょう。

実際に使ってみる

それでは、実際にvscodeネイティブBPCを使ってみます。vscodeのsetting.jsonを開き、下記の設定項目を追加します。

今回実装されたvscodeネイティブBPCを有効にします。(デフォルトがfalseなのでtrueにしてください)

//角かっこのペアの彩色を有効にするかどうかを制御します。角かっこの強調表示の色をオーバーライドするには、'workbench.colorCustomizations' を使用します。
"editor.bracketPairColorization.enabled": true,   

ブラケット間のブロックスコープに、水平線または垂直線のガイド線を表示するかどうかを制御できます。現在カーソルのあるブロックスコープだけ表示したいときは、activeにします。

// ブラケット ペアのガイドを有効にするかどうかを制御します。
// active: アクティブなブラケット ペアに対してのみブラケット ペア ガイドを有効にします。
"editor.guides.bracketPairs": "active",

ブラケット間のブロックスコープに、水平線を表示するかを制御します。これも、現在カーソルのあるブロックスコープだけ表示させることができます。

//水平方向のブラケット ペアのガイドを有効にするかどうかを制御します。
// active: アクティブなブラケット ペアに対してのみ、水平のガイドを有効にします。
"editor.guides.bracketPairsHorizontal": "active",

ブラケット間のブロックスコープの垂直線を表示するかどうかを制御できます。

//ブラケット ペアのガイドを有効にするかどうかを制御します。
"editor.guides.highlightActiveBracketPair": true,

ちなみに、こちらは名前は似ていますが、インデントのガイド線についての設定です。vscodeネイティブBPCを表示するときは、インデントの垂直ガイド線は上書きされます。

//エディターでインデント ガイドを表示するかどうかを制御します。
"editor.guides.indentation": true,

//エディターでアクティブなインデントのガイドを強調表示するかどうかを制御します。
"editor.guides.highlightActiveIndentation": true,

設定変更のデモを下記に示しますので、どのような効果が現れるか確認してみてください。

f:id:satoishi_ts:20211205175547g:plain

終わりに

役割をおえたBCP拡張機能は、今後非推奨となっていくことでしょう。しかし、vscodeのほぼ全ユーザに使われたであろう、なくてはならない素晴らしい拡張機能でした!vscode黎明期から、お疲れさまでした!

TeamSpirit Advent Calendar 2020 Day12:Visual Studio Codeに「検索ワード入力中のカーソル移動制御」機能がやってきた!ヤァ!ヤァ!ヤァ!

この記事は チームスピリット Advent Calendar 2020 の12日目の記事です。

adventar.org

こんにちは、SDチームの里石です!
去年のアドベントカレンダーに引き続き、今年もVSCodeネタで記事を書きたいと思います。

今年9月、VSCodeに「検索ワード入力中のカーソル移動制御」機能が追加されました。(find-cursor-move-on-type-control | Visual Studio Code August 2020)

VSCodeでは、Ctrl+Fで実行できるファイル内検索を「Find」、Ctrl+Shift+Fでサイドバーから実行できるgrep検索を「Search」と読んでいます。
Find機能は、検索ワード入力中にその検索結果へ自動でジャンプする、いわゆるインクリメンタルサーチとなっているのですが、これを自動でジャンプしないようにする設定がありませんでした。そのため、検索ワード入力中に勝手にカーソルがどんどん移動して、もと居た場所がわからなくなったり、弊害も多い動作でした。

バージョン1.49で、やっとこのカーソル移動を制御するオプションが追加されました。

  • 設定項目:"editor.find.cursorMoveOnType"
  • 設定値: true(defalut), false
    true:検索キーワード入力時、自動でカーソル移動する。false:しない

自分が待ち望んでいて、一時は自分で改造できないかソースを調べるまでしたことのあった機能だけに、どのような経緯でVSCodeへ実装されるに至ったのか、気になって調べてみました。

Issue起票と投票

発端は2019/11/21にultraGentle氏が起票した。下記のIssueでした。
Issue #85303 | Don't auto-scroll to first "Find" match(最初の「Find」一致に自動スクロールしないでください)

ここから遡ること1年前の2018/10/16、タイトルは異なりますがKeyC0de氏が似たような要望を出しており、
Issue #60977 | Disable Auto Cursor Reveal while typing in Find Widget(Findウィジェットの入力中に自動カーソル移動を無効にする)
また、dwelle氏も同様の要望を出していました。(2018/12/18)
Issue #65286 | Allow to disable incremental search(インクリメンタルサーチを無効にできるようにする)

MicrosoftのRebornix氏がIssue #85303を見つけました。彼は1年前から似たような要望に「feature-request(機能要望)」タグを付け、Issue #60977に集約し始めていた方でした。Rebornix氏がIssue #85303中でIssue #60977を紹介してみると、ultraGentle氏は自分の要望内容と同じであることを発見。Issue #60977へ合流しました。以後、Issue #60977でこの機能の議論は進むことになります。

Rebornix氏はその後、Issue #60977に「Backlog Candidates(バックログ候補)」マイルストーンを付与しました(2020/01/28)
するとvscodebot(ボット)がこのマイルストーンを自動検出し、投票を促しました。(60日中に20票が必要)

ultraGentole氏は、どうやら現在のFindウィジェットの動作が、他のIssueのバグの原因となっているらしいことを発見し、そのようなIssue(Issue #70306など)のコメントにIssue #60977へのリンクを張り、提案をして回りました。このおかげで多くの票が集まり、機能追加が決定しました。

2020/02/14に投票が終わり、vscodebotは「Backlog Candidates(バックログ候補)」マイルストーンを削除し「Backlog(バックログ)」マイルストーンへ付け替えました。また「help wanted(従業員募集)」のラベルを貼り付けました。(他のIssueを見ていると、これでこのIssueのプルリクエストがマージ候補としてきちんと検証・検討されるようになるようです)

プルリクエスト、insiders版への機能リリース

Rebornix氏とultraGentle氏が中心となり、設定名称などの議論が行われ、その後2020/04/10 にプルリクエストが開かれました。
PullRequest #94825 | Adds setting to disable cursor movement while typing into Find Widget(Findウィジェット入力中にカーソルの移動を無効にする設定を追加します)

プルリクエストはRebornix氏がまずレビューし、フォーマッタを指摘。ultraGentle氏が修正してコミットしたもののRebornix氏からは反応がなく、briankendall氏がセカンドレビュー者としてレビューし、いったん終えました。その後もRebornix氏がなんら反応することはなく、このプルリクエストはしばらく放置されることになりました。途中、6月ごろに他のユーザから「このプルリクエストは、いったいいつマージされるのか」といったコメントが付くも、ultraGentle氏も「これが保留中なのかリジェクトされたのかわからない」と回答しています。7月にしびれを切らしたultraGentle氏は「Rebornix氏へメンションを送るも、反応がなくイライラしている」とのコメントを残しています。

2020/08/11、Rebornix氏が少し手直ししたコミットを追加しプルリクエストをmasterへマージしました。どうやらRebornix氏は単純にこのIssueを忘れていたようです。Rebornix氏から、貢献への感謝と今週中(2020/08/10~16)にinsiders版で利用できるようになる旨のコメントが付きました。

なお、各コミット時やマージ時には自動テストが実行され、monacoプロジェクトのコードチェックやAzure Dev OpsのPipelinesでvscodeの自動テスト、MicrosoftのCLA要件チェック(「あなたのコードはMicrosoftからOSSとして人々に提供されます」などの同意書)をパスした後にマージされるようです。

同日、Issue #60977にはいったん「unreleased(未リリース)」のラベルがvscode-triage-bot(ボット)によって付与され、さらにRebornix氏が「Backlog(バックログ)」「August 2020」の2つのマイルストーンを付与します。その3日後の2020/08/14に、同じくvscode-triage-botによって「unreleased(未リリース)」ラベルが「insider-released(insiderリリース)」ラベルに張り替えられます。
(このタイミングでinsiders版のVSCodeへ、機能がリリースされたようです)

stable版への機能リリース

2020/09/08にRebornix氏は「verification-needed(確認が必要)」ラベルを付与し、mjbvz氏が「verified(確認済み)」を付与しました。 これによりinsiders版からstable版へリリースする準備が整ったことを示していると思われます。

2020/09/10、いよいよstable版VSCode 1.49 がリリース!ultraGentle氏が興奮気味に機能がリリースされた旨をコメントし、Issue #60977の起票者であるKeyC0de氏も「2年かかった…」とコメントを残しています。

まとめ

このような道のりを経て、VSCodeの機能は改善されていきます。

忘れられて機能のリリースが遅れたり、多くの人が困っているバグと関連付けられることで対応の機運が高まったりと、なんだか既視感のあるとてもアナログなプロセスで実装されていく様子が見て取れました。

また、botによるラベルの貼り替えや、プルリクエストのクローズとともにリリース対象のラベルが付与されるなど、自動化をふんだんに活用して、Issueやプルリクエストの管理が行われていることもわかりました。

何はなくとも、issueを書かないと始まりません。
欲しい機能があれば、是非Issueを挙げてみましょう。

TeamSpirit Advent Calendar Day13:Visual Studio Code拡張機能のテスト環境を構築する

この記事は チームスピリット Advent Calendar 2019 の13日目の記事です。

adventar.org

はじめに

先日のアドベントカレンダー記事では、自作のVisual Studio Code(以下VSCode)拡張機能をご紹介させていただきました。
その開発時に初めてTypeScriptを用いた拡張機能のテスト環境を構築したのですが、VSCodeの構成もよくわからないままに取り組み、随分苦労したので、今回はそれを解説してみたいと思います。

テストの実行方法の種類

公式には、テストの開発方法は下記にドキュメントがあります。
Testing Extension | Visual Studio Code Extension API

VSCode上で拡張機能をテストするには、コーディングに使用しているVSCodeとは別にVSCodeを立ち上げ、そのVSCode上で拡張機能を叩くテストプログラムを動かします。

この別のVSCodeのことを「インスタンス」と呼びます。

テストプログラム用のVSCodeインスタンスを立ち上げるには、実際にコマンドプロンプトなどからVSCodeを立ち上げる方法もありますが、他にもvscode-testモジュール(以下vscode-test)というnpmパッケージを読み込み、そこから子プロセスでVSCodeインスタンスを生成しテストを行うという方法もあり、今後主流になっていくそうです(下記のリストのうち2-1)。

ただ本稿では、参考にした拡張機能にならって、vscode-testを使わない方法 を解説します。

  1. vscode-testを使わない方法

    1. コマンドプロンプトで「code ~」のようにVSCodeの実行ファイルを指定して、VSCodeインスタンスを起動する。
      f:id:satoishi_ts:20191212020129p:plain
      もっともオーソドックスな方法です。
      ここでいちいちオプションを指定するのが面倒なので、次の1-2の方法があります。

    2. launch.jsonにパラメータを記入して、使用中のVSCodeインスタンスからGUIを操作し、テスト用のVSCodeインスタンスを起動する。
      f:id:satoishi_ts:20191212020235p:plain
      1-1でいちいちコマンドプロンプトで指定しているオプションを、launch.jsonに記述してVSCodeインスタンスを起動することが出来ます。
      (本稿で解説します)

  2. vscode-testを使う方法

    1. NodeのプログラムとしてTypeScriptを記述し実行する。(例:runTest.ts
      その中でvscode-testrunTests関数を呼び出す。
      (これでVSCodeインスタンスを子プロセスで起動することができます。このrunTest.tsのことを「Test Script」と呼びます。)
      f:id:satoishi_ts:20191213010422p:plain
      コマンドプロンプトで「node ./out/test/runTest.js」のように、Nodeプログラムとして指定することで起動できます。
      ただし、後述しますが色々と制限があります。(参照:runTest.jsでテストするときの注意)
      公式ドキュメントでもこの方法を主に解説しています。リモートデバッギングやMicrosoftが運営するAzureDevOps上でCIが回せるようになるのが理由のようです??
      Continuous Integration | Visual Studio Code Extension API

テスト環境の構築方法

繰り返しになりますが、本稿では launch.jsonにパラメータを記入して、使用中のVSCodeインスタンスからGUIを操作し、テスト用のVSCodeインスタンスを起動する テスト方法について解説します。
テストプログラムはTypeScriptで記述します。
特にJavaScriptであることを意識しない限りは「TypeScriptプログラム」と称して説明します。

必要なnpmパッケージ

テストを実行するには、開発環境のnode_modulesフォルダ内に、npmパッケージをインストールする必要があります。

npmパッケージ 概要
mocha テストフレームワークのパッケージ
istanbul カバレッジ測定に使用します。
remap-istanbul カバレッジとTypeScriptファイルとのマッピングに使用します。

他にも、BDD用のアサート記法を提供するchaiモジュールなどもありますが、今回は割愛します。(assertモジュールを使います。)

インストール方法

解説に使用する環境は、公式のドキュメントでも解説している、yeomanで生成するテンプレートを使用します。
Your First Extension | Visual Studio Code Extension API

上記サイトの手順に従って、VSCode拡張機能のテンプレートを生成します。
具体的には、下記の手順になります。

コマンドプロンプトで下記を実行。

yo

その後は対話形式で生成するテンプレートの内容を聞かれるので適当に入力します。

What would you like to do?→「code」を選択
What type of extension do you want to create?→「New Extension (TypeScript)」を選択
What's the name of your extension? →「sample」を入力
What's the identifier of your extension? (sample) →そのままEnter
What's the description of your extension?→そのままEnter
Initialize a git repository? (Y/n)→「Y」を入力
Which package manager to use?→「npm」を選択

生成直後は、下記のファイル構成になります。

f:id:satoishi_ts:20191212020229p:plain

テンプレート生成直後ではカバレッジ測定なども行えないため、以降の手順で調整していきます。
調整前の内容は、最後の「脚注(テンプレート生成直後の調整前のソース一覧)」に記載しています。
この作業は下記サイトを参考にしました。
VSCode extension code coverage · Roman Peshkov

extension.test.tsファイルの調整

拡張機能の本体であるextension.tsをテストするテストプログラムです。
下記のように調整します。

import * as assert from 'assert';
import * as mocha from 'mocha';    //①

// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode';
import * as myExtension from '../../extension';     //②

describe('Extension Test Suite', () => {    //①
  before(() => {
    vscode.window.showInformationMessage('Start all tests.');
  });

  it('Sample test', () => {    //①
    assert.equal(-1, [1, 2, 3].indexOf(5));
    assert.equal(-1, [1, 2, 3].indexOf(0));
  });
});

①テンプレートそのままでは、mochaのTDD記法とBDD記法が混ざっていて無駄に複雑になっているので、BDD記法に統一します。
extension.tsの読み込みがコメントアウトされているので、有効化します。
なお、このテストプログラムはあくまでサンプルのため、extension.tsの処理を呼び出していません。
実行すると、とりあえずassertが成功し、「テストが1件成功しました」というメッセージが表示されます。

index.tsファイルの調整

GUIから起動されるindex.tsは「Test Runner」と呼ばれ、テストプログラムを呼び出す役割を持っています。
これを下記のように書き換えます。

'use strict';

declare var global: any;

/* tslint:disable no-require-imports */

import * as fs from 'fs';
import * as glob from 'glob';
import * as paths from 'path';

const istanbul = require('istanbul');
const Mocha = require('mocha');
const remapIstanbul = require('remap-istanbul');

// Linux: prevent a weird NPE when mocha on Linux requires the window size from the TTY
// Since we are not running in a tty environment, we just implementt he method statically
const tty = require('tty');
if (!tty.getWindowSize) {
  tty.getWindowSize = (): number[] => {
    return [80, 75];
  };
}

let mocha = new Mocha({
  ui: 'bdd',
  color: true,  //①
});

function configure(mochaOpts: any): void {
  mocha = new Mocha(mochaOpts);
}
exports.configure = configure;

function _mkDirIfExists(dir: string): void {
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir);
  }
}

function _readCoverOptions(testsRoot: string): ITestRunnerOptions | undefined {
  const coverConfigPath = paths.join(testsRoot, '..', '..', '..', 'coverconfig.json');  //②
  if (fs.existsSync(coverConfigPath)) {
    const configContent = fs.readFileSync(coverConfigPath, 'utf-8');
    return JSON.parse(configContent);
  }
  return undefined;
}

function run(testsRoot: string, clb: any): any {
  // Read configuration for the coverage file
  const coverOptions = _readCoverOptions(testsRoot);
  if (coverOptions && coverOptions.enabled) {
    // Setup coverage pre-test, including post-test hook to report
    const coverageRunner = new CoverageRunner(coverOptions, testsRoot);
    coverageRunner.setupCoverage();
  }

  // Glob test files
  glob('**/**.test.js', { cwd: testsRoot }, (error, files): any => {
    if (error) {
      return clb(error);
    }
    try {
      // Fill into Mocha
      files.forEach((f): Mocha => mocha.addFile(paths.join(testsRoot, f)));
      // Run the tests
      let failureCount = 0;

      mocha.run()
        .on('fail', () => failureCount++)
        .on('end', () => clb(undefined, failureCount)
      );
    } catch (error) {
      return clb(error);
    }
  });
}
exports.run = run;

interface ITestRunnerOptions {
  enabled?: boolean;
  relativeCoverageDir: string;
  relativeSourcePath: string;
  ignorePatterns: string[];
  includePid?: boolean;
  reports?: string[];
  verbose?: boolean;
}

class CoverageRunner {

  private coverageVar: string = '$$cov_' + new Date().getTime() + '$$';
  private transformer: any = undefined;
  private matchFn: any = undefined;
  private instrumenter: any = undefined;

  constructor(private options: ITestRunnerOptions, private testsRoot: string) {
    if (!options.relativeSourcePath) {
      return;
    }
  }

  public setupCoverage(): void {
    // Set up Code Coverage, hooking require so that instrumented code is returned
    const self = this;
    self.instrumenter = new istanbul.Instrumenter({ coverageVariable: self.coverageVar });
    const sourceRoot = paths.join(self.testsRoot, self.options.relativeSourcePath);

    // Glob source files
    const srcFiles = glob.sync('**/**.js', {
      cwd: sourceRoot,
      ignore: self.options.ignorePatterns,
    });

    // Create a match function - taken from the run-with-cover.js in istanbul.
    const decache = require('decache');
    const fileMap: any = {};
    srcFiles.forEach((file) => {
      const fullPath = paths.join(sourceRoot, file);
      fileMap[fullPath] = true;

      // On Windows, extension is loaded pre-test hooks and this mean we lose
      // our chance to hook the Require call. In order to instrument the code
      // we have to decache the JS file so on next load it gets instrumented.
      // This doesn't impact tests, but is a concern if we had some integration
      // tests that relied on VSCode accessing our module since there could be
      // some shared global state that we lose.
      decache(fullPath);
    });

    self.matchFn = (file: string): boolean => fileMap[file];
    self.matchFn.files = Object.keys(fileMap);

    // Hook up to the Require function so that when this is called, if any of our source files
    // are required, the instrumented version is pulled in instead. These instrumented versions
    // write to a global coverage variable with hit counts whenever they are accessed
    self.transformer = self.instrumenter.instrumentSync.bind(self.instrumenter);
    const hookOpts = { verbose: false, extensions: ['.js'] };
    istanbul.hook.hookRequire(self.matchFn, self.transformer, hookOpts);

    // initialize the global variable to stop mocha from complaining about leaks
    global[self.coverageVar] = {};

    // Hook the process exit event to handle reporting
    // Only report coverage if the process is exiting successfully
    process.on('exit', (code: number) => {
      self.reportCoverage();
      process.exitCode = code;
    });
  }

  /**
   * Writes a coverage report.
   * Note that as this is called in the process exit callback, all calls must be synchronous.
   *
   * @returns {void}
   *
   * @memberOf CoverageRunner
   */
  public reportCoverage(): void {
    const self = this;
    istanbul.hook.unhookRequire();
    let cov: any;
    if (typeof global[self.coverageVar] === 'undefined' || Object.keys(global[self.coverageVar]).length === 0) {
      console.error('No coverage information was collected, exit without writing coverage information');
      return;
    } else {
      cov = global[self.coverageVar];
    }

    // TODO consider putting this under a conditional flag
    // Files that are not touched by code ran by the test runner is manually instrumented, to
    // illustrate the missing coverage.
    self.matchFn.files.forEach((file: any) => {
      if (cov[file]) {
        return;
      }
      self.transformer(fs.readFileSync(file, 'utf-8'), file);

      // When instrumenting the code, istanbul will give each FunctionDeclaration a value of 1 in coverState.s,
      // presumably to compensate for function hoisting. We need to reset this, as the function was not hoisted,
      // as it was never loaded.
      Object.keys(self.instrumenter.coverState.s).forEach((key) => {
        self.instrumenter.coverState.s[key] = 0;
      });

      cov[file] = self.instrumenter.coverState;
    });

    // TODO Allow config of reporting directory with
    const reportingDir = paths.join(self.testsRoot, self.options.relativeCoverageDir);
    const includePid = self.options.includePid;
    const pidExt = includePid ? ('-' + process.pid) : '';
    const coverageFile = paths.resolve(reportingDir, 'coverage' + pidExt + '.json');

    // yes, do this again since some test runners could clean the dir initially created
    _mkDirIfExists(reportingDir);

    fs.writeFileSync(coverageFile, JSON.stringify(cov), 'utf8');

    const remappedCollector = remapIstanbul.remap(cov, {
      warn: (warning: any) => {
        // We expect some warnings as any JS file without a typescript mapping will cause this.
        // By default, we'll skip printing these to the console as it clutters it up
        if (self.options.verbose) {
          console.warn(warning);
        }
      }
    });

    const reporter = new istanbul.Reporter(undefined, reportingDir);
    const reportTypes = (self.options.reports instanceof Array) ? self.options.reports : ['lcov'];
    reporter.addAll(reportTypes);
    reporter.write(remappedCollector, true, () => {
      console.log(`reports written to ${reportingDir}`);
    });
  }
}

元のindex.tsは配下の各テストプログラムを呼び出すだけでしたが、上記の様に書き換えることで、istanbulを利用しカバレッジ計測ができるようになります。
引用元のサイトに記載のものは、すでに非推奨になったコードも含まれていたため、一部手を加えています。(①の箇所)
41行目の②は、このindex.tsが使用する設定ファイル(coverconfig.json)へのパスです。
トランスパイルで出力されたindex.jsを起点とした相対パスを指定します。
とりあえずそのままコピペすれば動くはずです。

coverconfig.jsonファイルの作成

index.tsで読み込む設定ファイルです。
開発環境の作業フォルダの直下に新規作成します。

{
  "enabled": true,
  "relativeSourcePath": "../..",
  "relativeCoverageDir": "../../../coverage",
  "ignorePatterns": [
      "**/node_modules/**"
  ],
  "includePid": false,
  "reports": [
      "html",
      "lcov",
      "text-summary"
  ],
  "verbose": false
}

カバレッジ測定結果の出力先などを定義しています。
こちらも相対パスで指定する必要があるため、分かりにくいので注意してください。

名称 概要
relativeSourcePath テストしたいjsファイルが格納されているフォルダを指定します。
トランスパイル後のindex.jsのある場所を起点にして、相対パスで指定します。
テンプレートのフォルダ構成だと、テストプログラムも一緒にカバレッジ測定してしまいますが、とりあえずは動くので目をつぶります。
relativeCoverageDir カバレッジ測定結果のHTMLファイルの出力先を指定します。
トランスパイル後のindex.jsのある場所を起点にして、相対パスで指定します。
とりあえずそのままコピペすれば動くはずです。

launch.jsonファイルの調整

launch.jsonを下記のように書き換えます。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "runtimeExecutable": "${execPath}",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}"
      ],
      "outFiles": [
        "${workspaceFolder}/out/**/*.js"
      ],
      "preLaunchTask": "npm: watch"
    },
    {
      "name": "Extension Tests",
      "type": "extensionHost",
      "request": "launch",
      "runtimeExecutable": "${execPath}",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}",
        "--extensionTestsPath=${workspaceFolder}/out/test/suite"
      ],
      "outFiles": [
        "${workspaceFolder}/out/test/**/*.js"
      ],
      "preLaunchTask": "npm: watch"
    }
  ]
}

"name": "Run Extension"要素のブロックは今回使いません。
"name": "Extension Tests"要素のブロックの--extensionTestsPathを、以下のように書き換えています。
このオプションには、トランスパイル後のindex.jsが格納されるフォルダへのパスを指定しますが、テンプレート生成直後の状態では最後にindexがついており、どのパターンでもうまくVSCodeインスタンスが起動しません。
(他の解説でも、全部このようなパス指定になっているんですけどね・・・)

--extensionTestsPath=${workspaceFolder}/out/test/suite/index
 ↓
--extensionTestsPath=${workspaceFolder}/out/test/suite

package.jsonファイルの調整

package.jsonを下記のように書き換えます。

{
  "name": "sample-ex-org",
  "displayName": "sample_ex_org",
  "description": "",
  "version": "0.0.1",
  "engines": {
    "vscode": "^1.40.0"
  },
  "categories": [
    "Other"
  ],
  "activationEvents": [
    "onCommand:extension.helloWorld"
  ],
  "main": "./out/extension.js",
  "contributes": {
    "commands": [
      {
        "command": "extension.helloWorld",
        "title": "Hello World"
      }
    ]
  },
  "scripts": {
    "vscode:prepublish": "npm run compile",
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
    "pretest": "npm run compile"
  },
  "devDependencies": {
    "@types/glob": "^7.1.1",
    "@types/mocha": "^5.2.6",
    "@types/node": "^10.12.21",
    "decache": "^4.5.1",
    "glob": "^7.1.6",
    "istanbul": "^0.4.5",
    "mocha": "^6.1.4",
    "remap-istanbul": "^0.13.0",
    "tslint": "^5.12.1",
    "typescript": "^3.3.1",
    "vscode": "^1.1.34"
  }
}

vscode-testを用いたrunTest.tsから起動するパターンのテスト構成になっているため、scripts要素から"test": "node ./out/test/runTest.js"を削除します。
また、devDependencies要素に必要なnpmパッケージを追加・削除します。
vscode-testモジュールも使用しないので削除し、代わりにvscodeモジュールを追加します。
またそれに伴い、@types/vscodeも削除します。

npmパッケージの再インストール

package.jsonを編集したので、いったんnpm_modulesフォルダを削除して、各npmパッケージをインストールし直します。

npm install

runTest.tsファイルの削除

今回は使用しないため、見出しの通りrunTest.tsファイルを削除します。

TypeScriptのトランスパイル

上記が全部完了したら、TypeScriptのトランスパイルを実行します。
すでにoutフォルダが生成されているときは、念の為、一旦削除してください。

tsc -p ./

outフォルダに必要なJavaScriptファイルが出力されます。

調整作業後の確認

これでテストを実行する準備が整いました。
最終的には下記のような構成になります。

f:id:satoishi_ts:20191212020226p:plain

行った作業をまとめると、

  • launch.jsonindex.tspackage.jsonextension.test.tsを編集
  • coverconfig.jsonを新規作成
  • runTest.tsを削除
  • node_modulesフォルダを削除→npm installで再生成
  • TypeScriptのトランスパイルを実行し、outフォルダを生成

となります。
coverageフォルダはこの後のテスト実行で生成されます。

テストの実行

デバッグサイドバーの「デバッグ開始」ボタンを使用します。

f:id:satoishi_ts:20191212020403p:plain

テストが実行され、

f:id:satoishi_ts:20191212020401p:plain

カバレッジ測定結果がcoverageフォルダにHTMLで出力されます。

f:id:satoishi_ts:20191212020359p:plain

これでカバレッジ測定が可能なテスト環境が構築できました。
あとはテストをextension.test.tsに書いていきます。


補足的な話

runTest.tsとは?

公式ドキュメントでは、「Test Script」と呼ばれています。
本稿では本ファイルは削除しますが、最近のVSCode拡張機能のテストはrunTest.tsvscode-testを用いてVSCodeインスタンスを起動してテストを実行します。

仕組みとしては、Nodeのchild_process.spawnSync()関数でVSCodeの実行ファイルに起動をかけ、runTest.tsの子プロセスとしてVSCodeインスタンスを起動します。
このときさらに、コード内でvscode-testに用意された機能を使うと、任意のバージョンのVSCodeをダウンロードして、そこの実行ファイル(Code.exe)に対して起動をかけることも出来ます。
これにより、バージョンを指定したVSCode上でテストを行うことができテスト環境を柔軟に構築することが出来るようになります。
またさらに、VSCodeインスタンスを起動する前に、他の拡張機能のインストールを指定することもできるため、特定の拡張機能がインストールされたVSCodeインスタンスを生成することも出来ます。
(ちなみに、ダウンロードしてきたVSCodeは、開発環境の.vscode-testフォルダに保存されます)

launch.jsonCLIで単純に起動パラメータを指定して実行するよりも、より柔軟で統合的なテストが行えます。

runTest.tsでテストするときの注意

このように便利なvscode-testを用いたテストですが、実は制限もあり、同一PC上でVSCodeインスタンスを起動することが出来ません。
本末転倒のようですが、普通に「タスクの実行」やデバッグサイドバーからrunTest.tsを実行しようとすると、「Running extension tests from the command line is currently only supported if no other instance of Code is running.」とエラーが表示され、起動することが出来ません。
エラーメッセージの通り、CLIからは確かに起動できるのですが、それでも他にVSCodeが立ち上がっていると同じエラーで起動することが出来ません。
このため、テスト実行をするたびに毎回、今開発に使用しているVSCodeを終了させる必要があります。

これを解決する方法もあるにはあります。

VSCodeには、標準のVSCode以外にInsiderバージョンというものがあります。
このバージョンからrunTest.tsに「タスクの実行」やデバッグサイドバーから起動をかけると、テストを実行することができます。

Testing Extension | Visual Studio Code Extension API | Using Insiders version for extension development

runTest.ts内でダウンロードするVSCodeは標準のVSCodeのため、Insiderバージョンとは違うアプリと認識され、起動ができるようです。

拡張機能の開発のためにわざわざInsiderバージョンをインストールして使用しないといけないなんて、あまりに本末転倒すぎてにわかに信じられないのですが、本当にこの理解であっているのか誰か教えていただけると嬉しいです。

あくまでCI上で動かすことを想定しているが故の制限なんですかね・・・。

テスト起動時のTypeScriptのトランスパイル

yeomanからテンプレート環境を生成時は、テスト実行前に自動で「tsc -watch -p ./」が実行されます。
launch.json内の"preLaunchTask": "npm: watch"package.json上の「"watch": "tsc -watch -p ./"」が起動されるためです。
ただ、おそらくインクリメンタルコンパイルしていて、うまくテストが動かないときがあるので、テスト起動前には普通に「tsc -p ./」を実行するほうが無難です。
(これに、メチャクチャハマりました…)

テストにブレークポイントを張りたい

本稿の方法だと、テストプログラム(extension.test.tsの方)には、ブレークポイントが効いてくれません。
そこで、下記のようにdebuggerを仕込むことで、テストプログラムを途中で止めてステップ実行することが出来るようになります。

f:id:satoishi_ts:20191212020357p:plain

【出典】
mocha で debugger を止める

本来は、package.jsonにオプションを指定したりするようなのですが、私が試したときはうまくいかなかったので、このやり方のほうが確実です。

引用元・参考一覧

istanbulを用いた具体的なテスト構成の構築方法
VSCode extension code coverage · Roman Peshkov

(公式ドキュメント)VSCode拡張機能用テスト方法の説明
Testing Extension | Visual Studio Code Extension API

(公式ドキュメント)VSCodeデバッグ方法。拡張機能の開発には特化していませんが、リモートデバッギングの話とかも載っています。
Debugging in Visual Studio Code

(公式ドキュメント)launch.jsonの各パラメータに指定する変数一覧。パス指定で迷子になったときに…。
Visual Studio Code Variables Reference

runTest.tsって何者?というStack Overflow上の質問です。
runTest.ts(Test Script)とindex.ts(Test Runner)の役割の違いが、公式ドキュメントだけではよくわからないため、stackoverflowにも質問が上がっていました。
javascript - runTest.ts class in vscode-test setup gets never used even in example project, what is it's use? - Stack Overflow

vscode-testVSCodeインスタンスを子プロセスで生成するプロセスについて参考
node の spawn に関して調べてみた
node の spawn に関して調べてみた その2

脚注(テンプレート生成直後の調整前のソース一覧)

テンプレート生成直後のextension.test.ts

import * as assert from 'assert';
import { before } from 'mocha';

// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode';
// import * as myExtension from '../extension';

suite('Extension Test Suite', () => {
  before(() => {
    vscode.window.showInformationMessage('Start all tests.');
  });

  test('Sample test', () => {
    assert.equal(-1, [1, 2, 3].indexOf(5));
    assert.equal(-1, [1, 2, 3].indexOf(0));
  });
});

テンプレート生成直後のindex.ts

import * as path from 'path';
import * as Mocha from 'mocha';
import * as glob from 'glob';

export function run(): Promise<void> {
  // Create the mocha test
  const mocha = new Mocha({
    ui: 'tdd',
  });
  mocha.useColors(true);

  const testsRoot = path.resolve(__dirname, '..');

  return new Promise((c, e) => {
    glob('**/**.test.js', { cwd: testsRoot }, (err, files) => {
      if (err) {
        return e(err);
      }

      // Add files to the test suite
      files.forEach(f => mocha.addFile(path.resolve(testsRoot, f)));

      try {
        // Run the mocha test
        mocha.run(failures => {
          if (failures > 0) {
            e(new Error(`${failures} tests failed.`));
          } else {
            c();
          }
        });
      } catch (err) {
        e(err);
      }
    });
  });
}

テンプレート生成直後のlaunch.json

// A launch configuration that compiles the extension and then opens it inside a new window
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "runtimeExecutable": "${execPath}",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}"
      ],
      "outFiles": [
        "${workspaceFolder}/out/**/*.js"
      ],
      "preLaunchTask": "npm: watch"
    },
    {
      "name": "Extension Tests",
      "type": "extensionHost",
      "request": "launch",
      "runtimeExecutable": "${execPath}",
      "args": [
        "--extensionDevelopmentPath=${workspaceFolder}",
        "--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
      ],
      "outFiles": [
        "${workspaceFolder}/out/test/**/*.js"
      ],
      "preLaunchTask": "npm: watch"
    }
  ]
}

テンプレート生成直後のpackage.json

{
  "name": "sample-ex-org",
  "displayName": "sample_ex_org",
  "description": "",
  "version": "0.0.1",
  "engines": {
    "vscode": "^1.40.0"
  },
  "categories": [
    "Other"
  ],
  "activationEvents": [
    "onCommand:extension.helloWorld"
  ],
  "main": "./out/extension.js",
  "contributes": {
    "commands": [
      {
        "command": "extension.helloWorld",
        "title": "Hello World"
      }
    ]
  },
  "scripts": {
    "vscode:prepublish": "npm run compile",
    "compile": "tsc -p ./",
    "watch": "tsc -watch -p ./",
    "pretest": "npm run compile",
    "test": "node ./out/test/runTest.js"
  },
  "devDependencies": {
    "@types/glob": "^7.1.1",
    "@types/mocha": "^5.2.6",
    "@types/node": "^10.12.21",
    "@types/vscode": "^1.40.0",
    "glob": "^7.1.4",
    "mocha": "^6.1.4",
    "typescript": "^3.3.1",
    "tslint": "^5.12.1",
    "vscode-test": "^1.0.2"
  }
}

TeamSpirit Advent Calendar Day7:サクラエディタからVisual Studio Codeへ乗り換えたくて拡張機能を作った

この記事は チームスピリット Advent Calendar 2019 の7日目の記事です。 adventar.org

(2020/01/17追記)本記事はエントリーブログも兼ねていたため、弊社開発者ブログ(チームスピリットデベロッパーブログ)へ転記しました! teamspirit.hatenablog.com

また本記事の続きとして、VSCode上におけるTypeScriptのテスト環境構築の方法も、12/14のアドベントカレンダーにて引き続き解説しています! satoishi-ts.hatenablog.com