Metadata-Version: 2.4
Name: savetest-cli
Version: 0.3.0rc1
Summary: CLI for SaveTest: upload Allure results as a ZIP, as raw files, or by watching a directory
License-Expression: MIT
Requires-Python: >=3.8
Description-Content-Type: text/markdown
Requires-Dist: click>=8.0
Requires-Dist: requests>=2.28

<p align="center">
  <img src="savetest_cli/pwa-512x512.png" alt="SaveTest" width="128">
</p>

# savetest-cli

CLI-утилита для загрузки Allure-результатов в SaveTest. Старые команды `zip_report` и `upload` упаковывают и грузят ZIP. `upload_results` и `upload_files` отправляют сырые файлы `allure-results` без архива. `watch` следит за каталогом и отправляет файлы по мере появления.

## Установка

```bash
pip install savetest-cli
```

---

## Быстрый старт

```bash
# 1. Упаковать директорию с результатами в ZIP
savetest zip_report ./AllureReport

# 2. Загрузить в SaveTest и дождаться создания тест-рана
export SAVETEST_HOST=https://savetest.example.com
export SAVETEST_TOKEN=sat_xxxxxxxxxxxxxxxx
export SAVETEST_PROJECT_ID=42
export SAVETEST_BRANCH=main

savetest upload --test-run-name "Smoke $(date +%Y-%m-%d)"
```

---

## Переменные окружения

Все параметры можно задать через переменные окружения — не нужно указывать флаги при каждом вызове.

| Переменная | Обязательна | Описание |
|---|---|---|
| `SAVETEST_HOST` | да | URL вашего SaveTest, например `https://savetest.example.com` |
| `SAVETEST_TOKEN` | да | API-токен (создаётся в настройках профиля SaveTest) |
| `SAVETEST_PROJECT_ID` | да | ID проекта в SaveTest |
| `SAVETEST_BRANCH` | нет | Ветка Git, привязываемая к тест-рану |
| `SAVETEST_TEST_RUN_ID` | нет | ID существующего тест-рана (если не задан — создаётся новый) |
| `SAVETEST_TEST_RUN_NAME` | нет | Имя нового тест-рана |
| `SAVETEST_TEST_RUN_DESCRIPTION` | нет | Описание нового тест-рана |
| `SAVETEST_TEST_RUN_VERSION` | нет | Версия системы |
| `SAVETEST_TEST_RUN_REVISION` | нет | Ревизия / номер сборки |
| `SAVETEST_TEST_RUN_OBJECT` | нет | Объект тестирования |
| `SAVETEST_EPIC_ID` | нет | ID эпика для привязки тест-рана |
| `SAVETEST_EPIC_NAME` | нет | Наименование эпика в проекте (если ID неизвестен) |
| `SAVETEST_CONFIGURATION_ID` | нет | ID конфигурации тест-рана |
| `SAVETEST_CONFIGURATION_NAME` | нет | Наименование конфигурации в проекте |
| `SAVETEST_INCLUDE_ATTACHMENTS` | нет | `true` — включать вложения в ZIP, `false` — исключать (быстрее) |

---

## Команды

### `savetest zip_report <path>`

Упаковывает директорию с Allure-результатами в ZIP-архив.

По умолчанию вложения (картинки, видео, `*-attachment.json`) **не включаются** — архив получается меньше и грузится быстрее. Флаг `--include-attachments` включает их.

```
savetest zip_report [OPTIONS] PATH
```

| Опция | По умолчанию | Описание |
|---|---|---|
| `PATH` | — | Путь к директории с Allure-результатами |
| `--output`, `-o` | `allure-results.zip` | Путь к выходному ZIP-файлу |
| `--include-attachments` | выключено | Включить вложения (картинки, видео, `*-attachment.json`) |

**Примеры:**

```bash
savetest zip_report ./AllureReport

savetest zip_report ./AllureReport --output report.zip

savetest zip_report ./AllureReport --include-attachments
```

---

### `savetest upload`

