446 lines
26 KiB
TypeScript
446 lines
26 KiB
TypeScript
// 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);
|
||
});
|
||
});
|