ターミナル向けに色付きの文字を 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 が立ってた。