Загружает ZIP-файл в SaveTest по частям и ждёт завершения импорта. По завершении выводит ссылку на созданный тест-ран.

```
savetest upload [OPTIONS]
```

| Опция | Env-переменная | По умолчанию | Описание |
|---|---|---|---|
| `--host` | `SAVETEST_HOST` | — | URL SaveTest (**обязательно**) |
| `--token`, `-t` | `SAVETEST_TOKEN` | — | API-токен (**обязательно**) |
| `--project-id`, `-p` | `SAVETEST_PROJECT_ID` | — | ID проекта (**обязательно**) |
| `--zip-file`, `-z` | — | `allure-results.zip` | Путь к ZIP-файлу |
| `--branch`, `-b` | `SAVETEST_BRANCH` | — | Ветка Git |
| `--test-run-name` | `SAVETEST_TEST_RUN_NAME` | — | Имя нового тест-рана |
| `--description` | `SAVETEST_TEST_RUN_DESCRIPTION` | — | Описание нового тест-рана |
| `--version` | `SAVETEST_TEST_RUN_VERSION` | — | Версия системы |
| `--revision` | `SAVETEST_TEST_RUN_REVISION` | — | Ревизия / номер сборки |
| `--test-object` | `SAVETEST_TEST_RUN_OBJECT` | — | Объект тестирования |
| `--epic-id` | `SAVETEST_EPIC_ID` | — | ID эпика |
| `--epic-name` | `SAVETEST_EPIC_NAME` | — | Наименование эпика в проекте |
| `--configuration-id` | `SAVETEST_CONFIGURATION_ID` | — | ID конфигурации |
| `--configuration-name` | `SAVETEST_CONFIGURATION_NAME` | — | Наименование конфигурации |
| `--test-run-id` | `SAVETEST_TEST_RUN_ID` | — | ID существующего тест-рана |
| `--include-attachments` | `SAVETEST_INCLUDE_ATTACHMENTS` | выключено | Сообщить серверу, что ZIP содержит вложения |
| `--chunk-size` | — | `50` | Размер чанка при загрузке (МБ) |
| `--wait` / `--no-wait` | — | `--wait` | Ждать завершения импорта |
| `--timeout` | — | `3600` | Таймаут ожидания (сек) |
| `--poll-interval` | — | `1.5` | Интервал опроса статуса (сек) |

`--host`, `--token` и `--project-id` обязательны. Передавайте их через флаги или переменные окружения.

**Примеры:**

```bash
# Через переменные окружения (рекомендуется)
export SAVETEST_HOST=https://savetest.example.com
export SAVETEST_TOKEN=sat_xxxxxxxxxxxxxxxx
export SAVETEST_PROJECT_ID=42

savetest upload --branch main --test-run-name "Regression $(date +%Y-%m-%d)"

# Новый тест-ран с эпиком, конфигурацией и метаданными
savetest upload \
  --branch main \
  --test-run-name "Smoke $(date +%Y-%m-%d)" \
  --epic-name "Release 2.4" \
  --configuration-name "Chrome / Windows" \
  --version "2.4.0" \
  --description "Nightly smoke on staging"

# Загрузить в существующий тест-ран
savetest upload --branch main --test-run-id 17

# Загрузить другой ZIP с вложениями, не ждать импорта
savetest upload --zip-file full-report.zip --include-attachments --no-wait
```

---

### `savetest upload_results <path>`

Загружает каталог сырых результатов (`allure-results`, до `allure generate`) файлами, без ZIP. Файлы уходят пачками в ту же очередь, что и архив. Вложения по умолчанию не отправляются.

```
savetest upload_results [OPTIONS] PATH
```

Флаги подключения, тест-рана, `--wait` и `--timeout` те же, что у `upload`. Нет `--zip-file` и `--chunk-size`.

```bash
savetest upload_results ./allure-results \
  --test-run-name "Nightly $(date +%Y-%m-%d)" \
  --revision "$CI_COMMIT_SHORT_SHA"
```

