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

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_port → vars: { app: { http_port: … } } в local.yml копии), переопределяющий то, что установлено в проекте или в собственном local.yml разработчика.

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

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

Конкретное число вместо auto, наоборот, фиксирует путь: копия биндит именно этот порт, а автоматическое переназначение отступает. null, "" и отсутствующий ключ не фиксируют — экспорт откатился бы к собственному дефолту и забиндил порт живого стека, поэтому такие пути всё равно получают выделенный порт. Не фиксируют и map или список по этому пути: это структура, а не порт, и выделенный порт заменяет её.

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

Обычные шаги пайплайна, по той же схеме, что использует deploy.yml — type: 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. Просканировать представление проекта глазами сценария на риски изоляции compose и спланировать host-порты, которые выделит копия. Блокирующая находка проваливает сценарий здесь, до того как что-либо создано.
  3. Скопировать проект в изолированное дерево.
  4. Сгенерировать local.yml копии (seed + env: этого сценария + запланированные порты + свежая идентичность) и docker-файл идентичности, затем записать durable-манифест запуска.
  5. Выполнить dwe validate в копии (дешёвый fail-fast).
  6. Выполнить dwe deploy run --silent в копии — реальный deploy-пайплайн, а не пересобранное приближение.
  7. Выполнить steps: in-process по конфигу копии.
  8. Снести всё (если не указан --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 этого сценария плюс каждый порт, который раннер выделяет для этой копии — переназначенные services.<name>.ports и каждый переназначаемый путь vars:, включая отслеженные пути, которые сценарий вообще не упоминает (ниже);
  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 и фактический бинд двигаются вместе, поэтому сценарий работает параллельно с рабочим окружением без конфигурации портов. Каждый путь vars:, который копия переназначает, — отслеженные ниже и любые env.vars: { …: auto } — выделяется в том же пакете. Все порты берутся из одного прохода выделения (все listener’ы открываются до закрытия любого из них, гарантируя уникальность внутри пакета); preflight ports_free копии всё равно ловит гонки на уровне хоста, и при провале деплоя, похожем на конфликт привязки host-порта (при наличии любого выделенного порта), раннер переаллоцирует все порты и повторяет деплой ровно один раз, прежде чем провалить сценарий.

Интерполированные host-порты переназначаются через правило exports.env своей переменной. Host-порт compose, записанный переменной ("${VALKEY_PORT:-6379}:6379"), сдвигается, когда сканер может проследить эту переменную до значения, которым управляет копия, — до последнего правила exports.env с таким name:, чей when: пуст или истинен, ровно как рендерится .env:

  • from: vars.<path> → переназначается всегда, без какой-либо конфигурации сценария: раннер выделяет свободный порт для <path> в том же пакете, что и всё остальное, и записывает его в сгенерированный local.yml. Сценарий, зафиксировавший путь конкретным числом (env.vars: { ports.valkey: 6380 }), получает это число — решение автора о том, какой порт биндит копия, а не конфликт.
  • from: services.<name>.ports.<x> → переназначается в тех сценариях, которые переназначают <name>: сервис включён в копии и не перечислен под env.services.disable (сервис с required: true остаётся включённым, поэтому остаётся покрытым).
  • всё остальное — другой путь, под-путь .port, необъявленный порт, ложный when:, переменная из окружения хоста или написанного вручную .env — не переназначается. Переменная сохраняет то значение, которое имела бы и вне тестового запуска: выражение с дефолтом откатывается к нему ("${VALKEY_PORT:-6379}:6379" → 6379), а значение из окружения хоста или написанного вручную .env остаётся как есть — в обоих случаях копия биндит порт рабочего окружения; переменная без того и другого разворачивается в пустую строку, и compose тогда вовсе не публикует host-порт для этой записи. Сканер предупреждает в каждом из этих случаев.

Сканирование, стоящее за этим, выполняется по представлению проекта глазами сценария: рабочее дерево с применённым env: сценария и вырезанными персональными overlay’ями compose.extra — так же, как их вырезает seed-local.yml копии — и вычисляется до того, как копия создана. dwe test list --output json сканирует то же представление, поэтому compose-файл, доступный только через overlay compose.extra, невидим для всего, что он сообщает: копия его никогда не выполняет. dwe validate tests тоже строит это представление для каждого сценария, но только чтобы решить вопрос покрытия — собственное сканирование у него общепроектное, поэтому находка любого другого вида в таком overlay там по-прежнему предупреждает.

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

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

Что каждый запуск получает в собственное распоряжение:

ГарантированоКак
Уникальное имя compose-проекта<base>-t-<scenario>-<run-id>, записанное в docker-конфиг копии (выше)
Host-порты, объявленные под services.<name>.portsпереназначаются на свободные порты в сгенерированном local.yml
Host-порты, которые compose-файл читает из пути vars: через exports.envсвободный порт из того же пакета, записанный этим путём vars: в сгенерированный local.yml
Runtime-состояниесобственный .dwe/ копии — журнал, локи, логи, prompt-кэш
Сгенерированные значениясобственное хранилище сгенерированных значений копии, под тем же .dwe/

Всё остальное перечислено в Документированных ограничениях.

Кто видит COMPOSE_PROJECT_NAME копии. Раннер вычищает все переменные COMPOSE_* из собственного процесса и запускает сценарии параллельно, поэтому никакое значение на уровне процесса не может нести имя. dwe выводит его из конфига копии при каждом запуске дочернего процесса:

Дочерний процессПолучает имя копии
Шаги type: shell и shell-check: — steps: сценария и деплой копиида, как COMPOSE_PROJECT_NAME
Команды type: shell / type: script, вызванные через шаг type: commandда, как COMPOSE_PROJECT_NAME плюс COMPOSE_FILE
Контейнерные команды (service_exec / service_run), вызванные через шаг type: commandда — dwe передаёт имя копии как -p
Шаги type: dweда — дочерний dwe работает в копии и сам резолвит имя из её конфига
Shell-предикаты when: и билтин-проба shellнет — ничего не подставляется; в steps: сценария они вообще не видят COMPOSE_PROJECT_NAME
Всё, что запущено вне dwe, — скрипт, запущенный вручную, другой терминалнет

files_gate только проверяет файлы и не запускает шелл, поэтому получать ему нечего. Host-скрипт, который сам собирает docker compose -p вместо чтения $COMPOSE_PROJECT_NAME, обходит всё это: dwe validate tests предупреждает об этом, а в гайде есть рецепт.

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

ПутьНазначение
.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 — во что обойдётся его запуск и насколько далеко простирается его изоляция. Все числа, выведенные из цепочки, считаются по представлению проекта для сценария, поэтому персональные оверлеи compose.extra исключены: compose-сервис, существующий только в workspace/local.yml, здесь не учитывается нигде — копия его не получает.

{
"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", "shared": true}],
"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Неблокирующие находки сканера изоляции compose — named_volume / external_volume / named_network / external_network, то есть ресурсы, разделяемые с рабочим окружением, плюс interpolated_host_port — host-порт, подставленный из переменной, которую копия этого сценария не переназначает. Блокирующие виды (container_name, raw_host_port) не включаются: они прерывают сценарий ещё до того, как создана копия. Volume, подтверждённый объявлением shared: true в docker.yml, остаётся в списке и несёт "shared": true (иначе ключ опускается) — профиль сообщает факты, а не вердикты, так что вызывающий, которому нужны только неподтверждённые риски, фильтрует по нему. Полные сообщения даёт 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 ниже; находки, не подтверждённые как общие, выводятся один раз на проект как предупреждения (находка interpolated_host_port — только если какой-то сценарий оставляет её порт не переназначенным; никогда как ошибки — многоуровневая политика fail/warn применяется только к dwe test run, а не к статической валидации).

