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

от автора

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

src/  order.test.tsdist/  order.test.js

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

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

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

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

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

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

Такой команды достаточно, если проекту нужен только запуск подходящих файлов. Утверждать, что fwa существует из-за отсутствия glob в Node.js, было бы неверно. Его дополнительный контракт в другом:

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

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

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

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

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

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

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

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

npm install --save-dev fwa

В tsconfig.json нужен outDir. Явный rootDir не обязателен, но делает сопоставление исходников и результата сборки очевидным:

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

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

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

Запуск остаётся предельно обычным:

npm run buildnpm test

Важно: fwa не вызывает TypeScript-компилятор и проверяет только найденные compiled-тесты. Он сообщит об отсутствующем outDir, compiled-тесте без source или compiled-тесте, который старее source. Отдельно искать source без compiled и определять, пересобран ли остальной production-код, раннер не может.

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

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

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

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

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

Путь, переданный в --project, разрешается относительно выбранного корня проекта, а rootDir и outDir — по правилам цепочки TypeScript-конфигов относительно объявивших их файлов. Если rootDir отсутствует, корнем исходников становится каталог выбранного конфига. outDir обязателен: без него раннер не может однозначно определить, где искать скомпилированные тесты.

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

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

dist/payment/refund.test.js  → src/payment/refund.test.tsdist/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. Это простая защита от распространённой ошибки, но не доказательство идентичности исходника и результата компиляции. К этому ограничению ещё вернёмся.

Почему —prune не просто вызывает unlink

Опция удаления требует отдельной защиты: ошибочно разрешённый outDir не должен превратить test runner в команду очистки произвольного каталога.

Перед первым удалением fwa:

  1. получает реальные пути projectDir и outDir через realpath;

  2. проверяет, что реальный путь outDir — строгий потомок реального пути проекта;

  3. отклоняет корень проекта, внешний каталог и symlink на внешний каталог;

  4. проверяет весь оставшийся набор тестов на свежесть;

  5. только затем удаляет файлы без исходников.

Последовательность важна. Допустим, найден один осиротевший тест и один JavaScript-файл, который старее своего source. В этом случае --prune сначала сообщит о необходимости пересборки и не удалит осиротевший файл. Пользователь не получит частично изменённый dist только потому, что проблема обнаружилась в конце обхода.

Это не файловая транзакция: отдельный unlink всё ещё может завершиться ошибкой. Гарантия уже и понятнее — вся логическая валидация заканчивается до первой операции удаления.

Если после prune не осталось ни одного теста, очистка считается выполненной. CLI печатает сообщение о пустом наборе и устанавливает код завершения 1, а асинхронный API возвращает status: "empty" и exitCode: 1.

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

Файловая система не обязана возвращать содержимое каталога в удобном для нас порядке, а localeCompare способен зависеть от локали окружения. Поэтому fwa сортирует записи обычным сравнением строк по code units:

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

Каталоги обходятся depth-first, а готовый список передаётся в node:test явно. В результате порядок discovery-списка и собственных preflight-диагностик не зависит от порядка, полученного от файловой системы, или от локали контейнера. Сам node:test выполняет файлы конкурентно, поэтому порядок runtime-событий из этого не следует.

Сам обход реализован итеративно. Стек хранит текущий каталог, отсортированные записи и позицию следующего элемента. Это сохраняет depth-first-порядок при чередовании файлов и подкаталогов, не полагается на глубину стека вызовов и не создаёт цепочку промежуточных массивов для последующего ...spread.

Здесь нет обещания «быстрее любого glob»: в проекте нет benchmark, который подтверждал бы такое сравнение. Инвариант другой — один и тот же вход должен давать один и тот же порядок.

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

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

В fwa выбран более узкий контракт. Runtime-зависимости get-tsconfig и jsonc-parser читают JSONC, разрешают цепочку extends и извлекают только поля, которыми владеет раннер:

extendscompilerOptions.rootDircompilerOptions.outDir

include, target, проверка типов и граф исходных файлов остаются в зоне ответственности сборки. Благодаря этому fwa не объявляет peer dependency на TypeScript, не загружает typescript из проекта-потребителя и не ограничивает выбранную там версию компилятора.

У решения есть цена. Адаптер использует файловый cache get-tsconfig, чтобы перед разбором оставить в конфигурации только принадлежащие раннеру поля. Формат ключа этого cache не является публичным API зависимости, поэтому версия get-tsconfig закреплена точно. Это осознанный локальный компромисс: меньше связности с toolchain пользователя в обмен на более чувствительную внутреннюю интеграцию с небольшой библиотекой.

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

После preflight раннер собирает опции программного API Node.js. Упрощённо это выглядит так:

import { run, type RunOptions } from 'node:test';const runOptions: RunOptions = {  files: testFiles,  concurrency: true};if (supportsNodeTestIsolation()) {  runOptions.isolation = isolation;}if (nodeArgs.length > 0) {  runOptions.execArgv = nodeArgs;}run(runOptions);

Явно выбранный неподдерживаемый режим отклоняется с диагностикой до запуска. CLI и совместимый runSuite() направляют результат в нативный spec reporter. Асинхронный API подключает reporter, только если ему передан output. По умолчанию каждый тестовый файл исполняется с process isolation. Дополнительные CLI-возможности выглядят так:

# Выполнение без process isolationfwa --isolation none# Флаги для изолированных тестовых процессовfwa --node-args --no-warnings --conditions=development

