sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+445
View File
@@ -0,0 +1,445 @@
// UI の型検査が「関所」として残り続けることを守るテスト (#1001)。
//
// issue #1001 の原因は個別の型エラーではなく、**UI 側に型検査の関所が無かった**
// こと。vite build は型を見ず、vitest は esbuild でトランスパイルするだけなので、
// 誰も止めないまま 12 ファイル 37 件が溜まった
// (origin/main = 34aa673e を素の状態でチェックアウトして `cd ui && npx tsc
// --noEmit` した実測。issue 本文の 30 件は起票時点の値)。
//
// 型エラーを潰すだけでは同じことが必ず再発する。だからこのテストが守るのは
// 「今エラーが 0 件であること」ではなく、**検査が実行される経路が残っていること**。
// 関所を外す変更は、ここで落ちる。
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import ts from 'typescript';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const readJson = (p: string) => JSON.parse(readFileSync(join(repoRoot, p), 'utf8'));
/**
* ES2022 で標準ライブラリに入ったメソッドだけを使う検査用の小片 (#1314)。
* ui/tsconfig.json の lib がこれらを含んでいれば通り、含んでいなければ TS2550。
*/
const ES2022_PROBE_SOURCE = [
'const numbers = [1, 2, 3];',
'export const last = numbers.at(-1);',
"export const head = 'abc'.at(0);",
].join('\n');
/**
* ReadableStream を直接 for await で回す小片 (#1314)。
*
* この構文を成立させているのは lib.dom.asynciterable の 1 枚だけ。lib を明示した
* ときに書き起こし損ねやすい枚数で、落とすと「触っていないファイルが落ちる」が
* #1314 とは逆向きに起きる(今度は原因がこちらの設定変更になる)。
*/
const DOM_ASYNC_ITERABLE_PROBE_SOURCE = [
'export async function drain(stream: ReadableStream<Uint8Array>): Promise<number> {',
' let total = 0;',
' for await (const chunk of stream) total += chunk.byteLength;',
' return total;',
'}',
].join('\n');
/**
* ブラウザに無いもの——Node の型が漏れているかを見るための小片 3 つ (#1316)。
*
* 親ディレクトリの node_modules/@types が画面側の検査に混ざっていた頃は、3 つとも
* 診断ゼロで通っていた。とくに Buffer はブラウザに存在しないので、検査を素通りした
* まま出荷され、実行時に壊れる。1 つずつ分けてあるのは、どれが復活したかを
* 失敗メッセージだけで見分けられるようにするため。
*/
const NODE_TIMER_PROBE_SOURCE = 'export const handle: NodeJS.Timeout = setTimeout(() => {}, 0);';
const NODE_PROCESS_PROBE_SOURCE = 'export const flag = process.env.SOME_FLAG;';
const NODE_BUFFER_PROBE_SOURCE = "export const bytes = Buffer.from('hi');";
/** 上の 3 つの対照。ブラウザにあるものは通る、を同じ道具立てで確かめる。 */
const BROWSER_PROBE_SOURCE = [
'export const timer = window.setTimeout(() => {}, 0);',
'export const text = new TextDecoder().decode(new Uint8Array([104, 105]));',
].join('\n');
/**
* tsc が実際に組むファイル一覧を返す。
*
* 設定の字面(include / exclude / strict)を見るだけでは穴が残る。include は
* "src" のままで exclude に "src/**\/*.test.tsx" を足すと、227 本のテストが
* 黙って検査対象から外れる。解決済みの一覧を見れば、どちらの経路で抜いても落ちる。
*/
function resolveUiProgram(configName: string): string[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, configName), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
expect(parsed.options.strict).toBe(true);
return parsed.fileNames;
}
/**
* ui/tsconfig.json の compilerOptions だけで小さなコードを検査し、診断を返す。
*
* types を空にしてあるのが肝。TypeScript は node_modules/@types を親ディレクトリへ
* 遡って自動で取り込むので、既定のままだとリポジトリ直下の @types/* が画面側の
* 検査に混ざる。空にすると「画面側の設定だけで成り立っているか」を見られる。
*/
function diagnoseWithUiOptions(
source: string,
overrides: ts.CompilerOptions = {},
): readonly ts.Diagnostic[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
const options: ts.CompilerOptions = {
...parsed.options,
...overrides,
types: [],
noEmit: true,
noResolve: true,
};
const probePath = join(uiDir, '__lib-probe__.ts');
const host = ts.createCompilerHost(options, true);
const readOriginal = host.readFile.bind(host);
const getOriginal = host.getSourceFile.bind(host);
host.readFile = (f) => (f === probePath ? source : readOriginal(f));
host.fileExists = (f) => f === probePath || ts.sys.fileExists(f);
host.getSourceFile = (f, langVersion, onError, shouldCreate) =>
f === probePath
? ts.createSourceFile(f, source, langVersion, true)
: getOriginal(f, langVersion, onError, shouldCreate);
const program = ts.createProgram([probePath], options, host);
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
}
/**
* 指定した設定ファイルの compilerOptions を**そのまま**使って小片を検査する (#1316)。
*
* 上の diagnoseWithUiOptions との違いは 2 点あり、どちらも外せない。
*
* 1. types を上書きしない。あちらは types: [] を強制するので、tsconfig.json が
* types を書いていようがいまいが同じ結果になる——つまり #1316 の検体を
* あちらに載せると、設定を直す前から通ってしまう(空振り)。ここは設定の
* 実物を見る必要があるので、上書きしない。
* 2. noResolve を立てない。noResolve は types で名指しした型定義の読み込みまで
* 止めてしまい、types: ["node"] を書いても Node の型が入らなくなる(実測)。
* それでは tsconfig.typecheck.json 側の検査が意味を失う。
*
* 小片は ui/ 直下に置いたことにする。@types の探索は置き場所から始まるので、
* 本物のファイルと同じ条件になる。
*/
function diagnoseWithConfigAsWritten(
configName: string,
source: string,
overrides: ts.CompilerOptions = {},
): readonly ts.Diagnostic[] {
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, configName), ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir);
expect(parsed.errors).toEqual([]);
const options: ts.CompilerOptions = { ...parsed.options, ...overrides, noEmit: true };
const probePath = join(uiDir, '__node-probe__.ts');
const host = ts.createCompilerHost(options, true);
const readOriginal = host.readFile.bind(host);
const getOriginal = host.getSourceFile.bind(host);
host.readFile = (f) => (f === probePath ? source : readOriginal(f));
host.fileExists = (f) => f === probePath || ts.sys.fileExists(f);
host.getSourceFile = (f, langVersion, onError, shouldCreate) =>
f === probePath
? ts.createSourceFile(f, source, langVersion, true)
: getOriginal(f, langVersion, onError, shouldCreate);
const program = ts.createProgram([probePath], options, host);
return [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
}
/** ディスク上に実在する ui/src の TypeScript ファイル一覧(設定の字面を通さずに数える)。 */
function sourceFilesOnDisk(): string[] {
return ts.sys.readDirectory(join(repoRoot, 'ui', 'src'), ['.ts', '.tsx'], ['node_modules'], undefined);
}
/**
* 設定ファイルを**本物のプログラムとして組み**、読み込まれたファイルを全部返す (#1316)。
*
* resolveUiProgram が返すのは根っこ(include / exclude で選ばれたファイル)だけで、
* そこから import や参照でたぐり寄せられた .d.ts は含まれない。types: [] が守るのは
* まさにその「たぐり寄せ」の側なので、根っこだけ見ていても関所の状態は分からない。
*
* 第 5 引数に設定ファイルのパスを渡すのが肝。渡さないと typeRoots の探索が
* プロセスの作業ディレクトリ(=リポジトリ直下)から始まり、@types/node が
* ui/node_modules ではなく直下から拾われる。本物の `cd ui && tsc` と条件が変わって
* しまい、ui/package.json の宣言が効いているかを見られなくなる(実測)。
*/
function realProgramFiles(configName: string): string[] {
const uiDir = join(repoRoot, 'ui');
const configPath = join(uiDir, configName);
const raw = ts.readConfigFile(configPath, ts.sys.readFile);
expect(raw.error).toBeUndefined();
const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, uiDir, undefined, configPath);
expect(parsed.errors).toEqual([]);
const program = ts.createProgram(parsed.fileNames, { ...parsed.options, noEmit: true });
return program.getSourceFiles().map((f) => f.fileName);
}
/** 上の一覧のうち、Node の型定義(@types/node 配下)だけを取り出す。 */
function nodeTypeFiles(files: readonly string[]): string[] {
return files.filter((f) => /[\\/]@types[\\/]node[\\/]/.test(f));
}
describe('UI 型検査のゲート (#1001)', () => {
it('ui のビルドは型検査を通ってからバンドルする', () => {
const build: string = readJson('ui/package.json').scripts.build;
expect(build).toContain('tsc --noEmit');
// 順序も守る: バンドルが先だと、型エラーでも成果物が出てしまう。
expect(build.indexOf('tsc --noEmit')).toBeLessThan(build.indexOf('vite build'));
});
it('型検査だけを単体で呼べる', () => {
// CI と開発者が「バンドルせずに型だけ見る」ために必要。
expect(readJson('ui/package.json').scripts.typecheck).toContain('tsc -p tsconfig.typecheck.json');
expect(readJson('package.json').scripts['typecheck:ui']).toContain('ui run typecheck');
});
it('CI に独立した型検査ステップがある(コメントアウトも不可)', () => {
// build に含まれてはいるが、独立ステップにしておくと「型で落ちた」のか
// 「バンドルで落ちた」のかがログを読まずに分かる。
const ci = readFileSync(join(repoRoot, '.gitea/workflows/ci.yml'), 'utf8');
const live = ci.split('\n').filter(line => !line.trim().startsWith('#'));
expect(live.some(line => line.includes('npm run typecheck:ui'))).toBe(true);
// ステップ単位で失敗を無視されると、赤いはずの CI が緑になる。
expect(live.some(line => line.includes('continue-on-error'))).toBe(false);
});
it('型検査の失敗を握り潰していない', () => {
// `tsc --noEmit || true` と書けば、上の 3 つはすべて通ったまま関所が
// 無力化する。実行されていることと、落ちること(=失敗が伝播すること)は別。
//
// 見るのは呼び出し連鎖の全段。ui のスクリプトだけを見ていると、CI の
// `run:` かルートの中継スクリプトに 1 つ足すだけで素通りする(実測で確認)。
const swallowed = /\|\|\s*(true|:|exit\s+0)|;\s*(true|exit\s+0)\s*$/;
const uiScripts = readJson('ui/package.json').scripts;
const rootScripts = readJson('package.json').scripts;
for (const script of [uiScripts.build, uiScripts.typecheck, rootScripts['typecheck:ui']]) {
expect(script).not.toMatch(swallowed);
expect(script).not.toContain('--noEmit false');
}
const ci = readFileSync(join(repoRoot, '.gitea/workflows/ci.yml'), 'utf8');
for (const line of ci.split('\n')) {
if (line.trim().startsWith('#') || !line.includes('typecheck:ui')) continue;
expect(line).not.toMatch(swallowed);
}
});
it('ui の TypeScript は、テストも E2E も設定ファイルも検査対象に入っている', () => {
// テスト側の型ずれ(実装のプロパティ追加にモックが追随していない等)は
// 実装と現実がずれ始めた合図。検査対象から外すと、その合図が消える。
//
// include / strict の形だけを見ると素通りする穴がある: include は "src" の
// ままで exclude に "src/**/*.test.tsx" を足すと、227 本のテストが黙って
// 検査対象から外れる。だから設定の字面ではなく、tsc が実際に組む
// ファイル一覧を見る(exclude であれ include であれ、抜けたら落ちる)。
const files = resolveUiProgram('tsconfig.typecheck.json');
const checked = files.filter((f) => /\.test\.tsx?$/.test(f));
expect(checked.length).toBeGreaterThan(0);
// 実装コードも当然入っていること(テストだけ検査しても意味がない)。
expect(files.some((f) => /\/src\/App\.tsx$/.test(f))).toBe(true);
// E2E と ui/ 直下の設定ファイル。ここが抜けていたのが元の状態で、
// 「UI に関所が無い」という本件の主題からすると範囲が中途半端だった。
expect(files.some((f) => /\/e2e\/.*\.spec\.ts$/.test(f))).toBe(true);
expect(files.some((f) => /\/e2e-auth\/.*\.spec\.ts$/.test(f))).toBe(true);
expect(files.some((f) => /\/ui\/playwright\.config\.ts$/.test(f))).toBe(true);
});
it('ビルド用の設定は出荷物だけを見る(製品ビルドを E2E とテストに依存させない)', () => {
// build は Dockerfile の npm run build:ui から呼ばれる製品ビルドの経路。
// ここに E2E を混ぜると、製品イメージのビルドが @playwright/test などの
// devDependency に依存する。検査の網は広く、ビルドの依存は狭く。
//
// テストとテスト基盤 (src/test/) も外してある (#1316)。この設定は types: [] で
// Node の型を断っており、node:fs や global を正当に使う src のテスト (15 本) とは
// 両立しない。src/test/ を外す理由はもう 1 つあって、dom-setup.ts が読む vitest は
// ui/package.json に書かれておらず、リポジトリ直下から解決されていた——直下の
// node_modules から入るファイルが 109 本から 60 本 (TypeScript の標準ライブラリ
// だけ) に減る。「外したぶんが検査されなくなる」抜け穴は下の 1 件が塞ぐ。
const files = resolveUiProgram('tsconfig.json');
expect(files.some((f) => /\/src\/App\.tsx$/.test(f))).toBe(true);
expect(files.filter((f) => /\.test\.tsx?$/.test(f))).toEqual([]);
expect(files.filter((f) => /\/src\/test\//.test(f))).toEqual([]);
expect(files.some((f) => /\/e2e(-auth)?\//.test(f))).toBe(false);
});
it('ui/src のファイルは 1 本残らず型検査用の設定に入っている (#1316)', () => {
// #1001 の元の見張りは「ビルド用の設定にテストが 1 本以上入っている」を見て
// いた。#1316 でビルド用からテストとテスト基盤を外したので、その担保をこちらへ
// 移す。数ではなくディスク上の実ファイルとの差分で見るぶん、元より強い——
// exclude を 1 行足して 1 ディレクトリ丸ごと外す、といった抜き方でも落ちるし、
// ビルド用から今後さらに何かを外しても、こちらに入っていなければ落ちる。
const onDisk = sourceFilesOnDisk();
expect(onDisk.length).toBeGreaterThan(0);
const checked = new Set(resolveUiProgram('tsconfig.typecheck.json'));
expect(onDisk.filter((f) => !checked.has(f))).toEqual([]);
});
});
describe('UI の標準ライブラリ設定 (#1314)', () => {
it('画面側の設定だけで ES2022 のメソッドが解決できる', () => {
// 親ディレクトリの @types を一切使わない条件で at() が引ける、が合格条件。
// lib の明示を消すと target (ES2020) から標準ライブラリが決まるので、ここが
// TS2550 で落ちる。
const diagnostics = diagnoseWithUiOptions(ES2022_PROBE_SOURCE);
const messages = diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '));
expect(messages).toEqual([]);
});
it('lib を ES2020 相当に戻すと、この検査は実際に落ちる', () => {
// 上の検査が「何を書いても通る空振り」でないことの担保。ここが通らなくなったら
// 検査そのものが壊れている(親の @types に頼る経路が復活した等)。
const diagnostics = diagnoseWithUiOptions(ES2022_PROBE_SOURCE, {
lib: ['lib.es2020.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
});
expect(diagnostics.map((d) => d.code)).toContain(2550);
});
it('ReadableStream を直接 for await で回せる(DOM.AsyncIterable が効いている)', () => {
// 直す前の暗黙値 lib.es2020.full は 6 枚を参照していた。lib を手で書き起こす
// ときに落ちやすいのがこの 1 枚で、落ちても既存コードは無傷のまま通る——
// 画面側のストリーム処理は getReader() 経由で、for await している 3 か所は
// 相手が非同期ジェネレータだから。次に誰かが本文を直接 for await した瞬間に
// 初めて落ちる。その「次の人」の代わりに、ここで先に踏んでおく。
const diagnostics = diagnoseWithUiOptions(DOM_ASYNC_ITERABLE_PROBE_SOURCE);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it('DOM.AsyncIterable を外すと、この検査は実際に落ちる', () => {
// 空振りの担保。1 枚だけ抜いた対照で、他は本物の設定のまま。
const diagnostics = diagnoseWithUiOptions(DOM_ASYNC_ITERABLE_PROBE_SOURCE, {
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
});
expect(diagnostics.length).toBeGreaterThan(0);
});
it('lib は tsconfig に明示されている(target 任せにしない)', () => {
// 失敗したときに「何を直せばよいか」が一行で分かるように、字面も見ておく。
const uiDir = join(repoRoot, 'ui');
const raw = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
const lib: unknown = raw.config?.compilerOptions?.lib;
expect(Array.isArray(lib)).toBe(true);
const entries = (lib as string[]).map((l) => l.toLowerCase());
expect(entries).toContain('dom');
expect(entries).toContain('dom.asynciterable');
});
});
describe('画面側に Node の型を混ぜない (#1316)', () => {
// 直す前は 3 つとも診断ゼロで通っていた。原因は個別の型エラーではなく、
// TypeScript が node_modules/@types を親ディレクトリへ遡って自動で取り込む
// 性質——ui/package.json は Node の型定義を依存に持っていないのに、リポジトリ
// 直下の @types/node がそっくり画面側の検査に入っていた。
//
// 関所は ui/tsconfig.json 側にしか置けない。ui/tsconfig.typecheck.json の側は
// e2e が better-sqlite3 を読み、その .d.ts の `/// <reference types="node" />`
// が Node の型をプログラム全体へ呼び戻すので、types をどう書いても src で
// Buffer が通ってしまう(--explainFiles で確認)。
const buildProbes: ReadonlyArray<readonly [string, string, number]> = [
['タイマーの戻り値に NodeJS.Timeout を当てる', NODE_TIMER_PROBE_SOURCE, 2503],
['環境変数を読む', NODE_PROCESS_PROBE_SOURCE, 2591],
['バッファを作る', NODE_BUFFER_PROBE_SOURCE, 2591],
];
it.each(buildProbes)('出荷物の設定では落ちる: %s', (_name, source, code) => {
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.json', source);
expect(diagnostics.map((d) => d.code)).toContain(code);
});
it('出荷物の設定でも、ブラウザにあるものは通る', () => {
// 上の 3 件が「何を書いても落ちる」空振りでないことの対照。同じ道具立てで、
// window.setTimeout と TextDecoder は診断ゼロであること。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.json', BROWSER_PROBE_SOURCE);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it.each(buildProbes)('型検査用の設定では通る: %s', (_name, source) => {
// vite.config.ts と playwright の設定、e2e、src のテストはどれも Node の上で
// 動く。こちらまで断つと、それらが落ちる。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.typecheck.json', source);
expect(diagnostics.map((d) => ts.flattenDiagnosticMessageText(d.messageText, ' '))).toEqual([]);
});
it.each(buildProbes)('型検査用の設定から types を空にすると、実際に落ちる: %s', (_name, source, code) => {
// 上の 3 件の空振り担保。types: ["node"] の 1 行が効いていることを見る。
//
// ここを本物の tsc(npx tsc -p tsconfig.typecheck.json)で確かめようとすると
// 空振りする: types を消しても、better-sqlite3 の三連スラッシュ参照が Node の
// 型を呼び戻すので通ってしまう。小片 1 つだけのプログラムなら、Node の型の
// 出どころは types しかない。
const diagnostics = diagnoseWithConfigAsWritten('tsconfig.typecheck.json', source, { types: [] });
expect(diagnostics.map((d) => d.code)).toContain(code);
});
it('出荷物の本物のプログラムに、Node の型定義が 1 本も入っていない (#1316)', () => {
// ここが**関所の本体**。上の 3 件は小片 1 本だけの小さなプログラムを組んで
// いるので、実質「設定の字面に types: [] と書いてあるか」しか見ていない。
//
// types: [] が止めるのは自動取り込みだけで、プログラム内のどれかの .d.ts が
// `/// <reference types="node" />` を書いていればそこから Node の型が入る。
// 実際に ui/src へその 1 行を書いたファイルを 1 本置くと、`cd ui && npx tsc
// --noEmit` は Buffer を診断ゼロで通し、字面を見る見張りは全部緑のままだった
// (実測)。しかも実在の経路がある: ui/src の .d.ts がビルドツールの入口を
// 参照していて、そのパッケージは別の場所ですでに Node を参照している。版が
// 上がって入口側が Node を引いた瞬間、関所は黙って開く。
//
// だから本物のプログラムを組んで、読み込まれた @types/node のファイル数を見る。
// 参照が 1 本入るだけで 0 → 63 に跳ねるので、差ははっきり出る(実測)。
const leaked = nodeTypeFiles(realProgramFiles('tsconfig.json'));
expect(leaked).toEqual([]);
});
it('型検査用の本物のプログラムには Node の型定義が入っている(上の見張りの対照)', () => {
// 上の 1 件が「どう組んでも 0 本になる空振り」でないことの担保。同じ道具立てで
// types: ["node"] の側を組むと、Node の型定義が実際に読み込まれる。
//
// 出どころも見る。ui/package.json の devDependencies に @types/node を足した
// 意味は、参照先を ui 自身の node_modules に固定すること。リポジトリ直下から
// 拾われていたら宣言は飾りなので、そこまで含めて落とす。
const loaded = nodeTypeFiles(realProgramFiles('tsconfig.typecheck.json'));
expect(loaded.length).toBeGreaterThan(0);
const uiOwned = join(repoRoot, 'ui', 'node_modules');
expect(loaded.filter((f) => !f.startsWith(uiOwned))).toEqual([]);
});
it('親の型定義の自動取り込みは、設定に書いて断ってある', () => {
// 失敗したときに「何を直せばよいか」が一行で分かるように、字面も見ておく。
const uiDir = join(repoRoot, 'ui');
const build = ts.readConfigFile(join(uiDir, 'tsconfig.json'), ts.sys.readFile);
expect(build.config?.compilerOptions?.types).toEqual([]);
const typecheck = ts.readConfigFile(join(uiDir, 'tsconfig.typecheck.json'), ts.sys.readFile);
expect(typecheck.config?.compilerOptions?.types).toEqual(['node']);
});
it('Node の型定義は ui の依存として宣言され、直下と同じ版の範囲を書いている', () => {
// 宣言しないと、参照先はリポジトリ直下の node_modules を運任せに拾うことに戻る。
//
// ここが見ているのは**範囲の文字列が一致していること**だけで、実際に入る版まで
// は見ていない。固定ファイルが直下と ui で別なので、解決される版はすでにずれて
// いる(直下 20.19.37 / ui 20.19.43)。同じ大版の中の差で、1 つのプログラムに
// 入る型定義は片方だけなので実害は無い。
//
// 大版をそろえる意味は #1314 のときとは変わった。#1314 で踏んだ「直下が 20 の
// まま ui だけ新しくなると出荷物が落ちる」形は、ui/tsconfig.json が types: [] に
// なった時点で出荷物の側からは消えている。いま残る意味は、画面側の検査とサーバー
// 側の Node の版をそろえておくこと。将来 Node を 22 に上げるとき、直下だけ上げた
// 瞬間にこのテストが落ちる——意図した動作で、「ui も一緒に上げろ」の合図。
const uiRange: unknown = readJson('ui/package.json').devDependencies['@types/node'];
const rootRange: unknown = readJson('package.json').devDependencies['@types/node'];
expect(typeof uiRange).toBe('string');
expect(uiRange).toBe(rootRange);
});
});