---

### `savetest upload_files <file...>`

Загружает один файл или явно перечисленные файлы. Каталог целиком передавайте в `upload_results`. ZIP эта команда не принимает.

```bash
savetest upload_files ./allure-results/*-result.json ./allure-results/executor.json
```

---

### `savetest watch <path>`

Висит и смотрит каталог. Файл готов, когда размер не меняется `--settle` секунд и JSON читается. Готовые файлы не уходят по одному: если они появляются подряд, watch копит их `--batch-window` секунд после последнего нового и отправляет пачкой. Дольше `--batch-max-wait` секунд пачка не держится. Одна отправка — не больше 20 файлов или 32 МБ. Каталога может ещё не быть: команда дождётся его появления.

Остановка:

- Ctrl+C или `SIGTERM` — забирает уже готовые файлы и выходит;
- `--timeout СЕКУНД` — то же по таймеру, `0` (по умолчанию) значит «пока не остановят»;
- `--idle-timeout СЕКУНД` — выйти, когда каталог затих. `0` выключает этот режим.

Первая пачка создаёт тест-ран, если не передан `--test-run-id`. Следующие файлы пишутся в тот же ран: пока ран не создан, вторая пачка ждёт. Код возврата `1`, если хотя бы один импорт не завершился как `completed`.

Вложение уезжает вместе с `*-result.json`, который на него ссылается. Результат подождёт вложение до `--attachment-wait` секунд. Пачка без файла результата не отправляется.

Привязки к фреймворку нет. После `--` watch запускает любую команду как есть и не разбирает её аргументы: pytest, JUnit, Maven, Gradle, npm или свой скрипт. Ему всё равно, кто пишет файлы. Нужны только `*-result.json` в каталоге, который указан первым аргументом. `pytest` в примерах ниже — один из вариантов, не требование.

```bash
savetest watch ./allure-results \
  --include-attachments \
  --test-run-name "Локальный прогон" \
  --timeout 0
```

---

## Запуск в CI

Команды `zip_report` и `upload` остаются для уже настроенных пайплайнов. Для нового пайплайна берите `upload_results`: архив на раннере не нужен, сервер не собирает ZIP из чанков, а код возврата `1` при `failed` и `partial` роняет джобу.

Правила:

- Токен — маскируемая переменная `SAVETEST_TOKEN`, не пароль пользователя и не лог.
- Загрузку ставьте после тестов с `when: always` / `if: always()`, иначе упавший прогон не попадёт в SaveTest.
- Если тесты и загрузка — разные джобы, отдайте каталог `allure-results` артефактом тоже с `when: always`.
- `--wait` оставляйте включённым: джоба должна завершиться только когда импорт дошёл до конца.
- Вложения включайте только если они реально нужны. По умолчанию CLI их не шлёт.
- `0.3.0rc1` — предварительная версия для SaveTest 1.5.0: `pip install savetest-cli==0.3.0rc1`. Обычный `pip install savetest-cli` остаётся на последней стабильной версии.

### Результаты по мере прогона

`upload_results` показывает ран только после тестов. Чтобы кейсы появлялись во время прогона, запускайте тесты из `watch`, как это делает Allure TestOps: `allurectl watch -- ./gradlew test`. Команда пишется после `--`. Это не команда pytest: на её месте может быть любой фреймворк и любой запускаемый файл. `watch` остаётся на переднем плане, сам ждёт эту команду и выходит вместе с ней. Сборка не остаётся с фоновым процессом.

`--idle-timeout` для CI не нужен: пауза между тестами похожа на конец прогона. Код возврата — код тестов, а если тесты зелёные и импорт упал, то `1`.

```bash
mkdir -p allure-results
savetest watch allure-results \
  --test-run-name "CI ${CI_PIPELINE_ID}" \
  --revision "${CI_COMMIT_SHORT_SHA}" \
  -- pytest --alluredir=allure-results
```

#### GitLab CI