Домен также сканирует shell-текст на хосте, который могут запустить сценарии, — см. Host-скрипты, которые сами собирают имя compose-проекта.

Host-скрипты, которые сами собирают имя compose-проекта

Заголовок раздела «Host-скрипты, которые сами собирают имя compose-проекта»

Второй валидатор, tests.host_project_name, предупреждает, когда shell-текст на хосте передаёт docker compose имя проекта, не выведенное из $COMPOSE_PROJECT_NAME. Внутри dwe test shell-шаги и скрипты получают имя копии в COMPOSE_PROJECT_NAME; скрипт, который сам собирает -p из префикса и имени проекта, обращается вместо этого к живому стеку. Как и весь домен, он работает только в validate, молчит без workspace/tests/, а его предупреждения проваливают dwe validate --strict.

Источники:

ИсточникЧто сканируется
Пользовательские команды type: shell во всех файлах команд, включая скрытыеcmd или payload из argv: [<shell>, -c, <payload>]
Пользовательские команды type: script, у которых script.shell пуст или это POSIX-шелл (sh, bash, dash, ksh, zsh)файлы path, plan, run и cleanup
Шаги type: shell и shell-check: (включая подшаги parallel:) в workspace/deploy.yml, workspace/reset.yml, workspace/lifecycle.yml (run и stop), deploy.yml / reset.yml каждого сервиса и в каждом сценарииcmd
Скрипт, который inline-cmd из строк выше запускает как bash <path>, sh <path> или ./<path> (путь относительно корня проекта)файл, на один уровень вглубь; файл ./<path> — только без shebang или с shebang POSIX-шелла

