何が起こったのか
- babel-plugin-istanbulでカバレッジを計測しようとしたらCI上でCypressのテストが落ちるようになった
- 原因はアプリケーションコードが Function.prototype.toString に依存していたことにあった
- コードの一部をWorkerに送って実行するために Function.prototype.toString を呼び出している箇所があった。このとき当該関数は外側の環境にある変数を参照してはいけないという制約が生じるが、トランスパイラはそのような事情を関知しないため、toStringの制約に反するコード挿入されていた。
- Workerでエラーが起こっていたために、デバッグが困難だった
経緯
DXチームでは現在自動テストの文化の改善に取り組んでいます。その一環として、テストやテストのための仕組みが整備されていない部分を発見するためにテストカバレッジの測定を行おうとしていました。
一部のリポジトリではブラウザテストをCypressで行っていました。この場合、カバレッジを測定するには2つの方法が考えられます。
- ブラウザに送るJavaScriptコードに、あらかじめ計測用のコードを仕込んでおく。
- V8などWebブラウザの処理系がもつ計測APIを使う。
後者については実施できた例をあまり聞かないこともあり、今回も前者の方法で実装することにしました。そのために babel-plugin-istanbul というBabelプラグインを使用しました。
これを入れると、たとえば以下のようなコードが
export function foo(cond) {
if (cond) {
console.log("foo");
}
}
次のようなコードに変換されます。
function cov_kptk8mkc1() {
var path = "/path/to/src/index.js";
var hash = "924939788e00ef84bf872030df82a4f0ed2a1635";
var global = new Function("return this")();
var gcv = "__coverage__";
var coverageData = {
/* 略 */
};
var coverage = global[gcv] || (global[gcv] = {});
if (!coverage[path] || coverage[path].hash !== hash) {
coverage[path] = coverageData;
}
var actualCoverage = coverage[path];
{
// @ts-ignore
cov_kptk8mkc1 = function () {
return actualCoverage;
};
}
return actualCoverage;
}
cov_kptk8mkc1();
export function foo(cond) {
cov_kptk8mkc1().f[0]++;
cov_kptk8mkc1().s[0]++;
if (cond) {
cov_kptk8mkc1().b[0][0]++;
cov_kptk8mkc1().s[1]++;
console.log("foo");
} else {
cov_kptk8mkc1().b[0][1]++;
}
}
これにより、元のソースコードにおける文や分岐が実行されるごとにカウンタが加算され、最終結果が globalThis.__coverage__
という変数に入ることになります。
ところが、今回作業したリポジトリでは、この変換を有効化することでテスト中に Uncaught ReferenceError: $v is not defined というエラーが出るようになってしまいました。
なお、以降では読みやすいようにデバッグの時系列をいくつか前後させています。 (実際には最適ではない順番で対応した場所もあったため)
デバッグの試み
いくつかの要因が、問題の解決を難しくしていました。
- なぜかCIでしか発生しなかったこと。
- ブラウザテスト環境のため、ユニットテストに比べて操作が煩雑であること。
- スタックトレースが得られなかったこと。
- ただし、テスト側のコードではなくアプリケーション側のコード由来であることは示されていた。
- 記載の変数名が手元では見当たらなかったこと。
babel pluginを一時的に剥がす
当該PRには関連する変更がいくつかあったため、まずbabel-plugin-istanbulを剥がしてみました。するとエラーは無くなりました。
これにより、babel-plugin-istanbulを導入したことがトリガーになっていることは確実になりました。
(これは最初のほうにやるべきでしたが、結果としてはほとんど最後のほうでの作業になってしまいました。)
テストを減らす
CIでしか再現しないので、CIを繰り返し呼びながらデバッグしていくことになります。
そこで、必要なテスト以外を削除して、問題のテストが実行されるまでの時間を短くしました。
(これは最初のほうにやるべきでしたが、結果としてはほとんど最後のほうでの作業になってしまいました。)
ログを取る
テスト中のどの位置で発生しているかの当たりをつけるため、ログを取ります。
Cypressはconsole.logを全部出してくれるわけではないようだったので、 cy.log を使ってログを出力しました。
cy.log をテストの全てのステップに挿入して実行しました。なぜかCIのコンソール自体には cy.log の出力はなかったのですが、ブラウザの画面の録画がデバッグ用に残されており、そちらに出力が残っていました。
これでテスト中での発生タイミングはわかりました。ただ、エラーが起きていたのはテスト対象のページを呼び出した直後のため、発生箇所の絞り込みのための情報としてはあまり有益ではありませんでした。
変数アクセスをキャプチャする
ReferenceError は読み取りまたは書き込みしようとしたローカル変数が実際には存在しなかったことを示唆しています。
このような場合、同名のグローバル変数があればそちらにフォールバックします。そこで以下のようにしてグローバル変数へのアクセスをフックして、より有益な情報をエラーメッセージに流すことを考えました。
Object.defineProperty(window, "$v", {
get() {
throw new Error("Stack is " + new Error().stack);
},
set(_value) {
throw new Error("Stack is " + new Error().stack);
},
});
しかし実際にはこのフックで捕捉されることはありませんでした。
テストコードとテスト対象のコードでRealmが異なる可能性を考え、 cy.window に対して同じことを試みましたが、うまくいきません。
そこで、さらに条件を簡略化して
// これをすれば $v へのアクセスがエラーになることはないはず
window.$v = undefined;
に相当する処理を試してみてもエラーは残ったままでした。これをテストコードからではなく、アプリケーションコードのHTML内の最も早くロードされるscriptタグから実行しても結果は同じでした。
これにより、そもそも何らかの理由でテスト対象コードのグローバルオブジェクトを掴めていない可能性が推測されました。 (この時点ではその具体的な原因は不明でした)
ビルド済みコードで試す
ランタイムだけでエラー発生位置の目星をつけるのが難しそうだったため、再び変数名に目をつけました。
$v という名前からしても、この変数は自動生成された可能性が高いです。とはいえ、おそらく実行時ではなくコンパイル時に名前は決定されている可能性が高く、JavaScriptの生成物から検索しても見つからないというのは考えにくいです。
そこで次のようにします。元々ブラウザテストのためのJavaScriptアセットはCI側でビルドしたものを使っていましたが、今回はビルドのために一時的に手元で生成したものを使うようにします。
本来であればコミットしないビルド生成物をわざとコミットして、CIではこの生成物を所定のディレクトリに移すだけにします。
するとエラーメッセージが $g is not defined に変化しました。つまり、何らかの環境差異によってビルド結果が微妙に異なっていたようです。その差異の原因自体は謎ですが、とにかくこの時点でCI側のコードと手元のコードが揃えられたことになります。
変数名を探る
再びビルド生成物から当該変数名を探します。
ag '(?<!\w)$v\b'
ちなみに、 $ は本来であれば記号に分類されるためword boundary \b を使うと意図しない結果になってしまいます。
するとファイルが2件見つかりました。
リネームしてもう1度
2つの変数のどちらが悪さをしているかを見つけるために、2つの変数をリネームしました。
本来はビルド生成物を手でいじるのは御法度ですが、今回はデバッグのための一時的な対応として行います。
これによりエラーメッセージが変化し、原因となっているファイルが特定できました。
発生箇所を探す
問題となっていた $g は、カバレッジ用のオブジェクトを生成してキャッシュする関数 (最初に掲げた例における cov_kptk8mkc1
関数) でした。
しかし、この $g はかなり多くの箇所で呼ばれているため、まだ原因に当たりがついていません。そこで、以下のようなコードを手当たり次第に挿入しました。
// 番号は適宜変更してユニークにする
if (typeof $g !== "function") throw new Error("$g not found: 1");
これで、予想に反して $g が未定義な箇所をユニークなエラーメッセージで区別できます。 (とにかくスタックトレースが出てこないので何とかしてエラーメッセージで対応するしかない)
発生箇所発見
結果、 $g not found: 20 というエラーが出てきました。これは以下のようなコードになっていました。
new Blob([
"(".concat(
function () {
if (typeof $g !== "function") throw new Error("$g not found: 20");
$g().f[1]++;
// (略)
}.toString(),
")()"
)
])
よく見ると、ここで作った関数はcallせずに文字列化だけしています。とても怪しいです。
この時点で特徴的な断片がいくつかあるので、sourcemapを見なくても元を辿るのは簡単そうです。その元のコードは以下のようになっていました。
const fooFunc = () => {
// ...
};
// fooFuncをworkerで実行する
const blob = new Blob([`(${fooFunc.toString()})()`]);
const fooWorker = new Worker(window.URL.createObjectURL(blob));
つまり、Workerにオフロードする処理をインラインで書くために Function.prototype.toString を使っていたのが原因でした。これで色々なことの説明がつきました。
- $v / $g へのアクセスをフックできなかったのは、問題のコードがbackground workerで実行されていたため。
- 広く使われていて信頼性が高いと考えられる babel-plugin-istanbul が問題を起こしたのは、変換しようとしたコードが一般的な仮定を破るコードだったため。
なお、CIでのみ再現した理由は謎のままです。
何が悪かったのか
Function.prototype.toString を使うと関数のソース表現がそのまま得られます (ES2019以降の規定) 。これを使うことで、異なるAgentに処理を転送して実行することができます。
しかし、これには以下のような問題があります。
- 関数の外側にある変数を参照できない。そのような制約は一般的なツール (TypeScriptやデフォルトのESLintなど) では検証してくれない。
- トランスパイラを介在させた場合、トランスパイル後の関数のソースが使われる。トランスパイラが上記の制約を破る可能性がある。
- ECMAScriptの規定では、関数のソース表現のかわりにネイティブ関数と同等のスタブ表現 (function f() { [native code ] }) を返してもよいことになっている。これに該当する場合は動かない可能性がある。
どうするべきか
残念ながら、理想の解決策があるとは限らないようです。
Workerを呼び出すにはWebpackの専用構文が使えます。
ただ、JavaScriptを異なるオリジンに配置している場合には問題があることが知られています。
かわりに、ツールの支援 (シンタックスハイライトや型検査など) を受けることをあきらめて、Workerで動かしたいソースをはじめから文字列リテラルで記述するという手もあります。これが解決策としては一番シンプルでしょう。
まとめ
Function.prototype.toString に依存したコードを書くと面倒なことになるので、なるべく使わないようにしましょう。