Представим обычный 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 должен совпадать. Остальные файлы раннер игнорирует. После сопоставления возможны три результата:
|
Состояние |
Обычный запуск |
Запуск с |
|---|---|---|
|
Source существует и не новее compiled-файла |
тест запускается |
тест запускается |
|
Source отсутствует |
ошибка до |
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:
-
получает реальные пути
projectDirиoutDirчерезrealpath; -
проверяет, что реальный путь
outDir— строгий потомок реального пути проекта; -
отклоняет корень проекта, внешний каталог и symlink на внешний каталог;
-
проверяет весь оставшийся набор тестов на свежесть;
-
только затем удаляет файлы без исходников.
Последовательность важна. Допустим, найден один осиротевший тест и один 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/