Перейти к содержимому

Кодмод ​

codemod переводит тесты с jest-auto-spies и Jest или с jasmine-auto-spies и jasmine на Vitest и эту библиотеку. Он переписывает то, что может решить безопасно, всё остальное оставляет ровно как было и перечисляет каждое место, которое нужно переписать руками.

bash
npx vitest-auto-spy codemod            # предпросмотр: печатает дифф, ничего не пишет
npx vitest-auto-spy codemod --write    # применить
npx vitest-auto-spy codemod --verify   # проверить, что переводить больше нечего

Почему не поиск с заменой? Потому что несколько простых переименований компилируются и делают обратное задуманному:

  • jest.Mock<void, [Order]> → Mock<void, [Order]> компилируется, но значит обратное: у Jest первым идёт тип возврата, у Vitest — аргументы.
  • jest.requireMock, переименованный в vi.requireMock, падает с TypeError: vi.requireMock is not a function.
  • spyOn(o, 'm') из jasmine, переименованный в vi.spyOn(o, 'm'), начинает вызывать настоящий метод: jasmine его заглушает, а Vitest вызывает.
  • Если перенести все импорты jest-auto-spies в корневой vitest-auto-spy, сломается provideAutoSpy: корневая точка входа его не экспортирует.

Пошаговое руководство по переходу — Переход с jest-auto-spies; для jasmine — Переход с jasmine-auto-spies.

Перевести тесты ​

  1. Посмотрите. Запустите npx vitest-auto-spy codemod и прочитайте дифф. Ничего не пишется.
  2. Примените. Запустите ещё раз с --write.
  3. Доделайте оставленное. Каждая строка error и warn в отчёте называет path:line и говорит, что делать.
  4. Проверьте. npx vitest-auto-spy codemod --verify выходит с кодом 0 и сообщением Nothing left to migrate., когда всё сделано. Оставьте его в CI.
  5. Проверьте, что оставил переход. npx vitest-auto-spy doctor сообщит об include-паттерне в tsconfig, который сломала миграция, о jest.config.ts для раннера, которого уже нет, и о setup-файлах, которыми пользовался только этот конфиг.

По одной директории. Передайте директорию каждой команде:

bash
npx vitest-auto-spy codemod src/app            # предпросмотр
npx vitest-auto-spy codemod src/app --write    # применить
npx vitest-auto-spy codemod src/app --verify   # проверить

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

Как читать отчёт ​

$ npx vitest-auto-spy codemod
vitest-auto-spy codemod — /work/app
Dry run — nothing is written. Re-run with --write to apply.


src/app/service.spec.ts
  auto-spies-import         1 edit
  inject-cast               1 edit
  jest-namespace            2 edits
  jest-types                1 edit
  mock-implementation-arity 1 edit
    import { createSpyFromClass, Spy, asSpy } from 'vitest-auto-spy';
    import { provideAutoSpy } from 'vitest-auto-spy/angular';
    import type { Mock } from 'vitest';

--- a/src/app/service.spec.ts
+++ b/src/app/service.spec.ts
@@ -1,16 +1,18 @@
-import { createSpyFromClass, provideAutoSpy, Spy } from 'jest-auto-spies';
+import { createSpyFromClass, Spy, asSpy } from 'vitest-auto-spy';
+import { provideAutoSpy } from 'vitest-auto-spy/angular';
+import type { Mock } from 'vitest';

 import { Service } from './service';

 describe('Service', () => {
   let service: Spy<Service>;
-  let hook: jest.Mock<void, [Service]>;
+  let hook: Mock<(arg0: Service) => void>;

   beforeEach(() => {
-    service = TestBed.inject(Service) as Spy<Service>;
-    jest.spyOn(service, 'load').mockImplementation();
+    service = asSpy<Service>(TestBed.inject(Service));
+    vi.spyOn(service, 'load').mockImplementation(() => undefined);
     jest.requireActual('./service');
-    hook = jest.fn();
+    hook = vi.fn();
   });
 });

1 file would change, 6 edits

error  residue/jest-namespace src/app/service.spec.ts:12
       Still matches after the run: "jest."
       → `jest-namespace` declined it: `jest.requireActual` was left alone. Rewrite it by hand.
       Docs: https://asdalexey.github.io/vitest-auto-spy/utilities/codemod#verifying-by-matching-not-by-diffing