```yaml
test:
  stage: test
  image: python:3.11-slim
  variables:
    SAVETEST_HOST: https://savetest.example.com
    SAVETEST_PROJECT_ID: "42"
    SAVETEST_BRANCH: $CI_COMMIT_REF_NAME
  script:
    - pip install --quiet pytest allure-pytest savetest-cli
    - mkdir -p allure-results
    - savetest watch allure-results
        --test-run-name "GitLab ${CI_PROJECT_NAME} #${CI_PIPELINE_ID}"
        --revision "${CI_COMMIT_SHORT_SHA}"
        -- pytest --alluredir=allure-results
  artifacts:
    when: always
    expire_in: 1 day
    paths:
      - allure-results/
```

`SAVETEST_TOKEN` — Masked variable. Артефакт оставлен как запасная копия: в SaveTest результаты уже ушли во время тестов.

#### GitHub Actions

```yaml
- name: Watch and test
  env:
    SAVETEST_HOST: ${{ vars.SAVETEST_HOST }}
    SAVETEST_TOKEN: ${{ secrets.SAVETEST_TOKEN }}
    SAVETEST_PROJECT_ID: ${{ vars.SAVETEST_PROJECT_ID }}
    SAVETEST_BRANCH: ${{ github.ref_name }}
  run: |
    pip install pytest allure-pytest savetest-cli
    mkdir -p allure-results
    savetest watch allure-results \
      --test-run-name "GitHub ${{ github.workflow }} #${{ github.run_number }}" \
      --revision "${{ github.sha }}" \
      -- pytest --alluredir=allure-results
```

### GitLab CI

```yaml
test:
  stage: test
  script:
    - pytest --alluredir=allure-results
  artifacts:
    when: always
    expire_in: 1 day
    paths:
      - allure-results/

upload_savetest:
  stage: report
  when: always
  image: python:3.11-slim
  needs: ["test"]
  variables:
    SAVETEST_HOST: https://savetest.example.com
    SAVETEST_PROJECT_ID: "42"
    SAVETEST_BRANCH: $CI_COMMIT_REF_NAME
  script:
    - pip install --quiet savetest-cli
    - savetest upload_results allure-results
        --test-run-name "GitLab ${CI_PROJECT_NAME} #${CI_PIPELINE_ID}"
        --revision "${CI_COMMIT_SHORT_SHA}"
```

`SAVETEST_TOKEN` задайте в **Settings → CI/CD → Variables** с флагами Masked и Protected. Это вариант «загрузить всё после тестов», без слежения.

Если тесты и загрузка в одной джобе, артефакт не нужен: вызывайте `upload_results` в `after_script`.

### GitHub Actions

```yaml
- name: Tests
  if: always()
  run: pytest --alluredir=allure-results

- name: Upload Allure results
  if: always()
  env:
    SAVETEST_HOST: ${{ vars.SAVETEST_HOST }}
    SAVETEST_TOKEN: ${{ secrets.SAVETEST_TOKEN }}
    SAVETEST_PROJECT_ID: ${{ vars.SAVETEST_PROJECT_ID }}
    SAVETEST_BRANCH: ${{ github.ref_name }}
  run: |
    pip install savetest-cli
    savetest upload_results allure-results \
      --test-run-name "GitHub ${{ github.workflow }} #${{ github.run_number }}" \
      --revision "${{ github.sha }}"
```

---

## Настройка в Jenkins

Этот пример оставлен для ZIP-команд `zip_report` и `upload`. Чтобы не собирать архив, замените две команды на одну: `savetest upload_results "${WORKSPACE}/allure-results"`.

Добавьте токен в **Jenkins → Manage Jenkins → Credentials** (тип: Secret text, ID: `savetest-token`).

В `Jenkinsfile` используйте `withCredentials` и `parameters`, чтобы не хранить конфигурацию в коде:

