Инженерный кейс

Два сбоя в тестовой и превью-инфраструктуре Languagi

Languagi — языковой тренажёр для iOS (Swift 6, SwiftUI, 2217 строк, 19 unit-тестов + 1 сквозной UI-тест). Ниже — не история сборки приложения, а два инцидента, найденных при попытке объективно подтвердить, что оно работает: тест падал без видимой причины в коде, а собранная HTML-страница не рендерилась в превью. Каждый описан как есть: симптом, причина на уровне механизма, исправление в коде, измеримая проверка.

XCTest / XCUITest xcodebuild simctl Python macOS sandbox
Кейс 1

XCUITest падал без видимой причины в коде

Проблема

Сквозной UI-тест (SmokeFlowTests.testFullEightScreenFlow), проходящий весь пользовательский сценарий приложения от онбординга до paywall, падал на первом же запуске:

/LanguagiUITests/SmokeFlowTests.swift:18: error:
-[LanguagiUITests.SmokeFlowTests testFullEightScreenFlow] : XCTAssertTrue failed
Test Case '-[LanguagiUITests.SmokeFlowTests testFullEightScreenFlow]' failed (16.445 seconds).

Строка 18 проверяет, что кнопка «Продолжить» на экране онбординга неактивна до выбора цели. В коде приложения и теста на тот момент не было изменений, объясняющих падение: онбординг был готов заранее, 19 unit-тестов проходили стабильно.

Причина

Экспорт accessibility-дерева в момент падения (xcrun xcresulttool export attachments) показал, что тест стартовал не с онбординга, а сразу с уже пройденной тропы обучения — стрик 6, XP 45, узел «Заказ еды» помечен пройденным. Тест был запущен с launch-аргументом --uitest-reset, который должен был гарантировать чистый старт.

Причина оказалась не в тесте и не в логике приложения, а в способе, которым ранее вручную засеивались демонстрационные данные для скриншотов:

xcrun simctl spawn "iPhone 17 Pro" defaults write com.languagi.app \
  languagi.progress.v1 -data <hex>

Эта команда пишет в device-уровневый домен defaults, физически отделённый от container-уровневого UserDefaults, который читает и очищает сам код приложения. Приложение не может увидеть или удалить то, что записано в обход его собственного контейнера — «сброс» в коде срабатывал, но старые данные оставались видны через другой слой хранилища.

Во втором прогоне после этого исправления тест снова падал — на шаге сборки фразы. Слово a дважды встречается в банке слов упражнения, и тест иногда выбирал уже использованный дубликат: плитки зоны ответа в accessibility-дереве идут раньше плиток банка слов, а тест брал первый найденный активный элемент.

Решение

Оба исправления — в коде и конфигурации, не через интерфейс симулятора:

xcrun simctl spawn "iPhone 17 Pro" defaults delete com.languagi.app

— удаляет весь device-уровневый домен явно, вместо частичной перезаписи. Отдельно, выбор слова в тесте переписан на детерминированный обход с конца списка совпадений:

for i in stride(from: chips.count - 1, through: 0, by: -1)
where chips.element(boundBy: i).isEnabled { ... }

Проверка

Полный прогон xcodebuild test, дважды подряд на чистом симуляторе (переустановка .app между прогонами):

Test Suite 'All tests' passed at 2026-07-23 12:22:59.578.
Test Case '-[LanguagiUITests.SmokeFlowTests testFullEightScreenFlow]' passed (31.751 seconds).
** TEST SUCCEEDED **
19 unit-тестов + 1 UI-тест — 20 из 20, воспроизводимо
Кейс 2

Собранная HTML-страница не рендерилась в превью

Проблема

Локальная страница (self-hosted шрифты, изображения по относительным путям), открытая через file:// в браузерном превью, отображалась без единого стиля: системный шрифт, никаких цветов, вместо картинок — alt-текст.

Причина

A — разметка. document.fonts оказался пуст (ноль зарегистрированных @font-face), а getComputedStyle(hero).backgroundColor вернул rgba(0,0,0,0) — то есть не «шрифт не подгрузился», а вся таблица стилей не распарсилась. В исходнике был дублирующийся открывающий тег <style> и внутри CSS — случайно оставшийся закрывающий тег HTML-комментария -->. По правилам разбора HTML браузер трактует всё от первого <style> до первого </style> как один текстовый блок CSS; второй <style> не открывает новый элемент, а невалидный токен --> внутри ломает разбор всего листа.

B — среда просмотра. После исправления разметки картинки всё ещё не грузились. tabs_context показал, что происхождение вкладки — data:, а не file:: инструмент предпросмотра конвертирует локальный файл в data-URL снимок без разрешаемого базового пути, поэтому относительные ссылки не резолвятся.

Решение

Для A — убраны дублирующийся тег и невалидный -->. Для B — поднят настоящий локальный HTTP-сервер вместо файлового превью. Первая попытка падала при старте:

File ".../http/server.py", line 1274, in <module>
    parser.add_argument('--directory', '-d', default=os.getcwd(),
PermissionError: [Errno 1] Operation not permitted

Ошибка — на вызове os.getcwd(): песочница процесса запрещает даже узнать текущую директорию. Собственный сервер, вообще не вызывающий os.getcwd(), падал так же — уже на попытке открыть свой .py-файл, лежащий в ~/Desktop/…. Перенос того же скрипта в каталог вне ~/Desktop устранил ошибку немедленно.

Проверка

document.fonts.forEach(f => ...)              // все 7 @font-face: "loaded"
document.documentElement.scrollWidth
  - window.innerWidth                          // 0 при 1440px и при 375px

и расчёт контраста по формуле WCAG ((L1+0.05)/(L2+0.05) из относительной яркости) для 7 пар текст/фон — все ≥ 5.5:1.

Шрифты, картинки, отсутствие горизонтальной прокрутки — подтверждены измерением

Что здесь не является инженерным кейсом

Что усилило бы кейс в следующий раз

Стек

Swift 6SwiftUIXCTest / XCUITestxcodebuild xcrun simctlxcresulttoolPython 3CoreGraphics HTML / CSS / vanilla JSGoogle Fonts CSS2 APIgitGitHub CLI