warn   no-vi-twin src/app/service.spec.ts:12
       `jest.requireActual` was left alone.
       → `vi.importActual(id)` is asynchronous and only legal inside a `vi.mock` factory; rewriting
         it changes control flow.

1 error, 1 warning, 0 notes

Для каждого изменённого файла отчёт показывает:

  1. Правки по трансформациям — сколько раз сработала каждая.
  2. Итоговые инструкции импорта целиком. Смотрите их первыми: они показывают, из какой точки входа теперь приходит каждое имя.
  3. Дифф.

Дальше идут находки. Об одной строке может быть две: error говорит, что работа не закончена, а warn — почему кодмод её не сделал. Чинить нужно саму строку. Остаток — это текст, которого после перехода быть не должно: jest., from 'jest-auto-spies', as Spy<. Здесь одна строка, jest.requireActual, осталась как была, поэтому прогон выходит с кодом 1: работа для вас ещё есть. С шестью правками всё в порядке.

Остаток, в котором названа трансформация («jest-namespace declined it»), оставлен сознательно; дальше идёт причина. Остаток без такой пометки стоит там, куда не дотянулась ни одна трансформация: внутри шаблонного литерала или после несбалансированных скобок.

КодКогда
0Всё переписано, остатков нет
1Что-то оставлено вам на ручную правку или остаток всё ещё на месте — в том числе под --verify
2Ничего не запускалось: неизвестный id трансформации в --only / --skip, неизвестный флаг, --format markdown или путь без единого файла

Какие файлы он обходит ​

  • Без пути — все *.spec.* и *.test.* в репозитории, включая JavaScript. jest. в main.ts — это код приложения, а не тест, и прогон без пути его не трогает.
  • С путём — все исходники под ним, кроме файлов деклараций.
  • Файл, в котором выбранным трансформациям нечего менять, в отчёт не попадает, так что в выводе только файлы, которым есть что сказать.
  • Путь, под который не подходит ни один файл, — ошибка, код 2, и ничего не читается. Опечатка в строке CI не должна выглядеть как чистый результат. Пути считаются от --cwd. Подходят абсолютный путь, префикс ./, завершающий слэш и разделитель Windows; . — корень репозитория.

Выберите диалект через --from ​

bash
npx vitest-auto-spy codemod --from jasmine   # или jasmine-auto-spies
npx vitest-auto-spy codemod --from jest-auto-spies
npx vitest-auto-spy codemod                  # --from auto, по умолчанию

auto читает каждый файл и решает сам. Файл с членом jasmine., с .and., с импортом jasmine-auto-spies или vitest-auto-spy/jasmine получает трансформации jasmine; остальные — нет. Четыре общие трансформации работают всегда.

