diff --git a/tests/test_api_pipeline_logs.py b/tests/test_api_pipeline_logs.py new file mode 100644 index 0000000..20284e0 --- /dev/null +++ b/tests/test_api_pipeline_logs.py @@ -0,0 +1,171 @@ +"""Tests for GET /api/pipelines/{id}/logs endpoint (KIN-084 Live Console). + +Convention #418: since_id cursor pagination for append-only tables. +Convention #420: 404 for non-existent resources. +""" + +import pytest +from fastapi.testclient import TestClient + +import web.api as api_module +from core.db import init_db +from core import models + + +# ───────────────────────────────────────────────────────────── +# Fixtures +# ───────────────────────────────────────────────────────────── + +@pytest.fixture +def client(tmp_path): + """Bare TestClient с изолированной БД (без предварительных данных).""" + db_path = tmp_path / "test.db" + api_module.DB_PATH = db_path + from web.api import app + return TestClient(app) + + +@pytest.fixture +def pipeline_client(tmp_path): + """TestClient с project + task + pipeline, готовый к тестированию логов.""" + db_path = tmp_path / "test.db" + api_module.DB_PATH = db_path + from web.api import app + c = TestClient(app) + + # Seed project + task через API + c.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"}) + c.post("/api/tasks", json={"project_id": "p1", "title": "Task 1"}) + + # Создаём pipeline напрямую в БД + conn = init_db(db_path) + pipeline = models.create_pipeline(conn, "P1-001", "p1", "linear", ["step1"]) + conn.close() + + yield c, pipeline["id"], db_path + + +# ───────────────────────────────────────────────────────────── +# Тест: пустой pipeline → пустой список +# ───────────────────────────────────────────────────────────── + +def test_get_pipeline_logs_empty_returns_empty_list(pipeline_client): + """GET /api/pipelines/{id}/logs возвращает [] для pipeline без записей.""" + c, pipeline_id, _ = pipeline_client + r = c.get(f"/api/pipelines/{pipeline_id}/logs") + assert r.status_code == 200 + assert r.json() == [] + + +# ───────────────────────────────────────────────────────────── +# Тест: несуществующий pipeline → 404 (Convention #420) +# ───────────────────────────────────────────────────────────── + +def test_get_pipeline_logs_nonexistent_pipeline_returns_404(client): + """GET /api/pipelines/99999/logs возвращает 404 для несуществующего pipeline.""" + r = client.get("/api/pipelines/99999/logs") + assert r.status_code == 404 + + +# ───────────────────────────────────────────────────────────── +# Тест: 3 записи → правильные поля (id, ts, level, message, extra_json) +# ───────────────────────────────────────────────────────────── + +def test_get_pipeline_logs_returns_three_entries_with_correct_fields(pipeline_client): + """После write_log() x3 GET возвращает 3 записи с полями id, ts, level, message, extra_json.""" + c, pipeline_id, db_path = pipeline_client + + conn = init_db(db_path) + models.write_log(conn, pipeline_id, "PM started", level="INFO") + models.write_log(conn, pipeline_id, "Running agent", level="DEBUG") + models.write_log(conn, pipeline_id, "Agent error", level="ERROR", extra={"code": 500}) + conn.close() + + r = c.get(f"/api/pipelines/{pipeline_id}/logs") + assert r.status_code == 200 + logs = r.json() + assert len(logs) == 3 + + # Проверяем наличие всех обязательных полей + first = logs[0] + for field in ("id", "ts", "level", "message", "extra_json"): + assert field in first, f"Поле '{field}' отсутствует в ответе" + + assert first["message"] == "PM started" + assert first["level"] == "INFO" + assert first["extra_json"] is None + + assert logs[2]["level"] == "ERROR" + assert logs[2]["extra_json"] == {"code": 500} + + +def test_get_pipeline_logs_returns_entries_in_chronological_order(pipeline_client): + """Записи возвращаются в хронологическом порядке (по id ASC).""" + c, pipeline_id, db_path = pipeline_client + + conn = init_db(db_path) + for msg in ("first", "second", "third"): + models.write_log(conn, pipeline_id, msg) + conn.close() + + logs = c.get(f"/api/pipelines/{pipeline_id}/logs").json() + assert [e["message"] for e in logs] == ["first", "second", "third"] + + +# ───────────────────────────────────────────────────────────── +# Тест: since_id cursor pagination (Convention #418) +# ───────────────────────────────────────────────────────────── + +def test_get_pipeline_logs_since_id_returns_entries_after_cursor(pipeline_client): + """GET ?since_id= возвращает только записи с id > since_id.""" + c, pipeline_id, db_path = pipeline_client + + conn = init_db(db_path) + for i in range(1, 6): # 5 записей + models.write_log(conn, pipeline_id, f"Message {i}") + conn.close() + + # Получаем все записи + all_logs = c.get(f"/api/pipelines/{pipeline_id}/logs").json() + assert len(all_logs) == 5 + + # Берём id третьей записи как курсор + cursor_id = all_logs[2]["id"] + + r = c.get(f"/api/pipelines/{pipeline_id}/logs?since_id={cursor_id}") + assert r.status_code == 200 + partial = r.json() + + assert len(partial) == 2 + for entry in partial: + assert entry["id"] > cursor_id + + +def test_get_pipeline_logs_since_id_zero_returns_all(pipeline_client): + """GET ?since_id=0 возвращает все записи (значение по умолчанию).""" + c, pipeline_id, db_path = pipeline_client + + conn = init_db(db_path) + models.write_log(conn, pipeline_id, "A") + models.write_log(conn, pipeline_id, "B") + conn.close() + + r = c.get(f"/api/pipelines/{pipeline_id}/logs?since_id=0") + assert r.status_code == 200 + assert len(r.json()) == 2 + + +def test_get_pipeline_logs_since_id_beyond_last_returns_empty(pipeline_client): + """GET ?since_id= возвращает [] (нет записей после последней).""" + c, pipeline_id, db_path = pipeline_client + + conn = init_db(db_path) + models.write_log(conn, pipeline_id, "Only entry") + conn.close() + + all_logs = c.get(f"/api/pipelines/{pipeline_id}/logs").json() + last_id = all_logs[-1]["id"] + + r = c.get(f"/api/pipelines/{pipeline_id}/logs?since_id={last_id}") + assert r.status_code == 200 + assert r.json() == [] diff --git a/tests/test_migrate_pipeline_log.py b/tests/test_migrate_pipeline_log.py new file mode 100644 index 0000000..7284e37 --- /dev/null +++ b/tests/test_migrate_pipeline_log.py @@ -0,0 +1,169 @@ +"""Tests for core/db._migrate() — pipeline_log table migration (KIN-084). + +Convention #384: three tests for conditional DDL guard. +Convention #385: paired schema helper. +""" + +import sqlite3 + +from core.db import SCHEMA, _migrate, init_db + + +# ───────────────────────────────────────────────────────────── +# Helpers (Convention #385) +# ───────────────────────────────────────────────────────────── + +def _get_tables(conn: sqlite3.Connection) -> set: + return {r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall()} + + +def _get_indexes(conn: sqlite3.Connection) -> set: + return {r[1] for r in conn.execute( + "SELECT * FROM sqlite_master WHERE type='index'" + ).fetchall()} + + +def _get_columns(conn: sqlite3.Connection, table: str) -> set: + return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} + + +def _make_db_without_pipeline_log() -> sqlite3.Connection: + """In-memory DB with full schema minus pipeline_log (simulates legacy DB).""" + conn = sqlite3.connect(":memory:") + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + # Split on the pipeline_log section comment (last section in SCHEMA) + schema = SCHEMA.split("-- Live console log (KIN-084)")[0] + conn.executescript(schema) + conn.commit() + return conn + + +# ───────────────────────────────────────────────────────────── +# Тест 1: таблица отсутствует → _migrate() создаёт её +# ───────────────────────────────────────────────────────────── + +def test_migrate_creates_pipeline_log_table_when_absent(): + """_migrate() создаёт таблицу pipeline_log, если она отсутствует.""" + conn = _make_db_without_pipeline_log() + assert "pipeline_log" not in _get_tables(conn) + + _migrate(conn) + + assert "pipeline_log" in _get_tables(conn) + conn.close() + + +def test_migrate_creates_pipeline_log_index_when_absent(): + """_migrate() создаёт idx_pipeline_log_pipeline_id, если pipeline_log отсутствует.""" + conn = _make_db_without_pipeline_log() + assert "idx_pipeline_log_pipeline_id" not in _get_indexes(conn) + + _migrate(conn) + + assert "idx_pipeline_log_pipeline_id" in _get_indexes(conn) + conn.close() + + +def test_migrate_created_pipeline_log_has_all_columns(): + """pipeline_log, созданная _migrate(), содержит все нужные колонки.""" + conn = _make_db_without_pipeline_log() + + _migrate(conn) + + cols = _get_columns(conn, "pipeline_log") + assert {"id", "pipeline_id", "ts", "level", "message", "extra_json"} <= cols + conn.close() + + +# ───────────────────────────────────────────────────────────── +# Тест 2: таблица есть + полная схема → идемпотентность +# ───────────────────────────────────────────────────────────── + +def test_migrate_idempotent_when_pipeline_log_exists(): + """_migrate() не ломает pipeline_log и не падает при повторном вызове.""" + conn = init_db(":memory:") + assert "pipeline_log" in _get_tables(conn) + + # Повторный вызов не должен бросить исключение + _migrate(conn) + + assert "pipeline_log" in _get_tables(conn) + assert "idx_pipeline_log_pipeline_id" in _get_indexes(conn) + conn.close() + + +def test_migrate_idempotent_preserves_existing_pipeline_log_data(): + """_migrate() не удаляет данные из существующей pipeline_log.""" + from core import models + + conn = init_db(":memory:") + # Создаём минимальную цепочку project → task → pipeline → log + models.create_project(conn, "tp", "Test", "/tp") + models.create_task(conn, "TP-001", "tp", "T") + pipeline = models.create_pipeline(conn, "TP-001", "tp", "linear", []) + models.write_log(conn, pipeline["id"], "test-entry") + + _migrate(conn) + + rows = conn.execute("SELECT message FROM pipeline_log").fetchall() + assert len(rows) == 1 + assert rows[0][0] == "test-entry" + conn.close() + + +# ───────────────────────────────────────────────────────────── +# Тест 3: таблица есть без extra_json → _migrate() не падает +# ───────────────────────────────────────────────────────────── + +def test_migrate_no_crash_when_pipeline_log_missing_extra_json_column(): + """_migrate() не падает, если pipeline_log существует без колонки extra_json.""" + conn = _make_db_without_pipeline_log() + + # Создаём pipeline_log без extra_json (старая схема) + conn.executescript(""" + CREATE TABLE pipeline_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pipeline_id INTEGER NOT NULL, + ts TEXT NOT NULL DEFAULT (datetime('now')), + level TEXT NOT NULL DEFAULT 'INFO', + message TEXT NOT NULL + ); + """) + conn.commit() + + assert "pipeline_log" in _get_tables(conn) + assert "extra_json" not in _get_columns(conn, "pipeline_log") + + # _migrate() должен завершиться без исключений + _migrate(conn) + + # Таблица по-прежнему существует + assert "pipeline_log" in _get_tables(conn) + conn.close() + + +def test_migrate_does_not_add_extra_json_to_existing_pipeline_log(): + """_migrate() не добавляет extra_json к существующей pipeline_log (нет ALTER TABLE).""" + conn = _make_db_without_pipeline_log() + + conn.executescript(""" + CREATE TABLE pipeline_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pipeline_id INTEGER NOT NULL, + ts TEXT NOT NULL DEFAULT (datetime('now')), + level TEXT NOT NULL DEFAULT 'INFO', + message TEXT NOT NULL + ); + """) + conn.commit() + + _migrate(conn) + + # Документируем текущее поведение: колонка не добавляется + cols = _get_columns(conn, "pipeline_log") + assert "extra_json" not in cols + conn.close() diff --git a/web/frontend/src/__tests__/deploy-api.test.ts b/web/frontend/src/__tests__/deploy-api.test.ts new file mode 100644 index 0000000..752bba8 --- /dev/null +++ b/web/frontend/src/__tests__/deploy-api.test.ts @@ -0,0 +1,204 @@ +/** + * KIN-079: API функции deploy — unit тесты с fetch mock + * Тестируют fetch-вызовы напрямую без мока api-модуля. + * + * Проверяет: + * - api.projectLinks(id) — GET /api/projects/{id}/links + * - api.createProjectLink(data) — POST /api/project-links с телом + * - api.deleteProjectLink(id) — DELETE /api/project-links/{id} + * - api.patchProject с deploy_host/path/runtime/restart_cmd + * - api.deployProject — structured и legacy ответ + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { api } from '../api' + +function mockFetch(body: unknown, status = 200) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + statusText: status < 300 ? 'OK' : 'Error', + json: () => Promise.resolve(body), + } as Response) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +// ───────────────────────────────────────────────────────────── +// api.projectLinks +// ───────────────────────────────────────────────────────────── +describe('api.projectLinks', () => { + it('делает GET /api/projects/{id}/links', async () => { + const spy = mockFetch([]) + await api.projectLinks('KIN') + expect(spy).toHaveBeenCalledWith('/api/projects/KIN/links') + }) + + it('возвращает массив ProjectLink', async () => { + const links = [ + { id: 1, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: null, created_at: '2026-01-01' }, + ] + mockFetch(links) + const result = await api.projectLinks('KIN') + expect(result).toHaveLength(1) + expect(result[0].from_project).toBe('KIN') + expect(result[0].to_project).toBe('BRS') + }) +}) + +// ───────────────────────────────────────────────────────────── +// api.createProjectLink +// ───────────────────────────────────────────────────────────── +describe('api.createProjectLink', () => { + it('делает POST /api/project-links', async () => { + const spy = mockFetch({ id: 1, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: null, created_at: '' }) + await api.createProjectLink({ from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on' }) + expect(spy).toHaveBeenCalledWith('/api/project-links', expect.objectContaining({ method: 'POST' })) + }) + + it('передаёт from_project, to_project, link_type, description в теле', async () => { + const spy = mockFetch({ id: 1 }) + const data = { from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: 'API used by frontend' } + await api.createProjectLink(data) + const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string) + expect(body).toMatchObject(data) + }) + + it('передаёт запрос без description когда она не указана', async () => { + const spy = mockFetch({ id: 1 }) + await api.createProjectLink({ from_project: 'KIN', to_project: 'BRS', link_type: 'triggers' }) + const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string) + expect(body.from_project).toBe('KIN') + expect(body.link_type).toBe('triggers') + }) +}) + +// ───────────────────────────────────────────────────────────── +// api.deleteProjectLink +// ───────────────────────────────────────────────────────────── +describe('api.deleteProjectLink', () => { + it('делает DELETE /api/project-links/{id}', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 204, + statusText: 'No Content', + json: () => Promise.reject(new Error('no body')), + } as Response) + await api.deleteProjectLink(42) + expect(spy).toHaveBeenCalledWith('/api/project-links/42', expect.objectContaining({ method: 'DELETE' })) + }) + + it('использует числовой id в URL', async () => { + const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + status: 204, + statusText: 'No Content', + json: () => Promise.reject(new Error('no body')), + } as Response) + await api.deleteProjectLink(99) + expect(spy.mock.calls[0][0]).toBe('/api/project-links/99') + }) +}) + +// ───────────────────────────────────────────────────────────── +// api.patchProject — deploy поля +// ───────────────────────────────────────────────────────────── +describe('api.patchProject — deploy поля', () => { + it('все 4 deploy поля передаются в теле PATCH запроса', async () => { + const spy = mockFetch({ id: 'KIN' }) + await api.patchProject('KIN', { + deploy_host: 'myserver.com', + deploy_path: '/opt/app', + deploy_runtime: 'docker', + deploy_restart_cmd: 'docker compose up -d', + }) + const [url, opts] = spy.mock.calls[0] as [string, RequestInit] + expect(url).toBe('/api/projects/KIN') + expect((opts as RequestInit).method).toBe('PATCH') + const body = JSON.parse((opts as RequestInit).body as string) + expect(body).toMatchObject({ + deploy_host: 'myserver.com', + deploy_path: '/opt/app', + deploy_runtime: 'docker', + deploy_restart_cmd: 'docker compose up -d', + }) + }) + + it('можно передать только deploy_runtime без остальных полей', async () => { + const spy = mockFetch({ id: 'KIN' }) + await api.patchProject('KIN', { deploy_runtime: 'node' }) + const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string) + expect(body.deploy_runtime).toBe('node') + expect(body.deploy_host).toBeUndefined() + expect(body.deploy_path).toBeUndefined() + }) + + it('deploy_runtime=python передаётся корректно', async () => { + const spy = mockFetch({ id: 'KIN' }) + await api.patchProject('KIN', { deploy_runtime: 'python' }) + const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string) + expect(body.deploy_runtime).toBe('python') + }) + + it('deploy_runtime=static передаётся корректно', async () => { + const spy = mockFetch({ id: 'KIN' }) + await api.patchProject('KIN', { deploy_runtime: 'static' }) + const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string) + expect(body.deploy_runtime).toBe('static') + }) +}) + +// ───────────────────────────────────────────────────────────── +// api.deployProject +// ───────────────────────────────────────────────────────────── +describe('api.deployProject', () => { + it('делает POST /api/projects/{id}/deploy', async () => { + const spy = mockFetch({ success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2 }) + await api.deployProject('KIN') + expect(spy).toHaveBeenCalledWith('/api/projects/KIN/deploy', expect.objectContaining({ method: 'POST' })) + }) + + it('возвращает structured ответ со steps/results/dependents_deployed/overall_success', async () => { + const structured = { + success: true, + exit_code: 0, + stdout: '', + stderr: '', + duration_seconds: 5, + steps: ['git pull', 'docker compose up -d'], + results: [ + { step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }, + { step: 'docker compose up -d', stdout: 'started', stderr: '', exit_code: 0 }, + ], + dependents_deployed: [ + { project_id: 'FE', project_name: 'Frontend', success: true, results: [] }, + ], + overall_success: true, + } + mockFetch(structured) + const result = await api.deployProject('KIN') + expect(result.steps).toHaveLength(2) + expect(result.results).toHaveLength(2) + expect(result.dependents_deployed).toHaveLength(1) + expect(result.overall_success).toBe(true) + }) + + it('legacy формат поддерживается — нет steps/results', async () => { + const legacy = { success: true, exit_code: 0, stdout: 'Deploy complete!', stderr: '', duration_seconds: 3 } + mockFetch(legacy) + const result = await api.deployProject('KIN') + expect(result.success).toBe(true) + expect(result.stdout).toBe('Deploy complete!') + expect(result.steps).toBeUndefined() + expect(result.results).toBeUndefined() + }) + + it('failed deploy возвращает success=false', async () => { + mockFetch({ success: false, exit_code: 1, stdout: '', stderr: 'error', duration_seconds: 1 }) + const result = await api.deployProject('KIN') + expect(result.success).toBe(false) + expect(result.exit_code).toBe(1) + }) +}) diff --git a/web/frontend/src/__tests__/deploy-standardized.test.ts b/web/frontend/src/__tests__/deploy-standardized.test.ts new file mode 100644 index 0000000..567d7f2 --- /dev/null +++ b/web/frontend/src/__tests__/deploy-standardized.test.ts @@ -0,0 +1,691 @@ +/** + * KIN-079: Стандартизированный deploy — компонентные тесты + * + * Проверяет: + * 1. SettingsView — deploy config рендерится, Save Deploy Config, runtime select + * 2. ProjectView — Deploy кнопка (видимость, disabled, спиннер) + * 3. ProjectView — Deploy результат (structured, legacy, dependents) + * 4. ProjectView — Links таб (список, add link модал, delete link) + * 5. Граничные кейсы (пустые links, deploy без dependents, overall_success=false) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import ProjectView from '../views/ProjectView.vue' +import SettingsView from '../views/SettingsView.vue' + +vi.mock('../api', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + api: { + projects: vi.fn(), + project: vi.fn(), + getPhases: vi.fn(), + environments: vi.fn(), + projectLinks: vi.fn(), + patchProject: vi.fn(), + deployProject: vi.fn(), + createProjectLink: vi.fn(), + deleteProjectLink: vi.fn(), + syncObsidian: vi.fn(), + }, + } +}) + +import { api } from '../api' + +const Stub = { template: '
' } + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = v }, + removeItem: (k: string) => { delete store[k] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true }) + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: Stub }, + { path: '/project/:id', component: ProjectView, props: true }, + ], + }) +} + +const BASE_PROJECT = { + id: 'KIN', + name: 'Kin', + path: '/projects/kin', + status: 'active', + priority: 5, + tech_stack: ['python', 'vue'], + execution_mode: null, + autocommit_enabled: null, + auto_test_enabled: null, + obsidian_vault_path: null, + deploy_command: null, + test_command: null, + deploy_host: null, + deploy_path: null, + deploy_runtime: null, + deploy_restart_cmd: null, + created_at: '2024-01-01', + total_tasks: 0, + done_tasks: 0, + active_tasks: 0, + blocked_tasks: 0, + review_tasks: 0, + project_type: null, + ssh_host: null, + ssh_user: null, + ssh_key_path: null, + ssh_proxy_jump: null, + description: null, + tasks: [], + decisions: [], + modules: [], +} + +beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + vi.mocked(api.project).mockResolvedValue(BASE_PROJECT as any) + vi.mocked(api.getPhases).mockResolvedValue([]) + vi.mocked(api.environments).mockResolvedValue([]) + vi.mocked(api.projectLinks).mockResolvedValue([]) + vi.mocked(api.projects).mockResolvedValue([]) + vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any) + vi.mocked(api.deployProject).mockResolvedValue({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + } as any) + vi.mocked(api.createProjectLink).mockResolvedValue({ + id: 1, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: null, created_at: '2026-01-01', + } as any) + vi.mocked(api.deleteProjectLink).mockResolvedValue(undefined as any) +}) + +async function mountProjectView(project = BASE_PROJECT) { + vi.mocked(api.project).mockResolvedValue(project as any) + const router = makeRouter() + await router.push('/project/KIN') + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + return wrapper +} + +async function switchToLinksTab(wrapper: ReturnType) { + const allBtns = wrapper.findAll('button') + for (const btn of allBtns) { + if (btn.element.className.includes('border-b-2') && btn.text().includes('Links')) { + await btn.trigger('click') + await flushPromises() + return + } + } +} + +// ───────────────────────────────────────────────────────────── +// 1. SettingsView — Deploy Config +// ───────────────────────────────────────────────────────────── +describe('SettingsView — Deploy Config', () => { + async function mountSettings(deployFields: Partial = {}) { + const project = { ...BASE_PROJECT, ...deployFields } + vi.mocked(api.projects).mockResolvedValue([project as any]) + const wrapper = mount(SettingsView) + await flushPromises() + return wrapper + } + + it('раздел Deploy Config рендерится для каждого проекта', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Deploy Config') + }) + + it('поле Server host рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Server host') + }) + + it('поле Project path on server рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Project path on server') + }) + + it('поле Runtime рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Runtime') + }) + + it('поле Restart command рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Restart command') + }) + + it('runtime select содержит опции docker, node, python, static', async () => { + const wrapper = await mountSettings() + // Ищем select для runtime (первый select в Deploy Config) + const selects = wrapper.findAll('select') + // Находим select с options docker/node/python/static + const runtimeSelect = selects.find(s => { + const opts = s.findAll('option') + const values = opts.map(o => o.element.value) + return values.includes('docker') && values.includes('node') + }) + expect(runtimeSelect).toBeDefined() + const values = runtimeSelect!.findAll('option').map(o => o.element.value) + expect(values).toContain('docker') + expect(values).toContain('node') + expect(values).toContain('python') + expect(values).toContain('static') + }) + + it('Save Deploy Config вызывает patchProject', async () => { + const wrapper = await mountSettings() + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + expect(saveBtn).toBeDefined() + await saveBtn!.trigger('click') + await flushPromises() + expect(vi.mocked(api.patchProject)).toHaveBeenCalled() + }) + + it('patchProject вызывается с deploy_host, deploy_path, deploy_runtime, deploy_restart_cmd', async () => { + const wrapper = await mountSettings({ + deploy_host: 'myserver.com', + deploy_path: '/opt/app', + deploy_runtime: 'docker', + deploy_restart_cmd: 'docker compose up -d', + }) + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + await saveBtn!.trigger('click') + await flushPromises() + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect(callArgs[0]).toBe('KIN') + // Все 4 ключа присутствуют в объекте + expect(callArgs[1]).toHaveProperty('deploy_host') + expect(callArgs[1]).toHaveProperty('deploy_path') + expect(callArgs[1]).toHaveProperty('deploy_runtime') + expect(callArgs[1]).toHaveProperty('deploy_restart_cmd') + }) + + it('patchProject получает deploy_host=myserver.com из заполненного поля', async () => { + const wrapper = await mountSettings({ deploy_host: 'myserver.com' }) + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + await saveBtn!.trigger('click') + await flushPromises() + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect((callArgs[1] as any).deploy_host).toBe('myserver.com') + }) + + it('статус "Saved" отображается после успешного сохранения', async () => { + const wrapper = await mountSettings() + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + await saveBtn!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Saved') + }) +}) + +// ───────────────────────────────────────────────────────────── +// 2. ProjectView — Deploy кнопка +// ───────────────────────────────────────────────────────────── +describe('ProjectView — Deploy кнопка', () => { + it('кнопка Deploy присутствует в header проекта', async () => { + const wrapper = await mountProjectView() + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + expect(deployBtn).toBeDefined() + }) + + it('кнопка disabled когда deploy-параметры не заполнены', async () => { + const wrapper = await mountProjectView() + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + expect(deployBtn!.element.disabled).toBe(true) + }) + + it('кнопка активна когда deploy_host + deploy_path + deploy_runtime заполнены', async () => { + const project = { ...BASE_PROJECT, deploy_host: 'myserver', deploy_path: '/opt/app', deploy_runtime: 'docker' } + const wrapper = await mountProjectView(project) + const deployBtn = wrapper.findAll('button').find(b => + (b.text().trim() === 'Deploy' || b.text().includes('Deploy')) && b.text().includes('Deploy') + ) + expect(deployBtn!.element.disabled).toBe(false) + }) + + it('кнопка активна когда есть legacy deploy_command', async () => { + const project = { ...BASE_PROJECT, deploy_command: 'ssh prod ./deploy.sh' } + const wrapper = await mountProjectView(project) + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + expect(deployBtn!.element.disabled).toBe(false) + }) + + it('tooltip содержит "Settings" когда deploy не настроен', async () => { + const wrapper = await mountProjectView() + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + expect(deployBtn!.attributes('title')).toContain('Settings') + }) + + it('tooltip содержит "Deploy project" когда deploy настроен', async () => { + const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'node' } + const wrapper = await mountProjectView(project) + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + expect(deployBtn!.attributes('title')).toContain('Deploy project') + }) + + it('клик вызывает api.deployProject с id проекта', async () => { + const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' } + const wrapper = await mountProjectView(project) + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + await deployBtn!.trigger('click') + await flushPromises() + expect(vi.mocked(api.deployProject)).toHaveBeenCalledWith('KIN') + }) + + it('спиннер animate-spin виден пока deploying=true', async () => { + const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' } + let resolveDeployment!: (v: any) => void + vi.mocked(api.deployProject).mockReturnValue(new Promise(r => { resolveDeployment = r })) + + const wrapper = await mountProjectView(project) + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + await deployBtn!.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.find('.animate-spin').exists()).toBe(true) + // Cleanup + resolveDeployment({ success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 1 }) + await flushPromises() + }) + + it('кнопка disabled во время deploying', async () => { + const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' } + let resolveDeployment!: (v: any) => void + vi.mocked(api.deployProject).mockReturnValue(new Promise(r => { resolveDeployment = r })) + + const wrapper = await mountProjectView(project) + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + await deployBtn!.trigger('click') + await wrapper.vm.$nextTick() + + // Во время загрузки кнопка должна быть disabled + const updatedBtn = wrapper.findAll('button').find(b => + b.text().includes('Deploy') || b.text().includes('Deploying') + ) + expect(updatedBtn!.element.disabled).toBe(true) + // Cleanup + resolveDeployment({ success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 1 }) + await flushPromises() + }) +}) + +// ───────────────────────────────────────────────────────────── +// 3. ProjectView — Deploy результат +// ───────────────────────────────────────────────────────────── +describe('ProjectView — Deploy результат', () => { + const DEPLOY_PROJECT = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' } + + async function clickDeploy(result: any) { + vi.mocked(api.deployProject).mockResolvedValue(result) + const wrapper = await mountProjectView(DEPLOY_PROJECT) + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + await deployBtn!.trigger('click') + await flushPromises() + return wrapper + } + + it('блок результата не показывается до первого деплоя', async () => { + const wrapper = await mountProjectView() + expect(wrapper.text()).not.toContain('Deploy succeeded') + expect(wrapper.text()).not.toContain('Deploy failed') + }) + + it('success=true → текст "Deploy succeeded"', async () => { + const wrapper = await clickDeploy({ success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 1 }) + expect(wrapper.text()).toContain('Deploy succeeded') + }) + + it('success=false → текст "Deploy failed"', async () => { + const wrapper = await clickDeploy({ success: false, exit_code: 1, stdout: '', stderr: 'error', duration_seconds: 1 }) + expect(wrapper.text()).toContain('Deploy failed') + }) + + it('structured результат показывает имя step в
', async () => { + const result = { + success: true, + exit_code: 0, + stdout: '', + stderr: '', + duration_seconds: 3, + results: [ + { step: 'git pull', stdout: 'done', stderr: '', exit_code: 0 }, + { step: 'docker compose up -d', stdout: 'started', stderr: '', exit_code: 0 }, + ], + overall_success: true, + } + const wrapper = await clickDeploy(result) + expect(wrapper.findAll('details').length).toBeGreaterThanOrEqual(2) + expect(wrapper.text()).toContain('git pull') + expect(wrapper.text()).toContain('docker compose up -d') + }) + + it('structured step с exit_code=0 показывает "ok"', async () => { + const result = { + success: true, + exit_code: 0, + stdout: '', + stderr: '', + duration_seconds: 1, + results: [{ step: 'git pull', stdout: 'done', stderr: '', exit_code: 0 }], + overall_success: true, + } + const wrapper = await clickDeploy(result) + expect(wrapper.text()).toContain('ok') + }) + + it('structured step с exit_code!=0 показывает "fail"', async () => { + const result = { + success: false, + exit_code: 1, + stdout: '', + stderr: '', + duration_seconds: 1, + results: [{ step: 'git pull', stdout: '', stderr: 'error', exit_code: 1 }], + overall_success: false, + } + const wrapper = await clickDeploy(result) + expect(wrapper.text()).toContain('fail') + }) + + it('legacy формат — stdout рендерится через
', async () => {
+    const result = { success: true, exit_code: 0, stdout: 'Deploy complete!', stderr: '', duration_seconds: 2 }
+    const wrapper = await clickDeploy(result)
+    expect(wrapper.text()).toContain('Deploy complete!')
+  })
+
+  it('legacy формат — stderr рендерится при наличии', async () => {
+    const result = { success: false, exit_code: 1, stdout: '', stderr: 'Error occurred', duration_seconds: 1 }
+    const wrapper = await clickDeploy(result)
+    expect(wrapper.text()).toContain('Error occurred')
+  })
+
+  it('секция "Зависимые проекты" видна когда есть dependents_deployed', async () => {
+    const result = {
+      success: true,
+      exit_code: 0,
+      stdout: '',
+      stderr: '',
+      duration_seconds: 5,
+      results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }],
+      dependents_deployed: [
+        { project_id: 'FE', project_name: 'Frontend App', success: true, results: [] },
+      ],
+      overall_success: true,
+    }
+    const wrapper = await clickDeploy(result)
+    expect(wrapper.text()).toContain('Зависимые проекты')
+    expect(wrapper.text()).toContain('Frontend App')
+  })
+
+  it('overall_success=false → Deploy failed даже если success=true', async () => {
+    const result = {
+      success: true,
+      exit_code: 0,
+      stdout: '',
+      stderr: '',
+      duration_seconds: 5,
+      results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }],
+      dependents_deployed: [
+        { project_id: 'FE', project_name: 'Frontend', success: false, results: [] },
+      ],
+      overall_success: false,
+    }
+    const wrapper = await clickDeploy(result)
+    expect(wrapper.text()).toContain('Deploy failed')
+  })
+
+  it('dependents показывают статус "ok" или "fail" для каждого проекта', async () => {
+    const result = {
+      success: true,
+      exit_code: 0,
+      stdout: '',
+      stderr: '',
+      duration_seconds: 5,
+      results: [],
+      dependents_deployed: [
+        { project_id: 'FE', project_name: 'FrontendOK', success: true, results: [] },
+        { project_id: 'BE2', project_name: 'ServiceFail', success: false, results: [] },
+      ],
+      overall_success: false,
+    }
+    const wrapper = await clickDeploy(result)
+    expect(wrapper.text()).toContain('FrontendOK')
+    expect(wrapper.text()).toContain('ServiceFail')
+  })
+})
+
+// ─────────────────────────────────────────────────────────────
+// 4. ProjectView — Links таб
+// ─────────────────────────────────────────────────────────────
+describe('ProjectView — Links таб', () => {
+  it('таб Links присутствует в списке табов', async () => {
+    const wrapper = await mountProjectView()
+    const tabBtns = wrapper.findAll('button').filter(b => b.element.className.includes('border-b-2'))
+    const hasLinks = tabBtns.some(b => b.text().includes('Links'))
+    expect(hasLinks).toBe(true)
+  })
+
+  it('api.projectLinks вызывается при монтировании', async () => {
+    await mountProjectView()
+    expect(vi.mocked(api.projectLinks)).toHaveBeenCalledWith('KIN')
+  })
+
+  it('пустое состояние — "Нет связей" при links=[]', async () => {
+    vi.mocked(api.projectLinks).mockResolvedValue([])
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+    expect(wrapper.text()).toContain('Нет связей')
+  })
+
+  it('связи отображаются при links.length > 0', async () => {
+    const links = [
+      { id: 1, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: 'test', created_at: '2026-01-01' },
+    ]
+    vi.mocked(api.projectLinks).mockResolvedValue(links as any)
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+    expect(wrapper.text()).toContain('BRS')
+    expect(wrapper.text()).toContain('depends_on')
+  })
+
+  it('link_type и description отображаются для каждой связи', async () => {
+    const links = [
+      { id: 2, from_project: 'KIN', to_project: 'API', link_type: 'triggers', description: 'API call', created_at: '2026-01-01' },
+    ]
+    vi.mocked(api.projectLinks).mockResolvedValue(links as any)
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+    expect(wrapper.text()).toContain('triggers')
+    expect(wrapper.text()).toContain('API call')
+  })
+
+  it('кнопка "+ Add Link" присутствует в Links табе', async () => {
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+    const addBtn = wrapper.findAll('button').find(b => b.text().includes('Add Link'))
+    expect(addBtn).toBeDefined()
+  })
+
+  it('клик "+ Add Link" показывает форму добавления связи', async () => {
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+    const addBtn = wrapper.findAll('button').find(b => b.text().includes('Add Link') && !b.text().includes('Saving'))
+    // Ищем кнопку с "+" в тексте
+    const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
+    await (plusBtn ?? addBtn)!.trigger('click')
+    await flushPromises()
+    expect(wrapper.text()).toContain('From (current project)')
+    expect(wrapper.text()).toContain('To project')
+  })
+
+  it('форма Add Link содержит from_project readonly и selects', async () => {
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+    const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
+    await plusBtn!.trigger('click')
+    await flushPromises()
+
+    // from_project — disabled input с id 'KIN'
+    const disabledInputs = wrapper.findAll('input[disabled]')
+    const fromInput = disabledInputs.find(i => (i.element as HTMLInputElement).value === 'KIN')
+    expect(fromInput).toBeDefined()
+
+    // to_project и link_type — select элементы
+    const selects = wrapper.findAll('select')
+    expect(selects.length).toBeGreaterThanOrEqual(1)
+  })
+
+  it('форма link_type select содержит depends_on, triggers, related_to', async () => {
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+    const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
+    await plusBtn!.trigger('click')
+    await flushPromises()
+
+    const selects = wrapper.findAll('select')
+    const linkTypeSelect = selects.find(s => {
+      const opts = s.findAll('option')
+      return opts.some(o => o.element.value === 'depends_on')
+    })
+    expect(linkTypeSelect).toBeDefined()
+    const optValues = linkTypeSelect!.findAll('option').map(o => o.element.value)
+    expect(optValues).toContain('depends_on')
+    expect(optValues).toContain('triggers')
+    expect(optValues).toContain('related_to')
+  })
+
+  it('Add Link вызывает api.createProjectLink с from_project=KIN и to_project', async () => {
+    vi.mocked(api.projects).mockResolvedValue([
+      { ...BASE_PROJECT, id: 'BRS', name: 'Barsik' } as any,
+    ])
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+
+    const plusBtn = wrapper.findAll('button').find(b => b.text().includes('+') && b.text().includes('Link'))
+    await plusBtn!.trigger('click')
+    await flushPromises()
+
+    // Выбираем to_project — select с опцией BRS
+    const selects = wrapper.findAll('select')
+    const toProjectSelect = selects.find(s => {
+      const opts = s.findAll('option')
+      return opts.some(o => o.text().includes('BRS'))
+    })
+    if (toProjectSelect) {
+      await toProjectSelect.setValue('BRS')
+    }
+
+    // Сабмитим форму
+    const form = wrapper.find('form')
+    await form.trigger('submit')
+    await flushPromises()
+
+    expect(vi.mocked(api.createProjectLink)).toHaveBeenCalledWith(expect.objectContaining({
+      from_project: 'KIN',
+      to_project: 'BRS',
+    }))
+  })
+
+  it('Delete вызывает api.deleteProjectLink с id связи', async () => {
+    const links = [
+      { id: 7, from_project: 'KIN', to_project: 'BRS', link_type: 'depends_on', description: null, created_at: '2026-01-01' },
+    ]
+    vi.mocked(api.projectLinks).mockResolvedValue(links as any)
+    vi.spyOn(window, 'confirm').mockReturnValue(true)
+
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+
+    // Кнопка удаления имеет class text-red-500
+    const deleteBtn = wrapper.find('button.text-red-500')
+    expect(deleteBtn.exists()).toBe(true)
+    await deleteBtn.trigger('click')
+    await flushPromises()
+
+    expect(vi.mocked(api.deleteProjectLink)).toHaveBeenCalledWith(7)
+    vi.restoreAllMocks()
+  })
+})
+
+// ─────────────────────────────────────────────────────────────
+// 5. Граничные кейсы
+// ─────────────────────────────────────────────────────────────
+describe('Граничные кейсы', () => {
+  it('Deploy без dependents — секция "Зависимые проекты" не показывается', async () => {
+    const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'node' }
+    vi.mocked(api.deployProject).mockResolvedValue({
+      success: true,
+      exit_code: 0,
+      stdout: 'done',
+      stderr: '',
+      duration_seconds: 2,
+    } as any)
+    const wrapper = await mountProjectView(project)
+    const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
+    await deployBtn!.trigger('click')
+    await flushPromises()
+    expect(wrapper.text()).not.toContain('Зависимые проекты')
+  })
+
+  it('пустой список links — нет ошибок в рендере', async () => {
+    vi.mocked(api.projectLinks).mockResolvedValue([])
+    const wrapper = await mountProjectView()
+    await switchToLinksTab(wrapper)
+    // Нет ошибок — компонент рендерится нормально
+    expect(wrapper.text()).toContain('Нет связей')
+    expect(wrapper.find('[class*="text-red"]').exists()).toBe(false)
+  })
+
+  it('overall_success=false с одним failed dependent → "Deploy failed" и dependent виден', async () => {
+    const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' }
+    vi.mocked(api.deployProject).mockResolvedValue({
+      success: true,
+      exit_code: 0,
+      stdout: '',
+      stderr: '',
+      duration_seconds: 5,
+      results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }],
+      dependents_deployed: [
+        { project_id: 'FE', project_name: 'FrontendFailed', success: false, results: [] },
+      ],
+      overall_success: false,
+    } as any)
+    const wrapper = await mountProjectView(project)
+    const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
+    await deployBtn!.trigger('click')
+    await flushPromises()
+    expect(wrapper.text()).toContain('Deploy failed')
+    expect(wrapper.text()).toContain('FrontendFailed')
+  })
+
+  it('только deploy_host без deploy_path/runtime — кнопка Deploy disabled', async () => {
+    const project = { ...BASE_PROJECT, deploy_host: 'myserver' }
+    const wrapper = await mountProjectView(project)
+    const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
+    expect(deployBtn!.element.disabled).toBe(true)
+  })
+
+  it('все три structured deploy поля нужны — только host+path без runtime → disabled', async () => {
+    const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app' }
+    const wrapper = await mountProjectView(project)
+    const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy'))
+    expect(deployBtn!.element.disabled).toBe(true)
+  })
+})
diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue
index aefd91f..2c4b7be 100644
--- a/web/frontend/src/views/ProjectView.vue
+++ b/web/frontend/src/views/ProjectView.vue
@@ -1348,6 +1348,79 @@ async function addDecision() {
       
+ +
+
+ Связи между проектами + +
+ +

Загрузка...

+

{{ linksError }}

+
Нет связей. Добавьте зависимости между проектами.
+
+
+
+ {{ link.from_project }} + -> + {{ link.to_project }} + {{ link.link_type }} + {{ link.description }} +
+ +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

{{ linkFormError }}

+
+ + +
+
+
+
+
diff --git a/web/frontend/src/views/TaskDetail.vue b/web/frontend/src/views/TaskDetail.vue index 66081bd..d6f23aa 100644 --- a/web/frontend/src/views/TaskDetail.vue +++ b/web/frontend/src/views/TaskDetail.vue @@ -581,16 +581,55 @@ async function saveEdit() {
+ :class="deployResult.overall_success !== false && deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
- - {{ deployResult.success ? '✓ Deploy succeeded' : '✗ Deploy failed' }} + + {{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }} - exit {{ deployResult.exit_code }} · {{ deployResult.duration_seconds }}s - + {{ deployResult.duration_seconds }}s + +
+ +
+
+ + {{ step.exit_code === 0 ? 'ok' : 'fail' }} + {{ step.step }} + exit {{ step.exit_code }} + +
+
{{ step.stdout }}
+
{{ step.stderr }}
+
+
+
+ + + +
+

Зависимые проекты:

+
+ + {{ dep.success ? 'ok' : 'fail' }} + {{ dep.project_name }} + +
+
+ + {{ step.exit_code === 0 ? 'ok' : 'fail' }} + {{ step.step }} + +
+
{{ step.stdout }}
+
{{ step.stderr }}
+
+
+
+
-
{{ deployResult.stdout }}
-
{{ deployResult.stderr }}