From 5adb9db4ca07c892d4245dc5369ea8c78e6441b1 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Tue, 15 Sep 2026 22:24:52 +0300 Subject: [PATCH 1/4] feat: delegate acceptance tests to test lab --- .env.example | 1 + .github/workflows/app-smoke.yml | 17 ++- AGENTS.md | 3 + README.md | 12 +- Taskfile.yml | 2 +- config/versions.env | 1 + docs/decisions/0004-test-lab-ownership.md | 25 ++++ docs/development.md | 5 +- docs/index.md | 4 +- docs/runbook.md | 6 +- mkdocs.yml | 1 + scripts/check-apps.ps1 | 141 ---------------------- scripts/check-compose.ps1 | 26 ++-- scripts/run-test-lab.ps1 | 58 +++++++++ 14 files changed, 135 insertions(+), 167 deletions(-) create mode 100644 docs/decisions/0004-test-lab-ownership.md delete mode 100644 scripts/check-apps.ps1 create mode 100644 scripts/run-test-lab.ps1 diff --git a/.env.example b/.env.example index 990f39c..39e9f3a 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,7 @@ CHANNEL_GATEWAY_CONTEXT=../../portable-agent-channel-gateway ACTION_SERVICE_CONTEXT=../../portable-agent-action-service MCP_GATEWAY_CONTEXT=../../portable-agent-mcp-gateway CALENDAR_MCP_CONTEXT=../../portable-agent-calendar-mcp +TEST_LAB_PATH=../portable-agent-test-lab KEYCLOAK_DB_USER=keycloak KEYCLOAK_DB_PASSWORD=local_keycloak_db_change_me diff --git a/.github/workflows/app-smoke.yml b/.github/workflows/app-smoke.yml index b60e66e..2aaf771 100644 --- a/.github/workflows/app-smoke.yml +++ b/.github/workflows/app-smoke.yml @@ -9,7 +9,7 @@ on: - "compose/**" - "config/versions.env" - "Taskfile.yml" - - "scripts/check-apps.ps1" + - "scripts/run-test-lab.ps1" - "scripts/check-compose.ps1" - "scripts/check-keycloak.ps1" - "scripts/start-local.ps1" @@ -31,6 +31,7 @@ jobs: ACTION_SERVICE_CONTEXT: ${{ github.workspace }}/repos/action-service MCP_GATEWAY_CONTEXT: ${{ github.workspace }}/repos/mcp-gateway CALENDAR_MCP_CONTEXT: ${{ github.workspace }}/repos/calendar-mcp + TEST_LAB_PATH: ${{ github.workspace }}/repos/test-lab steps: - name: Получить deploy uses: actions/checkout@v7 @@ -45,6 +46,7 @@ jobs: "action=$($versions.ACTION_SERVICE_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "gateway=$($versions.MCP_GATEWAY_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT "calendar=$($versions.CALENDAR_MCP_IMAGE.Split(':')[-1])" >> $env:GITHUB_OUTPUT + "test_lab=$($versions.TEST_LAB_REF)" >> $env:GITHUB_OUTPUT - name: Получить Channel Gateway uses: actions/checkout@v7 @@ -81,13 +83,24 @@ jobs: ref: ${{ steps.versions.outputs.calendar }} path: repos/calendar-mcp + - name: Получить Test Lab + uses: actions/checkout@v7 + with: + repository: portable-agent/test-lab + ref: ${{ steps.versions.outputs.test_lab }} + path: repos/test-lab + + - uses: go-task/setup-task@v2 + with: + version: 3.53.1 + - name: Запустить полный срез shell: pwsh run: ./scripts/start-local.ps1 -Apps - name: Проверить путь от текста до события shell: pwsh - run: ./scripts/check-apps.ps1 -TimeoutSeconds 60 + run: ./scripts/run-test-lab.ps1 - name: Удалить только тестовый Compose project if: always() diff --git a/AGENTS.md b/AGENTS.md index f5449f4..5ef9b5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,9 @@ - `docs/` — русская документация; - `.github/workflows/` — CI. +`deploy` управляет окружением, а black-box сценарии принадлежат репозиторию `test-lab`. Не добавляй +сюда HTTP-проверки продуктового поведения; вызывай единый сценарий через `run-test-lab.ps1`. + ## Команды ```powershell diff --git a/README.md b/README.md index 0197ab3..7b80662 100644 --- a/README.md +++ b/README.md @@ -62,9 +62,10 @@ task test:e2e сохраняет локальные данные. `task reset` удаляет только volumes текущего Compose project и всегда требует явного подтверждения. -`check-apps.ps1` отправляет demo-команду в Channel Gateway, проверяет предложение, создаёт и -подтверждает действие, ждёт Temporal workflow и проверяет, что Calendar MCP сохранил ровно одно -событие с тем же `eventId`. +`deploy` поднимает окружение, затем `run-test-lab.ps1` передаёт локальную тестовую конфигурацию в +соседний репозиторий `portable-agent-test-lab`. Сам black-box сценарий принадлежит `test-lab`: он +отправляет demo-команду в Channel Gateway, подтверждает действие, ждёт Temporal workflow и проверяет, +что Calendar MCP сохранил ровно одно событие с тем же `eventId`. Та же проверка выполняется workflow `Full application smoke` при изменениях Compose-пути. Workflow читает полные Git SHA из `config/versions.env`, собирает именно эти версии сервисов в отдельном @@ -72,8 +73,9 @@ Compose project и всегда удаляет только созданные `-Apps` собирает образы из соседних локальных репозиториев через `compose/apps.local.yaml`. Пути можно переопределить переменными `CHANNEL_GATEWAY_CONTEXT`, `AGENT_RUNTIME_CONTEXT`, `ACTION_SERVICE_CONTEXT`, -`MCP_GATEWAY_CONTEXT` и `CALENDAR_MCP_CONTEXT`; вход в GHCR для локального запуска не нужен. По -умолчанию Channel Gateway доступен на `http://localhost:18084`, Agent Runtime — на +`MCP_GATEWAY_CONTEXT` и `CALENDAR_MCP_CONTEXT`; вход в GHCR для локального запуска не нужен. +`TEST_LAB_PATH` по умолчанию указывает на соседний `../portable-agent-test-lab`; путь можно +переопределить в `.env`. Channel Gateway доступен на `http://localhost:18084`, Agent Runtime — на `http://localhost:18080`, Action API — на `http://localhost:18081`, MCP Gateway — на `http://localhost:18083`, а Calendar MCP — на `http://localhost:18082`. diff --git a/Taskfile.yml b/Taskfile.yml index a759796..a4efd78 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -72,7 +72,7 @@ tasks: desc: Поднять полный срез и выполнить сквозной тест встречи cmds: - task: services:up - - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/check-apps.ps1' + - '{{.POWERSHELL}} {{.POWERSHELL_ARGS}} -File ./scripts/run-test-lab.ps1' verify: desc: Выполнить локальные проверки конфигурации и документации diff --git a/config/versions.env b/config/versions.env index 902e5e5..7213b56 100644 --- a/config/versions.env +++ b/config/versions.env @@ -16,3 +16,4 @@ AGENT_RUNTIME_IMAGE=ghcr.io/portable-agent/agent-runtime:4dd541e459d0bdbf889ddc1 ACTION_SERVICE_IMAGE=ghcr.io/portable-agent/action-service:8d449ab990001e31cd42a7655a16c5716c5d6f20 MCP_GATEWAY_IMAGE=ghcr.io/portable-agent/mcp-gateway:0a3884d52ad1156ed173a4ecf9182ea3a0d2e686 CALENDAR_MCP_IMAGE=ghcr.io/portable-agent/calendar-mcp:bbab64774eab482c9d3543c7b8220b4cb42df56e +TEST_LAB_REF=02599218e8a7986d83db71b672f5da64f9a72cec diff --git a/docs/decisions/0004-test-lab-ownership.md b/docs/decisions/0004-test-lab-ownership.md new file mode 100644 index 0000000..9f96b08 --- /dev/null +++ b/docs/decisions/0004-test-lab-ownership.md @@ -0,0 +1,25 @@ +# ADR 0004: владение сквозными тестами + +Статус: принято. + +## Контекст + +Первый сквозной сценарий находился в `deploy`. Из-за этого репозиторий развёртывания одновременно +управлял окружением и описывал продуктовое поведение. Такой сценарий было трудно переиспользовать для +Compose, Kubernetes и удалённого тестового стенда. + +## Решение + +`deploy` владеет жизненным циклом окружения: запускает сервисы, ждёт healthchecks и передаёт только +локальную тестовую конфигурацию. Репозиторий `test-lab` владеет black-box сценариями и их порогами. + +Команда `task test:e2e` остаётся единой точкой входа. Локально она использует соседний репозиторий, +заданный через `TEST_LAB_PATH`; CI явно получает обе репы. Временные учётные данные берутся из +локального Keycloak fixture и не копируются в Git. + +## Последствия + +- один и тот же сценарий можно запускать против Compose и будущего Kubernetes-стенда; +- `deploy` не дублирует HTTP-проверки продукта; +- unit-, component- и integration-тесты остаются в репозиториях сервисов; +- добавление нового E2E требует сценария в `test-lab`, но не нового скрипта в `deploy`. diff --git a/docs/development.md b/docs/development.md index 8f5e7a8..b68bced 100644 --- a/docs/development.md +++ b/docs/development.md @@ -18,8 +18,9 @@ Taskfile — единая точка входа. Внутренние PowerShell Допустимые имена: `channel-gateway`, `agent-runtime`, `action-service`, `mcp-gateway` и `calendar-mcp`. Неизвестное имя отклоняется до вызова Docker. -`task test:e2e` поднимает полный локальный срез, ждёт healthchecks и проверяет путь от текста до -сохранённого события. Следующий пакет передаст запуск black-box сценариев репозиторию `test-lab`. +`task test:e2e` поднимает полный локальный срез и ждёт healthchecks. Затем `deploy` вызывает +`task test:e2e` в соседнем репозитории `test-lab`, который владеет black-box сценарием от текста до +сохранённого события. Если репозитории лежат не рядом, задай `TEST_LAB_PATH` в локальном `.env`. `task down` не удаляет данные. `task reset` предназначен для явного пересоздания тестового состояния и запрашивает подтверждение. diff --git a/docs/index.md b/docs/index.md index 003507a..d4296c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,12 @@ # Deploy Репозиторий содержит инженерный полигон Portable Agent. Compose поднимает инфраструктуру и локальный -полный локальный slice `Channel Gateway → Agent Runtime → Action Service → MCP Gateway → Calendar MCP`. Минимальный +slice `Channel Gateway → Agent Runtime → Action Service → MCP Gateway → Calendar MCP`. Минимальный Helm chart устанавливается и проверяется в одноразовом k3d-кластере. Текущий результат: -1. acceptance-тест полного backend-пути в `test-lab`; +1. запуск acceptance-теста полного backend-пути из отдельного `test-lab`; 2. единый локальный JWT с отдельными audience сервисов; 3. общий Channel Gateway и Agent Runtime с закрытыми API и контрактом `2.2.0`; 4. воспроизводимый Compose с локальной сборкой всех приложений. diff --git a/docs/runbook.md b/docs/runbook.md index 1305fcb..ffadee5 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -33,8 +33,10 @@ task test:e2e ``` -Она отправляет текст через Channel Gateway, затем создаёт действие с тем же случайным `requestKey`, -подтверждает его и сверяет результат Action Service с событием в тестовом календарном коннекторе. +Команда поднимает окружение и запускает сценарий из соседнего `test-lab`. Он отправляет текст через +Channel Gateway, создаёт действие со случайным `requestKey`, подтверждает его и сверяет результат +Action Service с событием в тестовом календарном коннекторе. Если `test-lab` лежит в другой папке, +задай `TEST_LAB_PATH` в `.env`. Первый Docker build скачивает Gradle и Python packages и может быть заметно медленнее повторных запусков. BuildKit сохраняет слои для следующих сборок. diff --git a/mkdocs.yml b/mkdocs.yml index e8c5b33..d85fe3b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,3 +14,4 @@ nav: - Отдельный deploy-репозиторий: decisions/0001-deploy-repository.md - Платформа разработки: decisions/0002-developer-platform.md - Единые команды разработчика: decisions/0003-taskfile-workflow.md + - Владение сквозными тестами: decisions/0004-test-lab-ownership.md diff --git a/scripts/check-apps.ps1 b/scripts/check-apps.ps1 deleted file mode 100644 index b4797b5..0000000 --- a/scripts/check-apps.ps1 +++ /dev/null @@ -1,141 +0,0 @@ -param([int]$TimeoutSeconds = 30) -$ErrorActionPreference = "Stop" - -function Get-LocalSetting([string]$Name) { - $value = [Environment]::GetEnvironmentVariable($Name) - foreach ($path in @(".env", ".env.example")) { - if ($value -or -not (Test-Path -LiteralPath $path)) { continue } - $line = Get-Content -LiteralPath $path | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -Last 1 - if ($line) { $value = $line.Substring($line.IndexOf('=') + 1) } - } - if (-not $value) { throw "Не задан $Name." } - return $value -} - -function Test-SameDateTime([string]$Actual, [string]$Expected) { - try { - $style = [Globalization.DateTimeStyles]::RoundtripKind - $culture = [Globalization.CultureInfo]::InvariantCulture - $actualDate = [DateTimeOffset]::Parse($Actual, $culture, $style) - $expectedDate = [DateTimeOffset]::Parse($Expected, $culture, $style) - return $actualDate.UtcDateTime -eq $expectedDate.UtcDateTime ` - -and $actualDate.Offset -eq $expectedDate.Offset - } catch { - return $false - } -} - -$realm = Get-Content -Raw -LiteralPath "compose/keycloak/portable-agent-realm.json" | ConvertFrom-Json -$client = $realm.clients | Where-Object clientId -eq "portable-agent-local" -$user = $realm.users | Where-Object username -eq "local-user" -$password = ($user.credentials | Where-Object type -eq "password").value -$keycloakUrl = "http://localhost:$(Get-LocalSetting 'KEYCLOAK_PORT')" -$channelUrl = "http://localhost:$(Get-LocalSetting 'CHANNEL_GATEWAY_PORT')" -$actionUrl = "http://localhost:$(Get-LocalSetting 'ACTION_SERVICE_PORT')" -$calendarUrl = "http://localhost:$(Get-LocalSetting 'CALENDAR_MCP_PORT')" - -$token = (Invoke-RestMethod -Method Post ` - -Uri "$keycloakUrl/realms/$($realm.realm)/protocol/openid-connect/token" ` - -Body @{ - client_id = $client.clientId - username = $user.username - password = $password - grant_type = "password" - }).access_token -$headers = @{ Authorization = "Bearer $token" } -$requestKey = "compose-check-$([guid]::NewGuid().ToString('N'))" -$payload = @{ - title = "Compose check" - startAt = "2026-09-12T10:00:00+03:00" - endAt = "2026-09-12T10:30:00+03:00" - timeZone = "Europe/Moscow" -} - -function Invoke-JsonRequest( - [string]$Method, - [string]$Uri, - [hashtable]$Headers, - [string]$Body = $null -) { - $request = @{ - Method = $Method - Uri = $Uri - Headers = $Headers - } - if ($Body) { - $request.ContentType = "application/json; charset=utf-8" - $request.Body = $Body - } - if ($PSVersionTable.PSEdition -eq "Desktop") { - $request.UseBasicParsing = $true - } - $response = Invoke-WebRequest @request - if ($PSVersionTable.PSEdition -eq "Desktop") { - return $response.Content | ConvertFrom-Json - } - return $response.Content | ConvertFrom-Json -DateKind String -} -$proposalRequest = @{ - requestKey = $requestKey - text = "Создай встречу `"$($payload.title)`" с $($payload.startAt) до $($payload.endAt)" - context = @{ - locale = "ru-RU" - timeZone = $payload.timeZone - } -} | ConvertTo-Json -Depth 5 -$proposalResult = Invoke-JsonRequest -Method Post -Uri "$channelUrl/api/v1/messages" ` - -Headers $headers -Body $proposalRequest -$proposal = $proposalResult.proposal -$proposalErrors = @() -if (-not $proposal) { $proposalErrors += "proposal" } -if ($proposalResult.clarification) { $proposalErrors += "clarification" } -if (-not $proposal.requiresApproval) { $proposalErrors += "requiresApproval" } -if ($proposal.kind -ne "calendar.create_event") { $proposalErrors += "kind" } -if ($proposal.connector -ne "fake-calendar") { $proposalErrors += "connector" } -if ($proposal.payload.title -ne $payload.title) { $proposalErrors += "title" } -if (-not (Test-SameDateTime $proposal.payload.startAt $payload.startAt)) { $proposalErrors += "startAt" } -if (-not (Test-SameDateTime $proposal.payload.endAt $payload.endAt)) { $proposalErrors += "endAt" } -if ($proposal.payload.timeZone -ne $payload.timeZone) { $proposalErrors += "timeZone" } -if ($proposalErrors.Count -gt 0) { - $safeResponse = $proposalResult | ConvertTo-Json -Depth 8 -Compress - throw "Channel Gateway proposal не прошёл проверки: $($proposalErrors -join ', '). Ответ: $safeResponse" -} -$body = @{ - kind = $proposal.kind - connector = $proposal.connector - requestKey = $requestKey - payload = $proposal.payload -} | ConvertTo-Json -Depth 5 -$action = Invoke-JsonRequest -Method Post -Uri "$actionUrl/api/v1/actions" ` - -Headers $headers -Body $body -$decision = @{ decision = "CONFIRM"; payloadHash = $action.payloadHash } | ConvertTo-Json -Invoke-RestMethod -Method Post -Uri "$actionUrl/api/v1/actions/$($action.id)/decisions" ` - -Headers $headers -ContentType "application/json" -Body $decision | Out-Null - -$deadline = (Get-Date).AddSeconds($TimeoutSeconds) -do { - Start-Sleep -Milliseconds 500 - $saved = Invoke-JsonRequest -Method Get -Uri "$actionUrl/api/v1/actions/$($action.id)" -Headers $headers -} while ($saved.status -notin @("SUCCEEDED", "FAILED") -and (Get-Date) -lt $deadline) -if ($saved.status -ne "SUCCEEDED") { - throw "Action $($action.id) не выполнен за $TimeoutSeconds секунд: $($saved.status)." -} -if (-not (Test-SameDateTime $saved.payload.startAt $payload.startAt) ` - -or -not (Test-SameDateTime $saved.payload.endAt $payload.endAt) ` - -or $saved.payload.timeZone -ne $payload.timeZone) { - throw "Action Service изменил время или часовой пояс подтверждённого payload." -} - -$eventResponse = Invoke-JsonRequest -Method Get -Uri "$calendarUrl/test/events?requestKey=$requestKey" ` - -Headers @{ "X-Test-Key" = Get-LocalSetting "CALENDAR_TEST_API_KEY" } -$events = @($eventResponse.events) -$event = $events[0] -if ($events.Count -ne 1 -or $event.eventId -ne $saved.result.eventId ` - -or $event.requestKey -ne $requestKey -or $event.title -ne $payload.title ` - -or -not (Test-SameDateTime $event.startAt $payload.startAt) ` - -or -not (Test-SameDateTime $event.endAt $payload.endAt) ` - -or $event.timeZone -ne $payload.timeZone) { - throw "Calendar MCP не сохранил ожидаемое событие." -} - -Write-Host "Полный срез работает через Channel Gateway: proposal $($proposal.proposalId), action $($action.id), event $($saved.result.eventId)." diff --git a/scripts/check-compose.ps1 b/scripts/check-compose.ps1 index f122023..0cd8973 100644 --- a/scripts/check-compose.ps1 +++ b/scripts/check-compose.ps1 @@ -141,7 +141,7 @@ foreach ($taskName in @( } if ($taskfile -notmatch 'scripts/start-local\.ps1' ` -or $taskfile -notmatch 'scripts/service-local\.ps1' ` - -or $taskfile -notmatch 'scripts/check-apps\.ps1' ` + -or $taskfile -notmatch 'scripts/run-test-lab\.ps1' ` -or $taskfile -notmatch '(?m)^ prompt:') { throw "Taskfile не связывает команды запуска, управления, E2E и безопасного reset." } @@ -173,13 +173,17 @@ if ($keycloakCheck -notmatch 'portable-agent-realm.json' ` -or $keycloakCheck -notmatch 'calendar:write') { throw "Runtime-проверка Keycloak должна сверять JWT с realm fixture." } -$appCheck = Get-Content -Raw -LiteralPath "scripts/check-apps.ps1" -if ($appCheck -notmatch 'ConvertFrom-Json -DateKind String') { - throw "check-apps.ps1 должен сохранять исходные смещения времени из JSON." +$testLabRunnerPath = "scripts/run-test-lab.ps1" +if (-not (Test-Path -LiteralPath $testLabRunnerPath)) { + throw "Нет адаптера запуска сценариев из test-lab." } -foreach ($required in @("CHANNEL_GATEWAY_PORT", "/api/v1/messages", "requestKey", "requiresApproval", "proposalId")) { - if ($appCheck -notmatch [regex]::Escape($required)) { - throw "Сквозная проверка приложений не использует Agent Runtime: нет $required." +if (Test-Path -LiteralPath "scripts/check-apps.ps1") { + throw "Продуктовый E2E не должен дублироваться в deploy." +} +$testLabRunner = Get-Content -Raw -LiteralPath $testLabRunnerPath +foreach ($required in @("TEST_LAB_PATH", "portable-agent-realm.json", "CALENDAR_TEST_API_KEY", "task", "test:e2e")) { + if ($testLabRunner -notmatch [regex]::Escape($required)) { + throw "Адаптер test-lab не содержит $required." } } $versions = Get-Content -Raw -LiteralPath "config/versions.env" @@ -189,17 +193,15 @@ if ($versions -notmatch '(?m)^AGENT_RUNTIME_IMAGE=ghcr\.io/portable-agent/agent- if ($versions -notmatch '(?m)^CHANNEL_GATEWAY_IMAGE=ghcr\.io/portable-agent/channel-gateway:[0-9a-f]{40}\r?$') { throw "Channel Gateway image должен быть закреплён полным Git SHA." } -foreach ($oldName in @("utterance", "actor_id", "available_connectors", "requires_approval")) { - if ($appCheck -match [regex]::Escape($oldName)) { - throw "Сквозная проверка приложений содержит старое поле $oldName." - } +if ($versions -notmatch '(?m)^TEST_LAB_REF=[0-9a-f]{40}\r?$') { + throw "Test Lab должен быть закреплён полным Git SHA." } $appWorkflowPath = ".github/workflows/app-smoke.yml" if (-not (Test-Path -LiteralPath $appWorkflowPath)) { throw "Нет CI-проверки полного Compose-среза." } $appWorkflow = Get-Content -Raw -LiteralPath $appWorkflowPath -foreach ($required in @("versions.env", "channel-gateway", "agent-runtime", "start-local.ps1 -Apps", "check-apps.ps1", "stop-local.ps1 -DeleteData", "if: always()")) { +foreach ($required in @("versions.env", "channel-gateway", "agent-runtime", "repository: portable-agent/test-lab", 'ref: ${{ steps.versions.outputs.test_lab }}', "start-local.ps1 -Apps", "run-test-lab.ps1", "stop-local.ps1 -DeleteData", "if: always()")) { if ($appWorkflow -notmatch [regex]::Escape($required)) { throw "CI-проверка полного среза не содержит $required." } diff --git a/scripts/run-test-lab.ps1 b/scripts/run-test-lab.ps1 new file mode 100644 index 0000000..4c1d017 --- /dev/null +++ b/scripts/run-test-lab.ps1 @@ -0,0 +1,58 @@ +param([string]$TestLabPath = "") + +$ErrorActionPreference = "Stop" + +function Get-LocalSetting([string]$Name) { + $value = [Environment]::GetEnvironmentVariable($Name) + foreach ($path in @(".env", ".env.example")) { + if ($value -or -not (Test-Path -LiteralPath $path)) { continue } + $line = Get-Content -LiteralPath $path | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -Last 1 + if ($line) { $value = $line.Substring($line.IndexOf('=') + 1) } + } + if (-not $value) { throw "Set $Name in the environment or local .env file." } + return $value +} + +if (-not $TestLabPath) { $TestLabPath = Get-LocalSetting "TEST_LAB_PATH" } +$resolvedTestLabPath = (Resolve-Path -LiteralPath $TestLabPath).Path +if (-not (Test-Path -LiteralPath (Join-Path $resolvedTestLabPath "Taskfile.yml"))) { + throw "TEST_LAB_PATH must point to the portable-agent test-lab repository." +} + +$taskCommand = Get-Command task -ErrorAction SilentlyContinue +if (-not $taskCommand) { throw "Task is not installed. Run 'winget install Task.Task'." } + +$realm = Get-Content -Raw -LiteralPath "compose/keycloak/portable-agent-realm.json" | ConvertFrom-Json +$client = $realm.clients | Where-Object clientId -eq "portable-agent-local" +$user = $realm.users | Where-Object username -eq "local-user" +$password = ($user.credentials | Where-Object type -eq "password").value +if (-not $client -or -not $user -or -not $password) { + throw "Local Keycloak fixture does not contain the test client and user." +} + +$settings = @{ + KEYCLOAK_URL = "http://localhost:$(Get-LocalSetting 'KEYCLOAK_PORT')" + CHANNEL_URL = "http://host.docker.internal:$(Get-LocalSetting 'CHANNEL_GATEWAY_PORT')" + ACTION_URL = "http://host.docker.internal:$(Get-LocalSetting 'ACTION_SERVICE_PORT')" + CALENDAR_TEST_URL = "http://host.docker.internal:$(Get-LocalSetting 'CALENDAR_MCP_PORT')" + OIDC_REALM = $realm.realm + OIDC_CLIENT_ID = $client.clientId + TEST_USERNAME = $user.username + TEST_PASSWORD = $password + CALENDAR_TEST_API_KEY = Get-LocalSetting "CALENDAR_TEST_API_KEY" +} + +$oldSettings = @{} +try { + foreach ($name in $settings.Keys) { + $oldSettings[$name] = [Environment]::GetEnvironmentVariable($name, "Process") + [Environment]::SetEnvironmentVariable($name, $settings[$name], "Process") + } + & $taskCommand.Source --dir $resolvedTestLabPath test:e2e + if ($LASTEXITCODE -ne 0) { throw "Test Lab acceptance scenario failed." } +} +finally { + foreach ($name in $settings.Keys) { + [Environment]::SetEnvironmentVariable($name, $oldSettings[$name], "Process") + } +} From b69460fff27efcbadc0d30dd6a1621f31a9ce6a1 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Tue, 15 Sep 2026 22:32:47 +0300 Subject: [PATCH 2/4] fix: update test lab for Linux Docker host --- config/versions.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/versions.env b/config/versions.env index 7213b56..8d69b51 100644 --- a/config/versions.env +++ b/config/versions.env @@ -16,4 +16,4 @@ AGENT_RUNTIME_IMAGE=ghcr.io/portable-agent/agent-runtime:4dd541e459d0bdbf889ddc1 ACTION_SERVICE_IMAGE=ghcr.io/portable-agent/action-service:8d449ab990001e31cd42a7655a16c5716c5d6f20 MCP_GATEWAY_IMAGE=ghcr.io/portable-agent/mcp-gateway:0a3884d52ad1156ed173a4ecf9182ea3a0d2e686 CALENDAR_MCP_IMAGE=ghcr.io/portable-agent/calendar-mcp:bbab64774eab482c9d3543c7b8220b4cb42df56e -TEST_LAB_REF=02599218e8a7986d83db71b672f5da64f9a72cec +TEST_LAB_REF=1c8d9123c068611c3f36720b717d096420d6f40f From 25f7827a6df92f6fb4890f8015d4a247c9a91e90 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Tue, 15 Sep 2026 22:40:11 +0300 Subject: [PATCH 3/4] fix: connect acceptance runner to Compose network --- config/versions.env | 2 +- docs/decisions/0004-test-lab-ownership.md | 2 ++ scripts/check-compose.ps1 | 2 +- scripts/run-test-lab.ps1 | 7 ++++--- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/config/versions.env b/config/versions.env index 8d69b51..68e3383 100644 --- a/config/versions.env +++ b/config/versions.env @@ -16,4 +16,4 @@ AGENT_RUNTIME_IMAGE=ghcr.io/portable-agent/agent-runtime:4dd541e459d0bdbf889ddc1 ACTION_SERVICE_IMAGE=ghcr.io/portable-agent/action-service:8d449ab990001e31cd42a7655a16c5716c5d6f20 MCP_GATEWAY_IMAGE=ghcr.io/portable-agent/mcp-gateway:0a3884d52ad1156ed173a4ecf9182ea3a0d2e686 CALENDAR_MCP_IMAGE=ghcr.io/portable-agent/calendar-mcp:bbab64774eab482c9d3543c7b8220b4cb42df56e -TEST_LAB_REF=1c8d9123c068611c3f36720b717d096420d6f40f +TEST_LAB_REF=1ee8b67bb67b13507f32c8761ffa697ea6a7794b diff --git a/docs/decisions/0004-test-lab-ownership.md b/docs/decisions/0004-test-lab-ownership.md index 9f96b08..d7426bc 100644 --- a/docs/decisions/0004-test-lab-ownership.md +++ b/docs/decisions/0004-test-lab-ownership.md @@ -12,6 +12,8 @@ Compose, Kubernetes и удалённого тестового стенда. `deploy` владеет жизненным циклом окружения: запускает сервисы, ждёт healthchecks и передаёт только локальную тестовую конфигурацию. Репозиторий `test-lab` владеет black-box сценариями и их порогами. +Контейнер теста подключается к существующей Compose-сети и обращается к приложениям по внутренним +DNS-именам. Публиковать защищённые порты на всех интерфейсах для теста не требуется. Команда `task test:e2e` остаётся единой точкой входа. Локально она использует соседний репозиторий, заданный через `TEST_LAB_PATH`; CI явно получает обе репы. Временные учётные данные берутся из diff --git a/scripts/check-compose.ps1 b/scripts/check-compose.ps1 index 0cd8973..a58c642 100644 --- a/scripts/check-compose.ps1 +++ b/scripts/check-compose.ps1 @@ -181,7 +181,7 @@ if (Test-Path -LiteralPath "scripts/check-apps.ps1") { throw "Продуктовый E2E не должен дублироваться в deploy." } $testLabRunner = Get-Content -Raw -LiteralPath $testLabRunnerPath -foreach ($required in @("TEST_LAB_PATH", "portable-agent-realm.json", "CALENDAR_TEST_API_KEY", "task", "test:e2e")) { +foreach ($required in @("TEST_LAB_PATH", "portable-agent-realm.json", "CALENDAR_TEST_API_KEY", "DOCKER_NETWORK", "http://channel-gateway:8080", "http://action-service:8080", "http://calendar-mcp:8080", "task", "test:e2e")) { if ($testLabRunner -notmatch [regex]::Escape($required)) { throw "Адаптер test-lab не содержит $required." } diff --git a/scripts/run-test-lab.ps1 b/scripts/run-test-lab.ps1 index 4c1d017..da78997 100644 --- a/scripts/run-test-lab.ps1 +++ b/scripts/run-test-lab.ps1 @@ -32,9 +32,10 @@ if (-not $client -or -not $user -or -not $password) { $settings = @{ KEYCLOAK_URL = "http://localhost:$(Get-LocalSetting 'KEYCLOAK_PORT')" - CHANNEL_URL = "http://host.docker.internal:$(Get-LocalSetting 'CHANNEL_GATEWAY_PORT')" - ACTION_URL = "http://host.docker.internal:$(Get-LocalSetting 'ACTION_SERVICE_PORT')" - CALENDAR_TEST_URL = "http://host.docker.internal:$(Get-LocalSetting 'CALENDAR_MCP_PORT')" + CHANNEL_URL = "http://channel-gateway:8080" + ACTION_URL = "http://action-service:8080" + CALENDAR_TEST_URL = "http://calendar-mcp:8080" + DOCKER_NETWORK = "$(Get-LocalSetting 'COMPOSE_PROJECT_NAME')_default" OIDC_REALM = $realm.realm OIDC_CLIENT_ID = $client.clientId TEST_USERNAME = $user.username From eb2658ad6237116279a309d5fb04714904e6984b Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Tue, 15 Sep 2026 22:45:28 +0300 Subject: [PATCH 4/4] fix: discover acceptance Compose network --- scripts/check-compose.ps1 | 2 +- scripts/run-test-lab.ps1 | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/check-compose.ps1 b/scripts/check-compose.ps1 index a58c642..b0f703e 100644 --- a/scripts/check-compose.ps1 +++ b/scripts/check-compose.ps1 @@ -181,7 +181,7 @@ if (Test-Path -LiteralPath "scripts/check-apps.ps1") { throw "Продуктовый E2E не должен дублироваться в deploy." } $testLabRunner = Get-Content -Raw -LiteralPath $testLabRunnerPath -foreach ($required in @("TEST_LAB_PATH", "portable-agent-realm.json", "CALENDAR_TEST_API_KEY", "DOCKER_NETWORK", "http://channel-gateway:8080", "http://action-service:8080", "http://calendar-mcp:8080", "task", "test:e2e")) { +foreach ($required in @("TEST_LAB_PATH", "portable-agent-realm.json", "CALENDAR_TEST_API_KEY", "DOCKER_NETWORK", "docker inspect", "com.docker.compose.service=channel-gateway", "http://channel-gateway:8080", "http://action-service:8080", "http://calendar-mcp:8080", "task", "test:e2e")) { if ($testLabRunner -notmatch [regex]::Escape($required)) { throw "Адаптер test-lab не содержит $required." } diff --git a/scripts/run-test-lab.ps1 b/scripts/run-test-lab.ps1 index da78997..a53dcb0 100644 --- a/scripts/run-test-lab.ps1 +++ b/scripts/run-test-lab.ps1 @@ -13,6 +13,27 @@ function Get-LocalSetting([string]$Name) { return $value } +function Get-AppNetwork { + $projectName = Get-LocalSetting "COMPOSE_PROJECT_NAME" + $containerIds = @(& docker ps ` + --filter "label=com.docker.compose.project=$projectName" ` + --filter "label=com.docker.compose.service=channel-gateway" ` + --format "{{.ID}}") | Where-Object { $_ } + if ($LASTEXITCODE -ne 0 -or $containerIds.Count -ne 1) { + throw "Expected one running Channel Gateway container in Compose project $projectName." + } + + $networksJson = & docker inspect --format '{{json .NetworkSettings.Networks}}' $containerIds[0] + if ($LASTEXITCODE -ne 0 -or -not $networksJson) { + throw "Cannot inspect the Channel Gateway network." + } + $networkNames = @(($networksJson | ConvertFrom-Json).PSObject.Properties.Name) + if ($networkNames.Count -ne 1) { + throw "Expected Channel Gateway to use exactly one Compose network." + } + return $networkNames[0] +} + if (-not $TestLabPath) { $TestLabPath = Get-LocalSetting "TEST_LAB_PATH" } $resolvedTestLabPath = (Resolve-Path -LiteralPath $TestLabPath).Path if (-not (Test-Path -LiteralPath (Join-Path $resolvedTestLabPath "Taskfile.yml"))) { @@ -35,7 +56,7 @@ $settings = @{ CHANNEL_URL = "http://channel-gateway:8080" ACTION_URL = "http://action-service:8080" CALENDAR_TEST_URL = "http://calendar-mcp:8080" - DOCKER_NETWORK = "$(Get-LocalSetting 'COMPOSE_PROJECT_NAME')_default" + DOCKER_NETWORK = Get-AppNetwork OIDC_REALM = $realm.realm OIDC_CLIENT_ID = $client.clientId TEST_USERNAME = $user.username