Если ваши jasmine-тесты используют только spyOn, передайте --from jasmine. Голый spyOn( — не признак jasmine: оба диалекта пишут его одинаково, а поведение по умолчанию у них противоположное (jasmine заглушает, Vitest вызывает настоящий метод). Если угадать неверно, spyOn молча начнёт вызывать настоящий метод.

Неизвестное значение — ошибка со списком допустимых, а не молчаливый откат.

Тринадцать трансформаций ​

У каждой трансформации есть id, который можно передать в --only или --skip. Все они читают исходный файл до того, как применится хоть одна правка, поэтому --skip убирает правки одной трансформации и не влияет на остальные.

Четыре общие, потому что конструкция есть в обоих диалектах:

IdПереписываетОставляет как есть
auto-spies-importimport … from 'jest-auto-spies' / 'jasmine-auto-spies' → точки входа, которые экспортируют каждое имяИмпорт по умолчанию или namespace-импорт; имя, которое не экспортирует ни одна точка входа
inject-castTestBed.inject(X) as Spy<X> → asSpy<X>(TestBed.inject(X)) с добавлением импортаas Spy<T> над чем-либо другим
jasmine-aliasesxit / xdescribe / fit / fdescribe / xtest → it.skip / describe.skip / …Имя, которое не вызывается, — shape.fit(box), function fit(…) {
mock-implementation-aritymockImplementation() без аргумента → mockImplementation(() => undefined)Вызов, у которого функция уже есть

Три — из Jest:

IdПереписываетОставляет как есть
jest-globals-importfrom '@jest/globals' → from 'vitest', переименовывая привязку jest в viОстальное в импорте — describe / it / expect называются так же
jest-namespacejest.<member> → vi.<member> для 26 методов, у которых есть аналог в viМетод без аналога и любой метод, которого она не знает
jest-typesjest.Mock<R, [A]> → Mock<(a: A) => R> плюс четыре переименования, с импортом имени из VitestСписок аргументов типа, который не удаётся разделить на верхнем уровне; jest из @jest/globals — его только переименовывают

Шесть — из jasmine. От чего защищает каждая — в Переходе с jasmine-auto-spies:

IdПереписываетОставляет как есть
jasmine-and-helpersspy.load.and.nextWith(v) → spy.load.nextWith(v) — десять хелперов auto-spies теряют пространство имёнНичего; список закрыт
jasmine-strategies.and.returnValue / callFake / stub / throwError / returnValues / resolveTo → двойник mock*, а .withArgs( → .calledWith(.and.callThrough(), которому некуда вызывать; незнакомую стратегию; .withArgs( в цепочке spyOn
jasmine-spy-onspyOn(o, 'm') → vi.spyOn(o, 'm').mockImplementation(() => undefined), а spyOnProperty(o, 'p') получает 'get', который jasmine подставлял самВызов, в цепочке которого уже есть стратегия — там заглушка лишняя, — и вызов, который продолжается в .withArgs(
jasmine-globalsjasmine.createSpy / createSpyObj / any / clock() / addMatchers → их двойники в vi, expect и vitest-auto-spygetEnv, truthy / falsy / empty / notEmpty, DEFAULT_TIMEOUT_INTERVAL, стратегии спаев
jasmine-typesjasmine.Spy → Mock из vitest, jasmine.SpyObj<T> → Spy<T> из этого пакетаОба имени, если таблицу точек входа не удалось построить
jasmine-matcherstoBeTrue / toBeFalse / toHaveSize / toHaveBeenCalledOnceWith / withContext / fail → их написание в Vitestfail, который не вызывается; withContext у пустого expect()

auto-spies-import идёт первым: он создаёт импорт vitest-auto-spy, в который inject-cast потом добавляет asSpy. Порядок остальных не важен.

Несколько правок, которые стоит знать:

  • jest.dontMock становится vi.doUnmock, а jest.SpyInstance — MockInstance.
  • vi.mock() с относительным путём переименовывается, а потом о нём выдаётся предупреждение. Под тестовым билдером, который собирает код в бандл (например, @angular/build:unit-test), этого модуля отдельно уже нет, и мок молча ничего не делает. Вместо этого дайте настоящий шов: передавайте подмену явно, через DI или аргумент.
  • .and.throwError(x) из jasmine превращает любую строку в Error. Поэтому переменная становится throw typeof x === 'string' ? new Error(x) : x, а не голым throw x: иначе соседний toThrow(Error) упал бы. Строковый литерал становится простым new Error('…'). Аргумент, который проверке пришлось бы вычислить дважды, остаётся как был.
  • jest.Mock<R, [A]> не переименовывается, а собирается заново: см. Ловушка: у Jest тип возврата стоит первым.

Предупреждения, которые может выдать переписывание ​

Кроме фрагментов, которые кодмод оставляет как есть, он предупреждает там, где уже сделанная правка требует второго взгляда:

ПроверкаЧто значит и что делать
overlapping-editДве правки претендуют на один фрагмент. Правка, которую называет предупреждение, не применена и не входит в число правок; другая есть в диффе. Проверьте строку руками
name-declared-locallyПравке нужно имя — asSpy, Mock, … — которое файл объявляет сам (const, let, var, function, class, enum, interface, type, namespace), поэтому импорт не добавлен. Переименуйте локальную привязку и импортируйте имя из библиотеки. Деструктурированная привязка не распознаётся — такие места проверьте руками
fake-timers-optionВ vi.useFakeTimers передана опция только из Jest, и Vitest молча её игнорирует. По предупреждению на опцию, с аналогом в Vitest: advanceTimers → shouldAdvanceTime (с advanceTimeDelta для числа), doNotFake → toFake с обратным списком, legacyFakeTimers → ничего, timerLimit → loopLimit
vi-without-globalsФайл использует vi без импорта, а конфиг Vitest не задаёт test.globals: true, так что тесты бросят ReferenceError: vi is not defined. Импортируйте vi, describe, it и expect из vitest или включите globals

vi-without-globals — заметка, а не добавленный import { vi }: при выключенных globals нет и describe, it и expect, и импорт одного vi тесты не починит. globals читается из каждого vitest.config.*, vite.config.* и vitest.workspace.*, которые находит сканирование. angular.json с @angular/build:unit-test считается включённым (билдер включает globals), другие раннеры не оцениваются.

Что он сознательно оставляет как есть ​

У фрагментов ниже есть очевидная правка, которая компилируется, но неверна. Поэтому кодмод оставляет их как есть и печатает каждый с причиной и path:line, по которому редактор сразу откроет место.

Оставлено как естьПочему и что написать вместо
jest.requireActual(id)vi.importActual(id) асинхронный и допустим только внутри фабрики vi.mock. Перепишите руками, как показано под таблицей
jest.requireMock(id), jest.setMock(…)Двойника в vi нет. Передайте подмену через TestBed или DI-контейнер либо аргументом
jest.replaceProperty(o, k, v)Используйте mockValueProp(o, k, v) из этого пакета — другой хелпер со своим восстановлением
jest.setTimeout(n)Используйте опцию конфига testTimeout или vi.setConfig({ testTimeout: n })
enableAutomock, createMockFromModule, now, retryTimes, runAllTicks, …Члена vi с таким именем нет, и каждому нужен свой подход. Перепишите руками
Любой незнакомый jest.<member>Не переименовывается: кодмод не предполагает, что в vi есть такой же метод. Из такого предположения и получается vi.requireMock is not a function
as Spy<T> над чем угодно, кроме TestBed.inject(...)Если значение и правда спай — asSpy(...); если это самодельная подмена, её построит createAutoMock<T>()
Импорт по умолчанию или namespace-импорт легаси-пакетаХелперы живут за разными точками входа, и одно пространство имён не может покрыть их все
Всё внутри шаблонного литералаПаттерны сопоставляются с маскированным видом, где шаблонный литерал пуст
Любой фрагмент с несбалансированными скобкамиНепонятно, где кончается выражение; если угадать, правка может захватить только половину типа
.withArgs(…) в цепочке spyOn(…)У vi.spyOn нет calledWith, так что переименование падает с calledWith is not a function. Сообщается как jasmine-with-args-on-spy-on; spyOn под ним тоже остаётся как был
.and.throwError(build())В переписанном коде аргумент стоит дважды, и выражение выполнилось бы дважды. Для err или state.err это нормально. Отклоняется с unknown-jasmine-strategy
expect() без аргументаНет субъекта, рядом с которым можно поставить сообщение withContext
fail, fit, xit и их родня там, где это не вызовыМетод или функция с тем же именем, которую переименование превратило бы в синтаксическую ошибку. --verify объявления тоже не сообщает

Как переписать jest.requireActual руками. Если он собирает частичный мок, перенесите его в фабрику vi.mock и дождитесь через await. Если спеке нужен просто настоящий модуль, импортируйте его обычным импортом.

ts
// было: jest.mock('./x', () => ({ ...jest.requireActual('./x'), load: jest.fn() }));
vi.mock('./x', async () => ({ ...(await vi.importActual<typeof import('./x')>('./x')), load: vi.fn() }));

Проверка сопоставлением, а не диффом ​

--verify ищет в файлах то, что переход должен был убрать, и сообщает о каждом совпадении с file:line. Если кодмод прислал вас сюда, откройте эту строку и перепишите руками то, что цитирует сообщение. Сообщение говорит, какая трансформация отказалась и почему.

$ npx vitest-auto-spy codemod --verify
vitest-auto-spy codemod — /work/app
1 files matched against 7 transform patterns.

error  residue/auto-spies-import src/app/service.spec.ts:1
       Still matches after the run: "from 'jest-auto-spies'"
       → `auto-spies-import` did not rewrite this. Rewrite it by hand.
       Docs: https://asdalexey.github.io/vitest-auto-spy/utilities/codemod#verifying-by-matching-not-by-diffing

error  residue/inject-cast src/app/service.spec.ts:10
       Still matches after the run: "as Spy<"
       → `inject-cast` did not rewrite this. Rewrite it by hand.
       Docs: https://asdalexey.github.io/vitest-auto-spy/utilities/codemod#verifying-by-matching-not-by-diffing

error  residue/jest-types src/app/service.spec.ts:7
       Still matches after the run: "jest.Mock"
       → `jest-types` did not rewrite this. Rewrite it by hand.
       Docs: https://asdalexey.github.io/vitest-auto-spy/utilities/codemod#verifying-by-matching-not-by-diffing

Каждая трансформация объявляет паттерн остатка — то, что она должна убрать. --verify ничего не преобразует и только сопоставляет эти паттерны. Строка под заголовком вывода считает паттерны трансформаций, выбранных для этих тестов, — выше это четыре общие и три из Jest. Отсюда два свойства, которых нет у проверки по диффу:

  • Она работает на файлах, которых кодмод не касался: спека, переведённая руками, спека, которую правил кто-то другой, фрагмент, исключённый через --skip. На переведённом репозитории она выходит с кодом 0 и Nothing left to migrate., а с кодом 1 — как только вернётся jest., так что её можно держать в CI.
  • Она ловит дифф, который выглядит правильно, но работа не закончена. Трансформации не заходят внутрь шаблонного литерала и за несбалансированные скобки. Остальной дифф верен, а в файле всё ещё есть jest..

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

Флаги ​

ФлагЧто делает
(нет)Сухой прогон. Печатает дифф, правки по трансформациям, итоговые импорты и отчёт. Ничего не пишет
--writeПрименить правки, каждый файл через временный файл и переименование. Оставленное остаётся как было и по-прежнему сообщается
--verifyНичего не преобразовывать; сопоставить файлы с паттернами остатков. Код 1, если что-то совпало
--from <pkg>Какой это диалект: jest-auto-spies, jasmine-auto-spies (псевдоним jasmine) или auto (по умолчанию)
--only <ids>Запустить только эти трансформации, через запятую
--skip <ids>Запустить все, кроме этих
--listНапечатать трансформации и сгенерированную таблицу точек входа и выйти с кодом 0
--cwd <dir>Работать в другой директории
--format <f>text (по умолчанию) или json: один JSON-документ в stdout вместо текстового отчёта

Id, которого не знают --only или --skip, даёт код 2 и список известных id, а не запуск всего подряд. Флаг, которого нет в таблице, тоже даёт код 2 — до того, как что-то прочитано, — и подсказывает, какой, скорее всего, имелся в виду: codemod --wirte не должен выглядеть как сухой прогон, который случайно ничего не записал.

--list показывает, что сделает прогон, ничего не делая. Трансформация, которую прогон пропустит, помечена - в левой колонке, так что --list --skip jest-types отвечает на вопрос «что именно я сейчас запущу». Ещё он печатает таблицу точек входа:

$ npx vitest-auto-spy codemod --list
Transforms
    auto-spies-import         import … from 'jest-auto-spies' → the vitest-auto-spy entry points that export each name.
    inject-cast               TestBed.inject(X) as Spy<X> → asSpy<X>(TestBed.inject(X)), adding the import.
    …

Entry-point table (/work/app/node_modules/vitest-auto-spy)
  createSpyFromClass            vitest-auto-spy, vitest-auto-spy/bun, vitest-auto-spy/bun-angular, vitest-auto-spy/node, …
  injectSpy                     vitest-auto-spy/bun-angular, vitest-auto-spy/angular, vitest-auto-spy/nestjs
  provideAutoSpy                vitest-auto-spy/bun-angular, vitest-auto-spy/angular, vitest-auto-spy/nestjs, vitest-auto-spy/vue
  renderShallow                 vitest-auto-spy/bun-angular, vitest-auto-spy/angular

--format json для CI ​

--format json печатает один JSON-документ вместо текстового отчёта — чтобы шаг CI его читал:

bash
npx vitest-auto-spy codemod --verify --format json > codemod.json
  • Прогон: schema, command, version, cwd, run (dry-run, write или verify), exitCode, tally, findings и files — у каждого file, changed, edits, fired (правки по трансформациям), итоговые imports и unified-дифф diff.
  • С --list: трансформации (id, family, summary, selected) и таблица точек входа (entries).

Код выхода тот же, что у текстового прогона. --format markdown, который принимают doctor и perf, отклоняется с кодом 2.

Когда сканирование упирается в предел ​

Сканирование файлов останавливается на 50 000 файлов. Дальше кодмод пишет об этом в stderr, а не называет репозиторий переведённым:

The repository scan stopped at its safety cap of 50000 files — part of the tree was never looked at,
so a clean result here is not a migrated repository. Raise the cap with VITEST_AUTO_SPY_SCAN_CAP, or
pass the directories to migrate as arguments: `npx vitest-auto-spy codemod src/app`.

Обычно лучше передать директории для перевода аргументами: медленная часть — трансформации, а не сканирование. Поднять предел:

bash
VITEST_AUTO_SPY_SCAN_CAP=200000 npx vitest-auto-spy codemod --verify

Сканирование не заходит в директорию, которая сама является репозиторием, — git worktree (у которого .git — файл) или вложенный клон. Эти файлы принадлежат другой ветке, и --write не должен их переписывать.

В CI ​

yaml
- run: npx vitest-auto-spy codemod --verify

Пока вы переводите по одной директории, проверяйте только готовые: npx vitest-auto-spy codemod src/app --verify.

Одна строка, без сети, конфига и токена. На переведённом репозитории она проходит и ничего не меняет. Она падает, когда возвращается jest., легаси-импорт или приведение as Spy<…>, — а в больших тестах такое возвращается.

Подробнее ​

Ловушка: у Jest тип возврата стоит первым ​

diff
- let hook: jest.Mock<void, [Order]>;
+ let hook: Mock<(arg0: Order) => void>;

Первый аргумент типа у Jest — тип возврата, второй — кортеж аргументов. Единственный аргумент типа у Vitest — сигнатура вызова. Если просто переименовать и оставить аргументы типа на месте, код скомпилируется, но будет означать обратное, а ошибка всплывёт позже — там, где мок вызывают.

Поэтому jest-types собирает тип заново, а не переименовывает:

  • => при поиске скобок пропускается целиком. Иначе jest.Mock<() => void, []> «закрылся» бы на > стрелки, и правка забрала бы полтипа.
  • Элементы кортежа сохраняют метки, необязательность и rest.[id: number, force?: boolean, ...rest: string[]] становится (id: number, force?: boolean, ...rest: string[]). Элемент без метки получает имя arg0, arg1: у функционального типа параметры должны быть названы.
  • Всё, что не литерал кортежа, разворачивается через spread. jest.Mock<void, Args> становится Mock<(...args: Args) => void>, и это верно для любого Args.
  • Три и больше аргументов типа или несбалансированные <…> дают заметку jest-mock-type-arguments и никакой правки. Строка остаётся байт в байт такой же. Перепишите её руками в форме выше.

jest.Mock без аргументов типа становится просто Mock: на обеих сторонах он значит одно и то же.

Файл, который импортирует jest из @jest/globals, сохраняет аргументы типа. Этот jest — из jest-mock 29 и новее, где jest.Mock уже принимает тип функции (jest.Mock<() => string>), как и в Vitest. Поэтому меняется только имя. Перестановка дала бы Mock<() => () => string> — мок, который возвращает функцию.

Аргументы типа у вызова не переписываются, а сообщаются. jest.fn<R, [A]>() и jest.spyOn<…>() в написании @types/jest тоже ставят тип возврата первым. jest-namespace переименовывает функцию в vi.fn / vi.spyOn, где единственный аргумент типа — сигнатура вызова, поэтому jest-mock-type-arguments называет этот список и оставляет его вам — опять же только в файле, чей jest не из @jest/globals.

Таблица точек входа генерируется, а не записана руками ​

Из этого раздела кодмод выдаёт три заметки:

  • ambiguous-entry-point (предупреждение): имя экспортируют несколько точек входа, и ничто в файле не говорит, какая нужна. Кодмод выбрал самую частую и перечислил остальные. Если этой спеке нужна другая, поменяйте спецификатор руками: каждая точка входа регистрирует адаптер моков (связку с vi.fn() или mock() вашего раннера) для своего раннера, так что не та точка входа ломает все спаи.
  • unmapped-legacy-export (ошибка): имя не экспортирует ни одна точка входа установленного пакета. Импорт остаётся на легаси-пакете; замену ищите в таблице соответствия.
  • no-entry-table (ошибка): таблицу не удалось построить, так что ничего не размещено. Установите vitest-auto-spy в репозиторий (npm i -D vitest-auto-spy) и запустите снова.

Чтобы разложить import { createSpyFromClass, provideAutoSpy, Spy } from 'jest-auto-spies', кодмод должен знать, какая точка входа экспортирует какое имя. Таблица, вписанная в кодмод, была бы верна для одной версии и неверна для той, что установили вы. Поэтому он строит таблицу во время запуска:

  1. Находит установленный vitest-auto-spy, поднимаясь по node_modules до шести директорий от рабочей (этого хватает для поднятых зависимостей в workspace), а если не нашёл — берёт копию, из которой запущен сам CLI.
  2. Читает карту exports этого пакета и разрешает каждую точку входа в файл, предпочитая условие types: файл деклараций перечисляет экспорты только типов, которых нет в бандле. Если dist нет, берутся исходники рядом — так монорепозиторий может пользоваться пакетом до первого релиза.
  3. Собирает экспортированные имена — именованные списки, объявления и обе формы реэкспорта со звёздочкой, export * from './…' и export type * from './…', — проходя баррели на глубину до восьми, по виду с замаскированными комментариями и строками.

Результат — не догадка об API, а сам API, прочитанный с копии на диске.

Две формы export * не взаимозаменяемы

Пакет использует export type *, чтобы одной строкой реэкспортировать все свои публичные типы. Обходчик, который знает только форму для значений, теряет все типы разом — здесь это Spy<T> и всё, что объявлено рядом. Потеря ничем не выдаёт себя: таблица строится, а трансформация импорта просто не может разместить имя и оставляет его на jest-auto-spies с ошибкой остатка. Проявляется это только на исходниках, потому что .d.ts перечисляет экспорты явно, так что тесты против собранного dist зелёные, а те же тесты до сборки падают.

Когда имя экспортируют несколько точек входа, кодмод выбирает:

  1. Корень, если он экспортирует имя, — он регистрирует адаптер моков и есть у каждого раннера.
  2. Иначе точку входа вашего репозитория, определённую так же, как её определяет init: provideAutoSpy уходит в /angular в Angular-репозитории и в /nestjs в NestJS.
  3. Иначе единственную точку входа, где оно есть.
  4. Иначе спрашивает файл: точку входа, из которой файл уже импортирует, или точку для фреймворка, который называет сам текст файла — на Bun её Bun-вариант (/bun-angular, а не /angular).
  5. Иначе угадывает самую вероятную точку входа для вашей среды выполнения и сообщает ambiguous-entry-point со списком альтернатив.

Имя, которое не экспортирует ни одна точка входа, остаётся на легаси-пакете и сообщается как unmapped-legacy-export.

Если установленную копию найти не удалось, таблицы нет. Четыре трансформации, которым она нужна, — auto-spies-import, inject-cast и две jasmine-трансформации, размещающие createSpyObj и Spy<T>, — отказываются работать и говорят об этом. Неверная точка входа, которая всё равно компилируется, — ровно то, от чего эта команда защищает.

--list печатает всю таблицу, чтобы её можно было прочитать, прежде чем ей верить.

Инструкция импорта правится на месте, а не собирается заново ​

Блок импортов — место, где файл хранит то, что правка терять не должна: алиас, комментарий к спецификатору, окончание строк, принятое в репозитории. Поэтому список правится по диапазонам, а не собирается заново из имён.

  • Строчный комментарий внутри списка — не список имён. Список делится по запятым маскированного вида, так что слова внутри // keep this one никогда не читаются как импорт. Добавленное имя встаёт после последнего символа кода и перед комментарием; удалённый спецификатор забирает свою запятую и свой комментарий в конце строки и больше ничего. Алиасы (a as b) и переводы строк внутри списка сохраняются.
  • Новая инструкция встаёт на отдельную строку сразу после предыдущей инструкции, так что комментарий в конце той строки остаётся. Перевод строки — такой же, как в файле: CRLF-файл остаётся CRLF.
  • Метка порядка байтов (BOM) в начале файла сохраняется.

Результат парсится до того, как он записан ​

Если кодмод сообщил codemod-broke-syntax, файл остался ровно таким, каким был. Переведите его руками и сообщите о конструкции в названной строке как о дефекте кодмода.

error  codemod-broke-syntax src/app/service.spec.ts:7
       The rewritten file would not parse at line 7 (',' expected.), so the file was left as it
       was.
       → Migrate this file by hand, and report the construct on that line as a codemod defect.
       Docs: https://asdalexey.github.io/vitest-auto-spy/utilities/codemod#the-result-is-parsed-before-it-is-written

Все трансформации работают с текстом, и опасная поломка — правдоподобный дифф в файле, который больше не компилируется. Поэтому каждый результат проходит через парсер: typescript переводимого репозитория, найденный от --cwd, чтобы глобальный npx vitest-auto-spy не читал ваши файлы чужим компилятором.

  • Файл, в котором прогон добавил синтаксических ошибок, не записывается; его правки и новые строки импорта отбрасываются вместе с ним.
  • Строка и сообщение — от самого парсера, по переписанному тексту.
  • Ошибки до и после сравниваются между собой, а не с нулём, так что спеку с уже существовавшей синтаксической ошибкой в ней не обвинят.
  • Если typescript в репозитории не находится, проверка молча пропускается.

Один сломанный файл не останавливает прогон ​

Каждый файл преобразуется отдельно, как и каждый проход --verify по нему. Файл, на котором кодмод бросил исключение, становится ошибкой codemod-file-failed: в нём ничего не меняется, а прогон идёт дальше. Переведите этот файл руками и сообщите, какая форма кода к этому привела.

--write пишет каждый изменённый файл через временный файл в той же директории и переименование. Прерванный прогон оставляет каждый файл либо как был, либо полностью переведённым, но никогда не наполовину записанным. Права файла сохраняются, а через симлинк пишется в целевой файл. Неудачная запись (права доступа, полный диск) становится ошибкой codemod-write-failed, и файл на диске не меняется. Записываются только файлы, текст которых изменился.

Как паттерн никогда не попадает в комментарий ​

Кодмод, который сопоставлял бы сырой исходник, переписал бы комментарий, объясняющий переход, и строку, которую проверяет утверждение. Ни одна из этих поломок не шумит: файл компилируется, а дифф выглядит правдоподобно.

Поэтому каждый паттерн сопоставляется с маской: тот же текст, та же длина, те же переводы строк, а содержимое комментариев, строк, шаблонных литералов и регулярных выражений заменено пробелами. Смещения в маске верны и в оригинале, так что «найти в маске, вырезать из исходника» безопасно. Кавычки сохраняются, так что from 'jest-auto-spies' по-прежнему находится: кавычка совпадает в маске, а спецификатор читается из исходника.

JSX — это код, а не регулярное выражение. Слэш в <Thing /> и </Thing> стоит там, где может начаться регулярное выражение. Кодмод знает, что / сразу после < не открывает регулярное выражение и что литерал с < внутри тоже им не является. Поэтому .tsx-тесты переводятся и проверяются целиком.

Для --verify маска устроена чуть иначе — в двух местах, потому что то, что он ищет, стоит внутри литерала. Спецификатор модуля остаётся видимым: from 'jest-auto-spies' и есть остаток. Шаблонный литерал остаётся видимым: трансформации внутрь него не заходят, так что jest. там настоящий. Содержимое обычной строки остаётся замаскированным: expect(text).toBe('jest.spyOn(…)') — это текст о переходе, а не код для перевода.

Опубликовано под лицензией MIT.