Файл, до которого можно дойти из нескольких источников, сканируется один раз. Сканер смотрит на вызовы compose в позиции команды — docker compose, docker-compose или ${DOCKER:-docker} compose, в том числе после префиксов VAR=value и sudo, env, exec, command, time, nice с их опциями (sudo -u root, nice -n 10), — читает глобальный флаг -p / --project-name, а также проверяет каждое присваивание COMPOSE_PROJECT_NAME=. Значение, где где-либо есть ссылка на $COMPOSE_PROJECT_NAME, проходит (${COMPOSE_PROJECT_NAME:-dwe-myproj}, ${OVERRIDE:-$COMPOSE_PROJECT_NAME}). Для голого $PROJECT он читает все присваивания PROJECT в том же тексте и предупреждает, только если все они собирают имя без $COMPOSE_PROJECT_NAME; если они расходятся, он молчит. -p после подкоманды (docker compose exec db psql -p 5432) — не флаг compose, и его сканер не читает.

Окно терминала
PROJECT="${PROJECT_PREFIX:-dwe}-${PROJECT_NAME:-myproj}"
docker compose -p "$PROJECT" exec db psql # warns

Исправление оставляет старое выражение как fallback, чтобы скрипт работал и при ручном запуске:

Окно терминала
PROJECT="${COMPOSE_PROJECT_NAME:-${PROJECT_PREFIX:-dwe}-${PROJECT_NAME:-myproj}}"

Подсказка предупреждения печатает именно эту форму с подставленным проблемным значением. В файле скрипта место указывается как line N; в YAML — как ID команды или адрес шага плюс строка внутри cmd.

Сканер никогда не угадывает. Он пропускает, никогда не флагая:

  • литеральное имя (-p myproj), позиционный параметр ($1, ${1:-…}) и подстановку команды ($(…), обратные кавычки);
  • переменную без присваивания в том же тексте — заданную через read, цикл for или в другом файле;
  • скрипт глубже одного файла по ссылкам и путь в ссылке, содержащий подстановку;
  • путь в ссылке под workdir: команды — ссылки резолвятся только относительно корня проекта;
  • docker --context … compose, который не считается вызовом compose;
  • argv без шелла и -c — [docker, compose, …] запускается без шелла, а файл скрипта в [bash, <path>] не отслеживается;
  • скрипты для других интерпретаторов (script.shell: python3, ./tool.py с python-shebang);
  • файлы вне проекта (абсолютный путь, путь через ../, симлинк наружу) и файлы больше 1 MiB;
  • команды, выполняемые в контейнере.

Модель изоляции 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
Host-порт, который целиком одна переменная ("${VALKEY_PORT:-6379}:6379", published: "${PORT}")interpolated_host_portПредупреждение, когда переменная не отслежена или читает порт сервиса, который сценарий не переназначает; переменная, отслеженная до пути vars:, переназначается автоматически и никогда не предупреждает. См. Интерполированные host-порты
Volume/сеть с external: trueexternal_volume / external_networkПредупреждение — риск общего ресурса, а не жёсткое столкновение
Явное name: у volume/сетиnamed_volume / named_networkПредупреждение — тот же класс риска, что и external:

