ターミナル向けに色付きの文字を format する関数を実装してて、そのテストを Vitest で書こうとしていた。
// src/format.ts function red(text: string): string { // ... } export function formatError(message: string): string { if (process.stderr.hasColors()) { return red('error: ') + message; } else { return 'error: ' + message; } }
// src/format.test.ts import { expect, test } from 'vitest'; import util from 'node:util'; import { formatError } from './format.js'; test('formatError', () => { const actual = formatError('test'); expect(util.stripVTControlCharacters(actual)).toBe('error: test'); });
一見するとなんも問題なさそうだけど、テストを実行するとコケる。
$ npx vitest
...
FAIL src/format.test.ts > formatError
TypeError: process.stderr.hasColors is not a function
❯ formatError src/format.ts:5:22
3| }
4| export function formatError(message: string): string {
5| if (process.stderr.hasColors()) {
| ^
6| return red(message);
7| } else {
❯ eval src/format.test.ts:5:55
❯ <anonymous> ../../../blitz.eb2a6bdc.js:40:25205
❯ new Promise ../../../blitz.eb2a6bdc.js:40:25164
...
@types/node の型定義を見ると、process.stdout.hasColors() は常に定義されている。そのため、呼び出せないことはないはずなのだが...一体なぜ...
何が起きているのか
挙動を観察してみると、 テスト実行中に process.stdout.hasColors が undefined になっていた。
しかも同様の問題は、他のプロパティでも見られるようだった。process.stdout.clearLine(), process.stdout.columns, process.stdout.getColorDepth(), process.stdout.isTTY などのプロパティも、型定義上 undefined でないにも関わらず、テスト実行中に undefined になっていた。
原因
最初 Vitest のバグかと思って Vitest に issue を建てたのだけど、どうやら違うようだった。
Vitest には Test Isolation というテストファイルごとに別プロセスで実行する仕組みがある。この仕組みのおかげで、あるテストファイルで発生したグローバル汚染が他のテストファイルに影響しなくなる。
Test Isolation は tinypool というライブラリを使って実装されてる。tinypool の内部では Node.js の worker_threads/child_process モジュールが使われている。
そして、worker_threads/child_process で起動されたプロセスでは、どうも process.stdin/process.stdout/process.stderr の一部プロパティが欠ける仕様になってるらしい。
- https://github.com/nodejs/node/issues/26946#issuecomment-477502347
- https://github.com/nodejs/node/issues/2333#issuecomment-391968913
isTTY や columns に妥当な値を割り当てられないから、仕方なく undefined にしているみたい。なるほどねえ。
一部プロパティが欠けることを考慮してコードを書く
仕様上 process.stdout.hasColors が undefined になるのだから、コードを書くときもそれを想定すべきだろう。そう考えて、format 関数を以下のように修正した。
// src/format.ts
function red(text: string): string {
// ...
}
export function formatError(message: string): string {
- if (process.stderr.hasColors()) {
+ if (process.stderr.hasColors?.()) {
return red('error: ') + message;
} else {
return 'error: ' + message;
}
}
感想
child_process で起動されたプロセス向けに、それっぽいダミーの振る舞いを定義して、undefined にならないようにしても良いんじゃないかなとは思った。isTTY === false, columns === 0 と割り当てて、hasColors() や clearLine() などのメソッドは何もしない関数にして、getColorDepth() からは 0 を返す、みたいな。けどターミナルのサイズは存在しないはずなのに columns === 0 にして良いのかとか (undefined にすべきでは)、getColorDepth() は 0 を返すので本当に良いのかとか (例外を投げるか、関数をそもそも undefined にすべきでは)、色々論点がありそう。
あとこの挙動が、Node.js のドキュメントで一切触れられてないのはちょっと不親切だなと思った。せめてドキュメントに書かれていて欲しい。
@types/node の型定義が undefined を考慮してないのも良くないなとは思うけど...実際 undefined になることを考慮した型定義に変えたら、エコシステムへの影響結構デカそう。実行できるかどうか不明だけど、こちらは一応 Issue が立ってた。