```groovy
pipeline {
    agent any

    parameters {
        string(name: 'SAVETEST_HOST',       defaultValue: '', description: 'URL SaveTest')
        string(name: 'SAVETEST_PROJECT_ID', defaultValue: '', description: 'ID проекта в SaveTest')
        string(name: 'SAVETEST_BRANCH',     defaultValue: '', description: 'Ветка Git')
        string(name: 'SAVETEST_TEST_RUN_ID',defaultValue: '', description: 'ID тест-рана (пусто = создать новый)')
        booleanParam(name: 'INCLUDE_ATTACHMENTS', defaultValue: false, description: 'Включать вложения в ZIP')
    }

    stages {
        // ... ваши стадии запуска тестов ...

        stage('Upload to SaveTest') {
            steps {
                withCredentials([string(credentialsId: 'savetest-token', variable: 'SAVETEST_TOKEN')]) {
                    sh '''
                        pip install savetest-cli

                        savetest zip_report "${WORKSPACE}/AllureReport" \
                            $([ "${INCLUDE_ATTACHMENTS}" = "true" ] && echo "--include-attachments")

                        savetest upload \
                            --host "${SAVETEST_HOST}" \
                            --project-id "${SAVETEST_PROJECT_ID}" \
                            --branch "${SAVETEST_BRANCH}" \
                            --test-run-name "Jenkins ${JOB_NAME} #${BUILD_NUMBER}" \
                            $( [ -n "${SAVETEST_TEST_RUN_ID}" ] && echo "--test-run-id ${SAVETEST_TEST_RUN_ID}" ) \
                            $( [ "${INCLUDE_ATTACHMENTS}" = "true" ] && echo "--include-attachments" )
                    '''
                }
            }
        }
    }
}
```

> `SAVETEST_TOKEN` берётся из Jenkins Credentials и передаётся в окружение через `withCredentials` — в лог и в код сборки он не попадает.

### Результаты по мере прогона

В Jenkins `watch` на переднем плане без команды тестов шаг не отпустит: команда специально не завершается. Вариант `watch &` тоже опасен. Jenkins связан с шагом трубами stdout и stderr и ждёт их закрытия. Фоновый процесс наследует эти трубы, шаг может не закончиться, даже когда оболочка уже вышла. Это описано в [Spawning Processes From Build](https://www.jenkins.io/doc/book/managing/spawning-processes/).

Allure TestOps так не запускает слежение. В CI это `allurectl watch -- ./gradlew clean test`: агент остаётся главным процессом, сам стартует тесты и выходит, когда они закончились. Плагин Jenkins делает то же обёрткой `withAllureUpload { sh './gradlew clean test' }`.

Здесь тот же приём. После `--` — любая команда, не только pytest: `mvn test`, `./gradlew clean test`, `npm test` или свой скрипт. Watch к фреймворку не привязан. Пока команда идёт, шаг занят ею. Когда она выходит, watch дописывает готовую пачку и завершает шаг. Отдельный `kill` не нужен. Лимит времени ставьте на стадию Jenkins, не через `--timeout`: ноль у `--timeout` здесь значит «пока не кончится команда после `--`». В примере ниже стоит pytest только как иллюстрация.

```groovy
stage('Test') {
    steps {
        withCredentials([string(credentialsId: 'savetest-token', variable: 'SAVETEST_TOKEN')]) {
            sh '''
                pip install savetest-cli pytest allure-pytest
                mkdir -p allure-results
                savetest watch allure-results \
                    --host "${SAVETEST_HOST}" \
                    --project-id "${SAVETEST_PROJECT_ID}" \
                    --branch "${SAVETEST_BRANCH}" \
                    --test-run-name "Jenkins ${JOB_NAME} #${BUILD_NUMBER}" \
                    --revision "${BUILD_NUMBER}" \
                    -- pytest --alluredir=allure-results
            '''
        }
    }
}
```

---

## Получение API-токена

1. Войдите в SaveTest.
2. Откройте **Настройки профиля → API-токены**.
3. Создайте новый токен и скопируйте его значение.
4. Добавьте токен в Jenkins Credentials (или экспортируйте локально через `export SAVETEST_TOKEN=...`).
