// 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): Promise {', ' 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 の `/// ` // が Node の型をプログラム全体へ呼び戻すので、types をどう書いても src で // Buffer が通ってしまう(--explainFiles で確認)。 const buildProbes: ReadonlyArray = [ ['タイマーの戻り値に 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 が // `/// ` を書いていればそこから 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); }); });