У двух последних опций есть ограничения нативного API:

  • явный --isolation требует Node.js >=22.8.0;

  • --node-args требует Node.js >=22.10.0;

  • --node-args должен быть последней опцией fwa;

  • --node-args нельзя сочетать с --isolation none.

fwa использует тот же исполняемый файл Node.js, которым запущен сам. Он не скачивает и не выбирает другую версию runtime, поэтому матрица совместимости проекта по-прежнему должна проверяться в CI.

Не только CLI: API для оркестраторов

Самый короткий программный вызов выглядит так:

import { runSuiteAsync } from 'fwa';const result = await runSuiteAsync({  projectDir: process.cwd(),  output: process.stdout});process.exitCode = result.exitCode;

Асинхронный API не меняет process.exitCode самостоятельно. Reporter output выключен, пока вызывающий код не передаст output; подготовительные сообщения можно отдельно получить через log. Результат содержит:

  • status: passed, failed или empty;

  • exitCode: 0 или 1;

  • список файлов подготовленного плана, переданных runner;

  • счётчики suites, tests, passed, failed, skipped, todo и cancelled;

  • длительность выполнения.

Переданный output остаётся собственностью вызывающего кода: fwa ждёт завершения записей reporter и их ошибок, но не закрывает stream.

Ошибки конфигурации и preflight отклоняют Promise. Обычное падение теста, наоборот, разрешает Promise с status: "failed" — оркестратор может сам решить, останавливать ли весь запуск.

Для более сложного сценария подготовка и исполнение разделены:

import {  prepareSuite,  runPreparedSuite} from 'fwa';const plan = prepareSuite({  projectDir: process.cwd()});console.info('Будут запущены:', plan.testFiles);const result = await runPreparedSuite(plan, {  onEvent: (event) => {    if (event.type === 'summary') {      console.info(event.data);    }  }});

prepareSuite() разрешает конфиг, находит файлы и проверяет stale output, но не запускает тесты и не меняет глобальный exit code. При prune: true сама подготовка всё же может удалить осиротевшие compiled-файлы. runPreparedSuite() исполняет уже подготовленный снимок. Между этими вызовами список не пересчитывается — это важно учитывать, если файловая система может меняться параллельно.

Тип события нормализован до pass, fail, summary, stdout или stderr. Для нативных событий data сохраняет payload node:test. В зависимости от Node.js может прийти несколько summary; если runtime не создаёт нативный итог, fwa синтезирует один совместимый cumulative summary. Отмена через AbortSignal возвращает неуспешный результат с cancelled-счётчиками, а ошибки самого runner, reporter, output stream или пользовательского onEvent отклоняют Promise.

Для обратной совместимости остаётся runSuite(). Он подключает stream, возвращает void и отражает пустой набор, тестовые и потоковые ошибки через process.exitCode. Ошибки конфигурации и preflight прямой библиотечный вызов выбрасывает синхронно; CLI перехватывает их, печатает сообщение и также устанавливает код 1. Ждать завершения через await runSuite() нельзя; для нового orchestration-кода предназначен асинхронный API.

Границы внутри проекта

Bootstrap-слой зависит и от application, и от infrastructure: первый задаёт порядок prepare/run и контракты эффектов, вторая реализует работу с fs, tsconfig и node:test. Application-код напрямую не импортирует эти инфраструктурные API — bootstrap внедряет их при сборке сценария. Благодаря этому CLI может владеть stdout и exit code, а асинхронный API оставляет эти решения вызывающему коду.

Где проходит граница применимости

fwa намеренно решает узкую задачу, поэтому несколько ограничений важнее списка возможностей:

  • поддерживаются только пары .test.ts → .test.js и .spec.ts → .spec.js;

  • относительный путь между rootDir и outDir должен сохраняться;

  • source-тесты без соответствующего compiled-файла отдельно не перечисляются;

  • freshness-check сравнивает существование и mtime, но не хеш содержимого;

  • изменение обычного production-source не проверяется — проверка касается только самих тестовых файлов;

  • concurrency: true включён постоянно и не настраивается через публичный API;

  • синхронная подготовка блокирует вызывающий поток на время обхода файлов;

  • --prune требует, чтобы реальный путь outDir был строгим потомком реального пути проекта, и действительно удаляет файлы;

  • projectDir задаёт поиск конфига и тестов, но не вызывает process.chdir и не подменяет environment для отдельных suite.

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

Инструмент подходит, если проект:

  • компилирует TypeScript в отдельный outDir;

  • использует нативный node:test;

  • хочет запускать именно emitted JavaScript;

  • нуждается в проверке связи между source и compiled-тестами;

  • или собирает несколько suite через программный API.

В Node.js 22.6 появился ещё один вариант — запуск .ts через встроенный type stripping; с версии 22.18 он включён по умолчанию. Это не часть всей поддерживаемой fwa матрицы и у режима другой контракт: tsconfig.json не учитывается, а без дополнительной трансформации поддерживается только стираемый синтаксис.

Дополнительный слой может оказаться лишним, если проект запускает TypeScript напрямую или гарантированно пересоздаёт output с нуля. А когда нужен полноценный framework со своей трансформацией, watch mode и DSL, fwa просто решает задачу из другой категории.

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

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

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

Ссылки:

ссылка на оригинал статьи https://habr.com/ru/articles/1066056/