Два вида находок по volume’ам замолкают, если volume объявлен shared: true в docker.yml.

Volume, уже объявленный проектом в docker.yml под resources.volumes.<key> с shared: true, — это намеренный межпроектный ресурс (кэш пакетов, который dwe сам и создаёт через ensure_before), а не ошибка изоляции. Сканер читает workspace/docker.yml (плюс docker.local.yml) и помечает такие находки подтверждёнными, так что рецепт ниже молчит вместо двух предупреждений на volume при каждом запуске:

workspace/docker.yml
resources:
volumes:
npm_cache:
name: dwe_npm_cache
shared: true
compose.yaml
volumes:
npm_cache:
external: true
name: dwe_npm_cache

Сопоставление идёт по эффективному имени: явное name: volume’а после схлопывания цепочки -f (побеждает name: из более позднего overlay), иначе имя из устаревшей развёрнутой формы external: { name: … }, иначе ключ в compose-карте, если ни то ни другое не задано, — оно сравнивается с именем общего volume’а из docker.yml (volume с shared: true резолвится в своё name: дословно, без префикса проекта). Несовпадение не подтверждается, как и shared: false. Находки external_volume и named_volume по этому volume’у помечаются вместе.

Сети не подтверждаются никогда — docker.yml не объявляет сетевых ресурсов, сопоставлять не с чем. Блокирующие виды тоже не подтверждаются.

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

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

  • dwe test run — запускает скан на представлении проекта глазами сценария до того, как создана копия (тот же скан решает, какие пути vars: получат выделенный порт). Каждая находка, не подтверждённая как общая и не являющаяся интерполированным host-портом, который сценарий покрывает, печатается как предупреждение. Блокирующая находка немедленно проваливает сценарий, ничего не создав: ни копии, ни compose-проекта, ни teardown, и отчёт об ошибке для этого запуска не собирается (отчёт, оставшийся от более раннего провала того же сценария, остаётся на месте) — сценарий помечается failed (код выхода 1), и при --keep печатать про сохранённую копию нечего. --skip-isolation-check понижает каждую находку, блокирующую или нет, до предупреждения, и запуск продолжается. См. предпосылку по портам, как избежать находки raw_host_port в принципе: смоделируйте порт через services.<name>.ports.
  • dwe validate tests — выводит каждую находку, не подтверждённую как общая, как предупреждение (интерполированный host-порт — только пока какой-то сценарий его не покрывает), независимо от Blocking; статическая валидация никогда не проваливает сборку из-за риска изоляции, а только заранее его показывает.
  • dwe test list --output json — перечисляет неблокирующие находки, которые сценарий не покрывает, в cost_profile.isolation_findings, включая подтверждённые, с пометкой "shared": true.

Host-порт, записанный переменной ("${VALKEY_PORT:-6379}:6379", $PORT, ${PORT?} …), сдвигается, только если значением переменной управляет копия. Сканер прослеживает переменную до её правила exports.env — последнего с этим name:, у которого when: пуст или истинен, ровно как рендерится .env, — и остальное решает то, что читает правило:

Правило переменнойПокрыто в сценарии, когдаСообщение
from: vars.<path>всегда — раннер выделяет свободный порт для <path> в каждой копии, а сценарий, задавший env.vars: { <path>: <число> }, фиксирует вместо этого свой портконстатирует факт: переменная переназначается через vars.<path>
from: services.<name>.ports.<x>, где <x> объявлен под services.<name>.portsраннер переназначает порты <name> — сервис включён в сценарии и не указан в env.services.disable (сервис с required: true выключить нельзя, поэтому он остаётся покрытым)говорит, что порты <name> переназначаются только в сценариях, где он включён и не указан в env.services.disable
что угодно другое — другой путь, подпуть .port, необъявленный порт, ложный when:, отсутствие правила (окружение хоста, написанный вручную .env, значение по умолчанию после :-)только когда её compose-файла нет в стеке сценария (см. ниже)называет два способа маршрутизации порта: экспортировать его из объявленного порта сервиса (from: services.<name>.ports.<port>) или из vars.<path> через правило exports.env, после чего dwe test переназначит его сам

