Между tsc и node:test: как ловить устаревшие тесты в dist

Представим обычный build на проекта на TypeScript:

src/
  example.test.ts

dist/
  example.test.js

Сначала tsc создаёт JavaScript, затем тестовый раннер запускает файлы из dist. Пока исходники и результат сборки синхронны, всё работает ожидаемо. Но если пропустить сборку после изменения теста, будет исполнен предыдущий JavaScript. Такой прогон может остаться зелёным, хотя новый TypeScript-тест в нём вообще не участвовал.

Есть и другой вариант: example.test.ts уже удалён или переименован, а старый example.test.js остался в dist и продолжает участвовать в прогоне.

Для node:test здесь нет ошибки: ему передали корректный JavaScript-файл, и он его выполнил. Связь между compiled-файлом и TypeScript-исходником находится за пределами ответственности нативного раннера.

Именно на этой границе работает fwa — небольшой preflight-слой для скомпилированных TypeScript-тестов. Он строит проверенный список файлов и только после этого передаёт его в node:test.

Но ведь node:test уже умеет находить файлы

Да. Современный node:test принимает glob-паттерны, и документация Node.js рекомендует заключать их в кавычки, чтобы шаблон раскрывал сам Node.js, а не конкретная оболочка:

node --test "dist/**/*.test.js" "dist/**/*.spec.js"

Такой команды достаточно, если проекту нужен только запуск подходящих файлов.

Его задача немного другая:

  • прочитать rootDir и outDir из TypeScript-конфига;

  • рекурсивно и в стабильном порядке найти скомпилированные тесты;

  • сопоставить JavaScript-файлы с TypeScript-исходниками;

  • не запускать осиротевший или устаревший результат сборки;

  • передать итоговый список нативному раннеру.

Само выполнение тестов, изоляция процессов и репортинг остаются задачей node:test.

Минимальная настройка

Пакет требует Node.js >=20.19.0 и устанавливается как dev dependency:

npm install --save-dev fwa

В tsconfig.json нужен только outDir:

{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": [
    "src/**/*.ts"
  ]
}

В package.json достаточно разделить сборку и тестирование:

{
  "scripts": {
    "build": "tsc",
    "test": "fwa"
  }
}

Запуск остаётся обычным:

npm run build
npm test

Проверять, пересобран ли весь production-код, он не пытается — к этому ограничению ещё вернёмся.

Что происходит перед запуском

Весь путь можно представить так:

tsconfig
   ↓
rootDir + outDir
   ↓
детерминированный поиск *.test.js и *.spec.js
   ↓
проверка TypeScript-исходников и mtime
   ↓
безопасный список файлов
   ↓
node:test

По умолчанию используется <project-root>/tsconfig.json, без поиска в родительских каталогах. Другой конфиг можно выбрать примерно так же, как в tsc --project:

fwa --project tsconfig.test.json
fwa ./packages/billing --project tsconfig.test.json

--project принимает либо сам файл, либо каталог с tsconfig.json. Путь разрешается относительно выбранного корня проекта.

Если rootDir отсутствует, корнем исходников становится каталог выбранного конфига. outDir обязателен: без него раннер не может однозначно определить, где искать emitted JavaScript.

Как обнаруживаются тесты-призраки

Для каждого обнаруженного *.test.js или *.spec.js раннер вычисляет путь относительно outDir, переносит его в rootDir и меняет расширение:

dist/payment/refund.test.js
  → src/payment/refund.test.ts

dist/payment/refund.spec.js
  → src/payment/refund.spec.ts

Поэтому относительный путь конкретного теста между rootDir и outDir должен совпадать.

После сопоставления возможны три результата:

Состояние

Обычный запуск

Запуск с --prune

Source существует и не новее compiled-файла

тест запускается

тест запускается

Source отсутствует

ошибка до node:test

compiled-файл ставится в очередь на удаление

Source новее compiled-файла

требуется пересборка

та же ошибка, удаление не начинается

Удалённый исходник по умолчанию приводит к диагностике:

Stale compiled tests without source found.

Run with --prune to remove them:
- dist/feature/old.test.js

Такое поведение намеренно консервативно. Обычный запуск тестов не должен неожиданно менять файловую систему. Удаление включается явно:

fwa --prune

Если TypeScript-файл новее JavaScript-файла, сообщение другое:

Compiled tests are older than source tests.

Rebuild before npm test:
- dist/feature/example.test.js
  (source: src/feature/example.test.ts)

Сравнение основано на mtime. Это защита от распространённого рассинхронизированного состояния, но не доказательство того, что содержимое JavaScript действительно соответствует исходнику.

Детерминированный обход вместо надежды на окружение

Файловая система не обязана возвращать содержимое каталога в удобном для нас порядке, а localeCompare() способен зависеть от локали окружения.

Поэтому fwa сортирует записи обычным сравнением строк:

function compareDeterministically(left: string, right: string): number {
  return (left > right) - (left < right);
}

Каталоги обходятся так: сначала полностью проваливаемся в один подкаталог, и только потом возвращаемся к соседнему. Это значит, что если файловая система вернёт содержимое каталога в разном порядке, fwa всё равно сам отсортирует записи и получит одинаковый результат.

Обход реализован итеративно: стек хранит текущий каталог, отсортированные записи и позицию следующего элемента. Это позволяет сохранить порядок при чередовании файлов и подкаталогов и не зависеть от глубины стека вызовов.

Читать tsconfig, не загружая чужой TypeScript

На первый взгляд проще всего импортировать Compiler API и попросить TypeScript разобрать конфигурацию. Но тогда раннер либо зависит от собственной версии TypeScript, либо загружает TypeScript из проекта пользователя и начинает зависеть от установленной там версии компилятора.

В fwa выбран более узкий вариант. Зависимости get-tsconfig и jsonc-parser читают JSONC, разрешают цепочку extends и извлекают только поля, которые нужны раннеру. Поэтому fwa не загружает typescript из проекта и не ограничивает выбранную там версию компилятора.

node:test остаётся исполнителем

По умолчанию каждый тестовый файл выполняется с process isolation. При необходимости его можно отключить:

fwa --isolation none

А аргументы для изолированных тестовых процессов можно передать отдельно:

fwa --node-args --no-warnings --conditions=development

Здесь fwa ограничен возможностями нативного API: явный --isolation требует Node.js >=22.8.0, --node-args — >=22.10.0; кроме того, --node-args должен быть последней опцией и не сочетается с --isolation none.

Сам раннер использует тот же executable Node.js, которым был запущен. Другую версию runtime он не скачивает и не выбирает, поэтому compatibility matrix проекта всё равно остаётся задачей CI.

Вместо вывода

fwa не пытается стать ещё одной тестовой экосистемой. Его задача заканчивается там, где начинается node:test: найти compiled JavaScript-тесты, проверить их связь с TypeScript-исходниками и передать нативному раннеру явный список файлов.

Потому что самое неприятное в stale-тесте — не ошибка.

Самое неприятное — зелёный результат от кода, который уже не соответствует тому, что разработчик видит в исходниках.

Ссылки