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

workspace/tests/

Декларативные сценарии интеграционных тестов (dwe test).

Deploy-пайплайн проекта dwe обычно проверяется только на одном рабочем окружении разработчика — нет способа ответить на вопрос “переживёт ли чистый деплой эти изменения конфига?” без того, чтобы затронуть (или подвергнуть риску) окружение, в котором вы работаете.

dwe test запускает deploy-пайплайн проекта — и любые последующие проверки или проектные команды, которые вы объявите — внутри свежей, полностью изолированной, одноразовой копии проекта. Каждый сценарий — это один YAML-файл под workspace/tests/: одно изолированное окружение, один чистый деплой с параметрами этого сценария, за которым следуют упорядоченные шаги по той же схеме шагов, что и в deploy.yml.

Изоляция затрагивает только состояние, управляемое dwe (compose-проект, порты, volume’ы, файлы под .dwe/) — см. Документированные ограничения о том, что она не покрывает.

workspace/tests/<scenario>.yml — один файл на сценарий. Имя сценария — это базовое имя файла, а не поле (симметрично с workspace/services/<name>/service.yml): файл workspace/tests/redis-off.yml определяет сценарий с именем redis-off.

Имена сценариев должны уже быть валидными фрагментами имени compose-проекта: строчные алфанумерики, -, _, без ведущего разделителя (^[a-z0-9][a-z0-9_-]*$). Имена никогда не санитизируются — несоответствующее имя файла является жёсткой ошибкой на этапе обнаружения, для каждого файла под workspace/tests/*.yml/*.yaml.

Загрузчик строгий (KnownFields(true), как и у семейства pipeline-загрузчиков), с одним намеренным отличием от этого семейства: пустой или полностью закомментированный файл сценария — это ошибка, а не отсутствующий-и-задефолченный документ — у сценария нет осмысленного значения по умолчанию.

Отсутствие директории workspace/tests/ не является ошибкой: dwe test list сообщает об отсутствии сценариев, а dwe test run (без аргументов) ничего не запускает.

description: "Deploy with redis disabled"
env:
services:
disable: [redis] # и/или enable: [...]
vars:
app.http_port: auto # auto = выделить свободный host-порт перед деплоем
db.port: auto
db.password: "test-pw" # любое переопределение vars; попадает в local.yml копии
timeout: 15m # бюджет по времени на весь сценарий
steps: # упорядоченные, выполняются ПОСЛЕ неявного деплоя; формат шагов deploy.yml
- name: "app answers"
type: builtin
cmd: http_check
with: { url: "http://localhost:${vars.app.http_port}/up", status: 200, contains: "OK" }
- name: "create dump"
type: command # обычная пользовательская команда; private-команды разрешены
cmd: db:dump
- name: "dump exists"
type: builtin
cmd: file_exists
with: { path: "dumps/db-latest.sql.gz" }

Человекочитаемое описание, показываемое dwe test list дословно — не переводится (строки отображения в остальном dwe локализуются, но описания сценариев — это пользовательский контент и остаются как написано, spec §8).

env:
services:
enable: [worker]
disable: [redis]

Принудительно включает или выключает названные сервисы в копии, поверх того, что уже включено в workspace/local.yml. Отображается в services.<name>.enabled: true/false в сгенерированном local.yml (ниже) — тот же эффект, что и dwe services enable/disable --apply, только в рамках копии.

env:
vars:
app.http_port: auto
db.password: "test-pw"

Каждый ключ — это dot-путь относительно vars. (app.http_portvars: { app: { http_port: … } } в local.yml копии), переопределяющий то, что установлено в проекте или в собственном local.yml разработчика.

auto — единственное магическое значение: перед деплоем runner выделяет свободный host-порт и записывает конкретное число на его место. Шаги сценария видят выделенное значение через ${vars.app.http_port} (рендеринг на этапе резолва, ниже).

auto не требуется просто чтобы изолировать host-порт сервиса: каждый host-порт, объявленный включённым сервисом под services.<name>.ports, переназначается на свободный порт автоматически (см. Автоматическая изоляция host-портов ниже). env.vars: { …: auto } — для остаточного случая: compose-файл, который подставляет host-порт из var, не объявленного в конфигурации сервиса, — когда вы хотите, чтобы раннер выделил и подставил это значение; шаг тогда читает его через ${vars.<path>}.

Бюджет по времени на весь сценарий (деплой + все шаги), например 15m. Парсится через time.ParseDuration и должен быть строго положительным. Приоритет: флаг dwe test run --timeout (переопределяет любой сценарий) > это поле > значение по умолчанию 30 минут. При истечении таймаута выполняющийся подпроцесс или шаг убивается, сценарий помечается как проваленный, а teardown всё равно выполняется (со своим собственным свежим дедлайном, а не истёкшим).

Обычные шаги пайплайна, по той же схеме, что использует deploy.ymltype: shell / command / dwe / builtin, с when: — резолвятся и выполняются тем же движком. У шага нет поля service:/dir: (у deploy его тоже нет); шаг, которому нужно выполниться в контексте сервиса, использует type: command и отдаёт эту деталь пользовательской команде. Сценарий без steps: валиден и уже полезен: “деплой с этими параметрами проходит успешно, и контейнеры поднимаются здоровыми”.

Шаг также может задать собственный timeout: (например, timeout: 5s на шаге http_check) — то же общее, опциональное поле движка, описанное в Полях шага; отсутствие поля или 0 оставляет шаг неограниченным.

Шаги type: command диспетчеризуются через обычный реестр команд проекта, включая private-команды — так что проект может определить тест-специфичные команды (например, db:dump выше) и держать их вне повседневного листинга команд, продолжая при этом выполнять их из сценария. hide-команды — другое дело: пайплайны пропускают их согласно существующему контракту, поэтому тест-специфичная команда должна быть private, а не hide.

Любой билтин-предикат (KindPredicate) (file_exists, executable_in_path, tcp_reachable, containers_running, env_keys_present, config_keys_present, предикат shell, и билтин http_check ниже) может использоваться напрямую как тело шага, где он ведёт себя как проверка (assertion): false проваливает шаг с собственным сообщением предиката, true проходит молча. Это общая возможность движка (применима и к шагам deploy.yml/reset.yml/lifecycle.yml), а не тест-специфичная — полную механику, включая исключение always-run, которое не даёт гейтам актуальности деплоя пропустить проверку, см. в справочнике билтинов.

http_check (новый билтин, вид predicate) дополняет tcp_reachable для веб-стеков, которым нужно несколько секунд после up, прежде чем они начнут отвечать:

- name: "app answers"
type: builtin
cmd: http_check
with:
url: "http://localhost:${vars.app.http_port}/up"
status: 200
contains: "OK" # опциональная подстрока в теле
retries: 10 # опционально; по умолчанию 0
interval: 1s
timeout: 2s

Полный список параметров и семантику повторов см. в справочнике билтинов.

Рендеринг ${...} происходит в движке пайплайна, ровно один раз. Шаги сценария резолвятся тем же путём, что и шаги deploy.yml, поэтому cmd, строковые листья with, check, timeout и shell-when: рендерятся на этапе резолва по резолвленному конфигу копии — ${vars.app.http_port} резолвится в конкретный выделенный порт. Отдельного прохода на стороне сценария нет: рендеринг не идемпотентен, и второй проход развернул бы дважды переменную, значение которой само является ссылкой ${...}, — сценарий проверял бы то, чего деплой-пайплайн никогда не выполняет.

Поэтому здесь действуют те же правила, что и для шагов деплоя: нераспознанная голова (${HOME}, опечатка) остаётся литеральным ${...}, а не схлопывается в пустую строку, а у ${param.*} / ${context.*} / ${files.*} / ${generated.*} / ${args} на этом пути нет источника — их использование валит сценарий на этапе резолва с сообщением, называющим конкретное пространство имён. Пути в параметрах билтина (например, path: у file_exists) резолвятся относительно корня копии — проверки всегда осматривают тестовое окружение, а не исходное дерево.

Проваленный шаг проваливает сценарий; оставшиеся шаги пропускаются.

По порядку, для каждого сценария:

  1. Захватить per-scenario flock, загрузить и провалидировать файл сценария.
  2. Скопировать проект в изолированное дерево.
  3. Сгенерировать local.yml копии (seed + env: этого сценария + свежая идентичность) и docker-файл идентичности, затем записать durable-манифест запуска.
  4. Выполнить dwe validate в копии (дешёвый fail-fast).
  5. Выполнить dwe deploy run --silent в копии — реальный deploy-пайплайн, а не пересобранное приближение.
  6. Выполнить steps: in-process по конфигу копии.
  7. Снести всё (если не указан --keep).

Копирование дерева. Runner копирует проект в .dwe/tests/runs/<scenario>/ (внутри корня проекта, чтобы остаться в области видимости file-sharing Docker Desktop/OrbStack для bind-монтирования). Отбор файлов для копии основан на git: отслеживаемые + неотслеживаемые-но-не-игнорируемые файлы (git ls-files -co --exclude-standard), всегда исключая .dwe/, .env и .git/. Путь, который git перечисляет, но которого нет в рабочем дереве (незакоммиченное удаление), пропускается — побеждает состояние рабочего дерева, поэтому локально удалённый файл остаётся удалённым и в копии. Симлинки воссоздаются как симлинки; права сохраняются. Без git (или при ошибке git) runner переключается на полное копирование директории с теми же исключениями плюс предупреждение о том, что игнорируемые артефакты теперь включены.

Собственный workspace/local.yml разработчика — gitignored и поэтому не копируется — вместо этого он сидируется в сгенерированный local.yml (ниже), так что локально-обязательные vars и локально-включённые сервисы всё равно доходят до тестового деплоя.

Идентичность compose. Каждый запуск получает <base>-t-<scenario>-<run-id> в качестве имени compose-проекта (<base> = project.prefix, если задан, иначе project.name; <run-id> — 6 случайных hex-символов — само имя сценария не является границей уникальности при двух одновременных клонах одного проекта). Это автоматически разделяет контейнеры, сети и не-общие volume’ы; поиск контейнеров остаётся label-based (com.docker.compose.project), никогда не по угадыванию имени. Идентичность записывается в копию двумя способами:

  • если в копии есть workspace/docker.yml → генерируется workspace/docker.local.yml только с project_name: (локальный слой всегда побеждает);
  • если в копии нет workspace/docker.yml → генерируется workspace/docker.yml с project_name: плюс явным пустым [] для каждого ключа args:, чтобы сгенерированный файл оставался семантически нейтральным (отсутствие docker.yml сегодня означает нулевую конфигурацию args — сгенерированный файл без args: молча включил бы значения по умолчанию для каждой команды, что было бы изменением поведения). Любой случайно скопированный docker.local.yml в этой ветке удаляется, поскольку иначе он бы впервые активировался.

Приоритет генерируемого local.yml (от низшего к высшему):

  1. seed = local.yml исходного проекта (отсутствующий файл → пустой), с вырезанными compose.extra и services.<name>.compose.extra (каждое вырезание сопровождается предупреждением — они ссылаются на gitignored overlay-файлы, которых в копии нет);
  2. env.vars / env.services этого сценария;
  3. идентичность: project: { prefix: <имя compose-проекта> } и update: { mode: "off" } (без промптов самообновления внутри одноразового тестового запуска).

Автоматическая изоляция host-портов. Каждый host-порт, объявленный под services.<name>.ports сервисом, который будет включён в копии, переназначается на свежевыделенный свободный порт, записываемый в сгенерированный local.yml как переопределение services.<name>.ports.<x> (scheme исходного порта сохраняется). Поскольку preflight ports_free читает то же поле — и проект, который берёт свои compose-биндинги host-портов из services.<name>.ports (напрямую или через запись exports.env from: services.<name>.ports.<x>), биндится из него же — preflight и фактический бинд двигаются вместе, поэтому сценарий работает параллельно с рабочим окружением без конфигурации портов. Любые env.vars: { …: auto }-порты выделяются в том же пакете. Все порты берутся из одного прохода выделения (все listener’ы открываются до закрытия любого из них, гарантируя уникальность внутри пакета); preflight ports_free копии всё равно ловит гонки на уровне хоста, и при провале деплоя, похожем на конфликт привязки host-порта (при наличии любого выделенного порта), раннер переаллоцирует все порты и повторяет деплой ровно один раз, прежде чем провалить сценарий.

Volume’ы shared: true резолвятся в свои буквальные имена и переиспользуются как есть — намеренное исключение для кэшей пакетов (composer, npm, …). shared-volume, хранящий реальные (не кэш) данные, поэтому виден каждому тестовому запуску.

Состояние. Журнал, локи, хранилище сгенерированных значений, prompt-кэш, логи — всё живёт под собственным .dwe/ копии, отдельно от исходного. .env регенерируется внутри копии самим деплоем. Docker-демон (кэши образов/сборки) и ~/.config/dwe (пользовательские настройки, переопределения бинарей) общие по замыслу — “всё состояние локально для проекта” верно для runtime-состояния, но не для демона или пользовательского конфига.

Все пути ниже — относительно корня исходного проекта (никогда не копии):

ПутьНазначение
.dwe/tests/runs/<scenario>/Одноразовая копия для текущего/последнего запуска сценария
.dwe/tests/locks/<scenario>.lockPer-scenario flock (никогда не общий на проект deploy.lock/snapshot.lock)
.dwe/tests/manifests/<scenario>-<run-id>.ymlDurable-манифест запуска — записывается до любого взаимодействия с Docker
.dwe/tests/reports/<scenario>/Артефакты сбоя последнего непройденного (и не --keep) запуска сценария (см. Отчёты об ошибках)

Манифест (scenario, run_id, compose_project, copy_path, bridge_dir, report_dir, created_at) — единственный вход для teardown: запуск, оборвавшийся на середине (падение, --keep, убитый процесс), всё равно полностью описывается своим манифестом и содержимым копии, без обращения к рабочему окружению и без угадывания имён.

Выполняется по умолчанию после каждого сценария (успех/провал/таймаут/Ctrl+C), управляется только манифестом, по порядку: docker compose down --remove-orphans (никогда не -v, и политика args.down копии игнорируется, чтобы проект с args.down: ["-v"] не мог удалить общий кэш-volume, на который в сыром compose-файле ссылаются как на обычный именованный volume) → добор оставшихся контейнеров с меткой, точно равной com.docker.compose.project из манифеста → удаление собственных volume’ов тестового проекта (по префиксу имени compose-проекта; shared:-volume’ы выживают, та же семантика, что у dwe reset) → остановка bridge-демона, если деплой запустил его в копии → удаление директории копии → удаление манифеста → освобождение flock. Каждый шаг best-effort — сбой логируется, а последующие шаги всё равно выполняются.

--keep пропускает все шаги выше, оставляет манифест и копию на месте и печатает имя compose-проекта, путь копии и подсказку по очистке. Последующий dwe test run того же имени сценария сразу проваливается (манифест сохранённого запуска ещё существует), а не тихо удаляет сохранённое окружение из-под вас — очистите вручную, либо выполните dwe test clean.

Непройденный запуск с --keep печатает вторую строку — где на самом деле лежат улики: <copy>/.dwe/logs/ и docker compose -p <project> logs. Отчёт об ошибке под --keep не собирается (окружение всё ещё живо, снимок с него был бы избыточен), а без этой строки его отсутствие читается как упущение, а не как осознанный размен.

Когда сценарий не проходит (сбой деплоя, сбой шага или таймаут) и --keep не был указан, runner пытается собрать отчёт об ошибке в .dwe/tests/reports/<scenario>/ до того, как teardown уничтожит окружение — так материал для отладки переживает снос. Директория очищается и перезаписывается на каждом непройденном запуске (отлаживается последний сбой); успешный сценарий или запуск с --keep её не трогают.

ФайлИсточник
pipeline.logкопия лога пайплайна сценария (.dwe/logs/test.log внутри одноразовой копии)
compose-ps.txtdocker compose ps --all по копии (--all, чтобы сервис, упавший или завершившийся во время деплоя, всё равно был виден — running-only по умолчанию отбросил бы именно тот сервис, ради которого и существует отчёт об ошибке)
container-logs.txtdocker compose logs --no-color --tail 200 по контейнерам копии, объединённые в один файл

Сбор — best-effort на всём протяжении и выполняется под собственным свежим таймаутом (никогда не по дедлайну самого сценария, который может быть уже истёк): отсутствующий лог пайплайна, частично упавшая docker-команда или таймаут самого сборщика — предупреждаются и никогда не меняют результат прохождения сценария. Если конфиг docker копии не удаётся загрузить (то же условие, что может сломать и сам деплой), сбор переключается на docker ps -a / docker logs, отфильтрованные по точному значению метки com.docker.compose.project этого запуска, так что отчёт формируется даже когда compose неработоспособен.

Путь отчёта отображается в текстовом выводе dwe test run (строка report: <dir> под непройденным сценарием) и в JSON-выводе как report_dir (пусто для успешного сценария, запуска с --keep или когда директорию отчёта создать не удалось).

dwe test run [scenario...]
--keep # пропустить teardown; напечатать имя проекта, путь копии, подсказку по очистке
--timeout <duration> # переопределить собственный timeout каждого сценария (например, 15m)
--skip-isolation-check # понизить блокирующие находки изоляции до предупреждений
--parallel N # запускать до N сценариев одновременно (по умолчанию 1)

Без аргументов запускаются все сценарии под workspace/tests/*.yml, в отсортированном по имени порядке. Именованные аргументы запускают ровно эти сценарии (неизвестное имя проваливается до того, как что-либо запустится, код выхода 2). По умолчанию сценарии выполняются последовательно. Ctrl+C (SIGINT/SIGTERM) отменяет выполняющиеся в данный момент сценарии, сносит их и пропускает остальные — уже завершённые сценарии всё равно попадают в отчёт.

Вывод — это стандартный живой репортер пайплайна на каждый сценарий (тот же вид, что у dwe deploy run), за которым следует строка сводки, например:

2 passed, 1 failed (redis-off: step "tests/app answers")

dwe test требует проект — в отличие от read-only команд документации, он не работает вне проекта.

Глобальные флаги --verbose / --debug прокидываются в подпроцессы dwe validate и dwe deploy run внутри копии, поэтому диагностический уровень применяется к тому самому деплою, который проверяет сценарий, а не только к учётной логике самого раннера. В обычном последовательном текстовом режиме трейс подпроцесса выводится в stderr в реальном времени; при --parallel или --output json (где вывод подпроцесса не транслируется) он попадает в лог запуска этой копии (.dwe/tests/runs/<scenario>/.dwe/logs/test.log). --debug перекрывает --verbose (это надмножество), как и во всех остальных командах.

--parallel N (по умолчанию 1) запускает до N сценариев одновременно. Эффективный параллелизм — min(N, число сценариев): --parallel 8 при двух сценариях запускает два воркера; --parallel 8 при одном сценарии — один. Порядок вывода (текстовая сводка и JSON-массив scenarios) всегда соответствует исходному порядку имён, независимо от порядка завершения.

  • --parallel 1 (по умолчанию) байт-в-байт совпадает с сегодняшним поведением. Когда эффективный параллелизм равен 1 — флаг отсутствует, задан 1, или сценариев меньше, чем запрошено воркеров — выполняется прежний потоковый последовательный путь: стандартный живой репортер пайплайна на каждый сценарий, ровно как у dwe deploy run.
  • При эффективном параллелизме > 1 потоковый вывод заменяется компактным агрегированным видом. Одна закреплённая строка на сценарий показывает спиннер, имя сценария, грубую фазу (preparing…, validating…, deploying…, deploy retry…, running steps…, collecting report…, tearing down…) и секундомер; по завершении строка финализируется в ✓ <name> passed, ✗ <name> failed — step "…" либо ✗ <name> error (сценарий, который не удалось подготовить). Футер отслеживает running k/n scenarios…. Когда сценариев больше, чем помещается в терминале (закреплённый блок ограничен высотой терминала), сценарии сверх этого лимита отображаются теми же плоскими строками scenario <name>: started / scenario <name>: <status>, а не закреплённой строкой. Пошаговый вывод деплоя/пайплайна каждого сценария не транслируется в терминал — он идёт только в собственный лог этой копии (.dwe/tests/runs/<scenario>/.dwe/logs/test.log), а отчёт об ошибке непройденного сценария собирается как обычно. Предупреждения снабжаются префиксом [<scenario>] warning: … и печатаются в stderr, не нарушая блок.
  • Запуски через pipe / без TTY (CI) деградируют до плоских строк scenario <name>: started / scenario <name>: <status> на каждый сценарий вместо живого блока — сводка и код выхода не меняются.
  • JSON-режим (--output json) не зависит от --parallel: форма payload идентична, и, как на любой read-only поверхности, живой вывод и предупреждения подавляются независимо от параллелизма.

Коды выхода не меняются (см. Коды выхода): любой сценарий, который не удалось подготовить → 2, иначе любой проваленный сценарий → 1, иначе 0.

Изоляция сохраняется без изменений при параллелизме. Каждый сценарий уже выполняется в собственной директории-копии, под собственным per-scenario flock, со своим per-run-id compose-проектом и манифестом, и с каждым host-портом, перемапленным на свежевыделенный свободный порт (см. Модель изоляции). Выделение портов дополнительно защищено от гонок в пределах процесса: набор аренд гарантирует, что два одновременных сценария в одном dwe test run никогда не получат один и тот же host-порт (межпроцессные гонки между отдельными вызовами по-прежнему покрываются preflight ports_free каждой копии плюс одной повторной попыткой деплоя).

Конкуренция за общий кэш пакетов. Сценарии, переиспользующие один и тот же shared: true кэш-volume (кэш composer или npm), могут конкурировать при параллельном запуске — пакетные менеджеры берут lock-файлы, а одновременные установки на холодный кэш против одного volume могут замедлять друг друга или споткнуться о собственную блокировку менеджера. Предпочтительно не параллелить сценарии, каждый из которых выполняет тяжёлую установку на холодный кэш против общего кэша; сценарии с тёплым или непересекающимся кэшем параллелятся без проблем. Нагрузка на Docker-демон от N одновременных деплоев (pull образов, сборки, старты контейнеров) — на ваше усмотрение; подбирайте N под хост.

dwe test list

Перечисляет каждый сценарий под workspace/tests/*.yml с его description:, дословно. Отсутствие директории workspace/tests/ ничего не перечисляет и не является ошибкой.

dwe test list --output json отдаёт для каждого сценария объект cost_profile — во что обойдётся его запуск и насколько далеко простирается его изоляция:

{
"scenarios": [
{
"name": "redis-off",
"description": "Deploy with redis disabled",
"cost_profile": {
"enabled_services": 3,
"build_services": ["app"],
"external_images": ["postgres:16", "redis:7"],
"max_start_period_seconds": 30,
"shared_volumes": 1,
"isolation_findings": [{"kind": "external_volume", "resource": "composer-cache"}],
"host_steps": 2
}
}
]
}
ПолеЗначение
enabled_servicesСервисы dwe, включённые после наложения env.services этого сценария — то самое число, которым сценарии одного проекта реально различаются. Сервис с required: true остаётся включённым, даже если сценарий его выключает: ровно так загрузчик конфигурации разрешает сгенерированный local.yml.
build_servicesCompose-сервисы с build: во включённой цепочке, отсортированные.
external_imagesРазличные image: тех compose-сервисов, которые не собираются локально, отсортированные — то, что холодный запуск будет тянуть. Собираемый сервис несёт локальный тег (image: myproject-app:dev), тянуть его неоткуда, поэтому он исключён.
max_start_period_secondsНаибольший start_period healthcheck во включённой цепочке. Максимум, не сумма: docker compose up --wait ждёт параллельно, поэтому сумма завышает оценку тем сильнее, чем больше сервисов. Отключённый или неразбираемый healthcheck не учитывается.
shared_volumesКоличество томов docker.yml с shared: true. Они разрешаются в свои дословные имена, и тестовый запуск в них пишет.
isolation_findingsНеблокирующие находки сканера изоляции composenamed_volume / external_volume / named_network / external_network, то есть ресурсы, разделяемые с рабочим окружением. Блокирующие виды (container_name, raw_host_port) не включаются: они и так прерывают сценарий до деплоя. Полные сообщения даёт dwe validate tests.
host_stepsШаги, которые сценарий выполнит на хосте, вне контейнерной песочницы: его собственные, деплой-пайплайн, который он вызывает (проектный и включённых сервисов), и проверки workspace/validate.yml, которые прогон запускает через dwe validate и preflight dwe deploy run в копии — проверка считается, если это type: builtin с cmd: shell либо type: command (загрузчик допускает там только команды типа shell / script), с тем же гейтом services:, который применяет загрузчик проверок. Шаги пайплайна считаются с заходом внутрь групп parallel:. Считаются формы type: shell, type: builtin с cmd: shell, type: dwe, чья подкоманда снова входит в код проекта (cmd, run, restart, deploy, reset, test), type: command, чья команда разрешается в хостовую, и любой шаг с shell-условием when: или хостовым check: (включая check: auto). Команда считается хостовой, если она объявляет argv_append_from: (выражение выполняется на хосте даже у контейнерной команды), либо — в его отсутствие — если её тип не service_exec и не service_run, причём правила type: dwe / type: builtin применяются рекурсивно, а для workflow считается подшаг, который сам хостовый или несёт when: вида cmd:. Собственные подкоманды dwe (docker up --wait, info, render … — весь встроенный пайплайн по умолчанию) не считаются: это машинерия dwe поверх одноразовой копии, ради запуска которой сценарий и существует. Шаг считается один раз, сколько бы форм он ни использовал; shell-условие when: на уровне фазы считается за один. Ссылка type: command, которая не разрешается, считается хостовым шагом — поле управляет запуском без присмотра, поэтому неизвестный случай закрывает ворота. Побочные эффекты на хосте не изолируются.

Два свойства заданы намеренно:

  • Только факты — поля-вердикта cheap/expensive нет. Что считать достаточно дешёвым для запуска без присмотра — политика вызывающей стороны (для ИИ-агентов правило формулирует skill dwe), а не CLI.
  • Профиль сообщает, есть ли сборка, а не сколько она стоит. Главный фактор — тёплый ли кэш слоёв Docker, секунды против многих минут — статического источника не имеет и не моделируется.

Профиль опускается (не пустой, не ошибка), если нужное состояние проекта не загружается: dwe test list не берёт локов, не трогает Docker и не требует загружаемой конфигурации — это команда, к которой обращаются, когда конфигурация в процессе правки, и там она продолжает работать. Текстовый вывод профиль вообще не вычисляет.

dwe test clean [scenario...]
--dry-run # сообщить, что было бы снесено, ничего не трогая

Удаляет тестовые окружения, оставшиеся после dwe test run --keep или прерванного/упавшего запуска. clean строго манифест-управляемый: он перечисляет .dwe/tests/manifests/*.yml и переиспользует тот же самый Teardown, что выполняется в конце обычного dwe test run — ничто и никогда не сносится по угаданному по паттерну имени compose-проекта.

Без аргументов сносится каждый сценарий с манифестом. Именованные аргументы ограничивают снос ими. Сценарий, чей flock (.dwe/tests/locks/<scenario>.lock) в данный момент удерживается живым запуском, пропускается, никогда не сносится — clean никогда не соперничает с выполняющимся запуском. --dry-run сообщает, что было бы снесено, ничего не трогая (flock каждого сценария всё равно захватывается и освобождается, чтобы корректно классифицировать живой запуск как пропущенный, а не подлежащий сносу).

Если teardown манифеста не завершился чисто (например, шаг удаления контейнера или volume’а провалился на середине), эта запись отражается как failed, никогда как swept — Teardown best-effort и мог удалить часть ресурсов (даже сам манифест) до того, как наткнулся на сбой, поэтому засчитать его как swept означало бы скрыть реальный остаток от следующего запуска.

Best-effort, report-only сканирование дополнительно перечисляет Docker compose-проекты, совпадающие по префиксу тестового имени этого проекта (<base>-t-, та же основа, что использует dwe test run), у которых вообще нет манифеста — они отражаются как orphans и никогда не сносятся автоматически; удалите их вручную, убедившись, что это безопасно. Если собственный корневой конфиг проекта не удаётся загрузить, скан orphans пропускается (с предупреждением), но каждое манифестированное окружение всё равно сносится — сломанный или недоредактированный конфиг не должен блокировать восстановительный снос.

dwe test clean --output json
{
"dry_run": false,
"swept": [{"scenario": "smoke", "compose_project": "myapp-t-smoke-a1b2c3", "copy_path": ".dwe/tests/runs/smoke"}],
"skipped": [{"scenario": "redis-off", "compose_project": "myapp-t-redis-off-9f1e2d", "copy_path": ".dwe/tests/runs/redis-off", "reason": "live"}],
"failed": [],
"orphans": [{"compose_project": "myapp-t-old-9f8e7d", "note": "no manifest — remove manually"}]
}

Как и в run/list, живой вывод (предупреждения по каждой записи) в JSON-режиме молчит на stderr.

dwe validate tests

Домен dwe validate (Domain() == "tests"), который статически проверяет каждый файл сценария workspace/tests/*.yml, не трогая Docker — только валидация, никогда не подключается к preflight. Запускайте его в CI перед dwe test run, чтобы отловить ошибки авторства сценария без поднятия одноразовой копии.

Для каждого файла, по порядку:

  • загрузкаLoadScenario (строгий KnownFields(true), пустой файл отвергается); это также покрывает валидацию имени (ValidateScenarioName отвергает — а не переписывает — некорректное базовое имя файла), так что некорректное имя файла всплывает здесь.
  • timeout: — собственное поле timeout: сценария должно парситься через time.ParseDuration и быть строго положительным (повторяет рантайм-контракт resolveScenarioTimeout); ошибка парсинга или неположительная длительность — это ошибка.
  • env.services — каждая запись enable/disable должна называть сервис, существующий в смёрженном конфиге проекта; неизвестное имя — ошибка.
  • steps — все шаги рендерятся и резолвятся как одна цельная фаза, точно как при реальном запуске (pipeline.ResolvePhaseSteps над одной синтетической фазой) — это ловит ошибки схемы шага, некорректные параметры with: билтина, сломанные условия when: и дублирующиеся имена шагов верхнего уровня (проверка по-шагово упустила бы последнее, поскольку уникальность — это инвариант всей фазы). Рендеринг подставляет вместо любой записи env.vars со значением-литералом auto валидный плейсхолдер host-порта, так что ${vars.db.port} рендерится в валидное число и шаг tcp_reachable/http_check проходит валидацию как обычно — по-настоящему некорректный параметр (например, status: nope) всё равно даёт ошибку, даже рядом с шаблонизированным url:. Var, который заполняется только после деплоя (секрет ${generated.*}, или var, который создаёт сам деплой), на момент валидации отсутствует и может дать ложную диагностику — задайте для него дефолт на уровне проекта, чтобы этого избежать: валидатор видит конфиг до деплоя, а реальный запуск — конфиг после деплоя.
  • шаги type: command — каждый ID команды ищется в реестре команд проекта; неизвестный ID — ошибка.
  • изоляция compose — см. Сканер изоляции compose ниже; находки выводятся один раз на проект как предупреждения (никогда как ошибки — многоуровневая политика fail/warn применяется только к dwe test run, а не к статической валидации).

Модель изоляции dwe test (выше) разграничивает контейнеры, сети и не-общие volume’ы по имени compose-проекта — но горстка конструкций сырого compose полностью обходит это разграничение и может столкнуться с рабочим окружением или прикрепиться к нему. Сканер (config.ScanComposeIsolation) разбирает активные compose-файлы проекта (cfg.ComposeFiles()) на предмет этих конструкций и отмечает их:

КонструкцияKindСерьёзность
container_name: (любое вхождение)container_nameБлокирующая — буквальное имя контейнера напрямую сталкивается с рабочим окружением
Буквальный host-порт (одиночный, например "8080:80", или диапазон, например "8080-8090:80-90"), не смоделированный через services.<name>.portsraw_host_portБлокирующая — обходит и автоматическое переназначение портов, и ports_free
Volume/сеть с external: trueexternal_volume / external_networkПредупреждение — риск общего ресурса, а не жёсткое столкновение
Явное name: у volume/сетиnamed_volume / named_networkПредупреждение — тот же класс риска, что и external:

Токены host-порта с интерполяцией ${...}/переменными окружения и записи только с container-портом (случайный host-порт) не отмечаются — только буквальное число порта или диапазон. IPv6-хосты в скобках ([::1]:8080:80) вне области действия и не отмечаются (редки в дев-compose). Находка container_name: выдаётся всегда, независимо от того, к какому compose-сервису она относится — сопоставление compose-сервиса с ключом dwe-сервиса ненадёжно, так что ложные срабатывания снимаются через --skip-isolation-check, а не подавляются у источника.

У самого сканера нет мнения о серьёзности сверх встроенного флага Blocking — каждый вызывающий сам решает, что делать с находкой:

  • dwe test run — запускает скан на копии прямо перед подпроцессом dwe validate. Каждая находка печатается как предупреждение. Блокирующая находка немедленно проваливает сценарий (teardown всё равно выполняется; подпроцесс деплоя не запускается), если не передан --skip-isolation-check — в этом случае каждая находка, блокирующая или нет, становится только предупреждением, и запуск продолжается. См. предпосылку по портам, как избежать находки raw_host_port в принципе: смоделируйте порт через services.<name>.ports.
  • dwe validate tests — выводит каждую находку как предупреждение, независимо от Blocking; статическая валидация никогда не проваливает сборку из-за риска изоляции, а только заранее его показывает.
КодЗначение
0Все сценарии прошли (run); снос завершён, включая случай “нечего сносить” (clean — пропущенные-живые записи и orphans на это не влияют)
1Хотя бы один сценарий провалился — сбой деплоя, сбой шага или таймаут (run); teardown хотя бы одного манифеста не завершился чисто (clean)
2Сценарий — или сам запуск — не удалось даже подготовить: неизвестное имя сценария, ошибка загрузки/парсинга сценария, flock захвачен параллельным запуском, манифест сохранённого предыдущего запуска всё ещё существует (run)

clean отображает жёсткую ошибку (например, нечитаемую директорию манифестов) в ненулевой код выхода через стандартный конверт ошибок CLI, отдельно от кодов 0/1 результата сноса выше.

dwe test run --output json
dwe test list --output json
{
"scenarios": [
{"name": "redis-off", "status": "passed", "duration_seconds": 4.213},
{"name": "cache-on", "status": "failed", "failed_step": "tests/http_check", "duration_seconds": 2.101, "report_dir": ".dwe/tests/reports/cache-on"}
],
"summary": "1 passed, 1 failed"
}

status — одно из passed, failed, error (error = сценарий не удалось подготовить — сбой копии/конфига/манифеста/validate; отличается от сбоя деплоя или шага, который отражается как failed). failed_step и report_dir опускаются, когда пусты (у успешного сценария нет ни того, ни другого). report_dir — директория отчёта об ошибке для непройденного сценария; опускается для успешного сценария, запуска с --keep или когда сбор не смог создать директорию отчёта. Как и в любой другой read-only/отчётной поверхности, живой вывод пайплайна и строка сводки в JSON-режиме молчат — файловый лог под .dwe/logs/ всё равно фиксирует всё.

dwe test list --output json дополнительно отдаёт для каждого сценария профиль стоимости.

  • .git/ исключён из копии. Deploy- или тестовый шаг, вызывающий git в корне проекта, провалится или поведёт себя иначе внутри копии.
  • Именованные compose-ресурсы обходят изоляцию. container_name:, явно именованные сети/volume’ы и external: true в сырых compose-файлах игнорируют разграничение по имени compose-проекта и могут столкнуться — или прикрепиться — к рабочему окружению. Именно поэтому teardown никогда не использует compose down -v. Сканер изоляции compose обнаруживает эти конструкции и для container_name: и буквальных host-портов проваливает сценарий ещё до деплоя (можно понизить через --skip-isolation-check) — он не делает их безопасными, он показывает их до того, как они вызовут столкновение.
  • Host-порты, не смоделированные в services.<name>.ports, не изолируются. Автоматическое переназначение и preflight ports_free видят только порты, объявленные через services.<name>.ports; host-порт, захардкоженный прямо в сыром compose-файле (8080:8080), обходит и то, и другое. Объявите его под services.<name>.ports либо проведите подстановку в compose через var, заданный через env.vars: { …: auto }. Сканер изоляции отмечает это как блокирующую находку raw_host_port.
  • Побочные эффекты на хосте от собственных deploy-/тестовых шагов проекта не изолируются. Шаг shell, затрагивающий абсолютные пути, ~ или bind-монтирования вне проекта, влияет на реальный хост так же, как это было бы при реальном деплое. dwe test изолирует управляемое dwe состояние (файлы, контейнеры, volume’ы, сети, порты) — а не произвольные побочные эффекты, которые выбирает себе шаг.
  • Копирование неатомарно. Ничто не блокирует исходный проект во время работы git ls-files и копирования; редактирование файлов во время тестового запуска может дать смешанный снимок.
  • ~/.config/dwe и кэши образов/сборки Docker-демона общие, по замыслу (см. Модель изоляции).
  • dwe deploy run — реальная команда, выполняемая внутри копии каждого сценария
  • dwe validate — fail-fast проверка, выполняемая перед деплоем внутри каждой копии
  • dwe validate tests — статическая валидация сценариев без Docker (см. выше)
  • dwe reset run — использует ту же семантику удаления volume’ов, что переиспользует teardown
  • deploy.yml / reset.yml — схема шагов, которую переиспользует steps: (типы, with:, when:)
  • Билтиныhttp_check и предикаты-как-проверки в теле шага
  • Условия и действия — типизированные условия/действия, доступные when: и телам шагов