Первая строка — причина, по которой отслеженной переменной вообще не нужна конфигурация сценария и по которой удаление строки env.vars: { <path>: auto } ничего не меняет. Сервис с required: true, указанный в env.services.disable, остаётся включённым в копии — ровно так загрузчик конфигурации разрешает сгенерированный local.yml, — поэтому его порты по-прежнему переназначаются и находка второй строки покрыта.

При любом правиле сценарий покрывает находку и тогда, когда compose-файла, публикующего порт, нет в его стеке: собственные compose-файлы сервиса (compose: и compose_after: в его service.yml и его оверлеи из local.yml) выпадают из цепочки, когда сценарий выключает сервис, поэтому в этой копии ничто из них порт не занимает. Сервис с required: true остаётся включённым, и его файлы остаются в цепочке; сервис, объявленный в корневом compose-файле, из неё не выпадает никогда. Файл, доступный только через персональный overlay compose.extra, не входит в стек никогда — ни одно представление их не несёт.

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

  • dwe test run отбрасывает её, когда запускаемый сценарий её покрывает — для переменной, отслеженной до vars:, всегда, — а иначе печатает как предупреждение.
  • dwe test list --output json делает то же для каждого сценария, поэтому cost_profile.isolation_findings никогда не перечисляет переменную, отслеженную до vars:; условия when: вычисляются с применёнными переключателями сервисов сценария.
  • У dwe validate tests нет своего сценария, поэтому он отбрасывает находку, только когда её покрывает каждый сценарий, а иначе дописывает (not covered in scenarios: a, b). Переменная, отслеженная до vars:, поэтому молчит в проекте, все сценарии которого загружаются, а файл сценария, который не загрузился, ничего не покрывает. Находка из последней строки таблицы предупреждает без списка сценариев, если только её compose-файла нет в стеке ни одного сценария. Здесь when: вычисляется на собственной конфигурации проекта, поэтому правило, привязанное к сервису, выключенному по умолчанию, но включённому каждым сценарием, всё равно предупреждает.

Поскольку dwe test run запускает dwe validate внутри копии, а копия несёт все файлы сценариев, dwe test run a всё равно выводит это общепроектное предупреждение, пока сценарий b оставляет непокрытой неотслеженную переменную. Запуск оно не блокирует.

КодЗначение
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/ всё равно фиксирует всё. Исключение — гейт изоляции: он срабатывает раньше, чем появляется лог запуска, поэтому под --output json остановленный им сценарий виден только как failed без failed_step и report_dir, а сообщения о находках нигде не сохраняются — чтобы их прочитать, перезапустите без --output json или выполните dwe validate tests.

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) — он не делает их безопасными, он показывает их до того, как они вызовут столкновение. Volume, объявленный shared: true в docker.yml, подтверждён и больше не сообщается: он по-прежнему обходит изоляцию — намеренно.
  • Имена, которые скрипт собирает сам, не изолируются. Host-скрипт, который собирает имя compose-проекта (docker compose -p "${PREFIX}-${NAME}") или имя контейнера (docker exec myproj-db-1 …) из префикса и имени проекта, обращается к рабочему окружению, а не к копии. Берите имя из $COMPOSE_PROJECT_NAME (см. гайд). dwe validate tests предупреждает о самостоятельно собранном значении -p или COMPOSE_PROJECT_NAME=, но не о вручную собранном имени контейнера.
  • Host-порты, которые dwe не может отследить, не изолируются. Автоматическое переназначение покрывает порты, объявленные под services.<name>.ports, и пути vars:, которые правило exports.env направляет в подстановку compose; host-порт, захардкоженный прямо в сыром compose-файле (8080:8080), или подставленный из переменной, которую не отслеживает ни одно активное правило, обходит его — и preflight ports_free не видит ни того, ни другого. Объявите порт под services.<name>.ports либо экспортируйте переменную из vars.<path> через правило exports.env. Сканер изоляции отмечает буквальный порт как блокирующую находку raw_host_port, а неотслеженную переменную — как неблокирующее предупреждение interpolated_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: и телам шагов