diff --git a/tests/test_api_pipeline_logs.py b/tests/test_api_pipeline_logs.py deleted file mode 100644 index 20284e0..0000000 --- a/tests/test_api_pipeline_logs.py +++ /dev/null @@ -1,171 +0,0 @@ -"""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 deleted file mode 100644 index 7284e37..0000000 --- a/tests/test_migrate_pipeline_log.py +++ /dev/null @@ -1,169 +0,0 @@ -"""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 deleted file mode 100644 index 752bba8..0000000 --- a/web/frontend/src/__tests__/deploy-api.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * 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 deleted file mode 100644 index 567d7f2..0000000 --- a/web/frontend/src/__tests__/deploy-standardized.test.ts +++ /dev/null @@ -1,691 +0,0 @@ -/** - * 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 2c4b7be..4d88530 100644
--- a/web/frontend/src/views/ProjectView.vue
+++ b/web/frontend/src/views/ProjectView.vue
@@ -773,75 +773,13 @@ async function addDecision() {
         |
         Чат
       
-
+

{{ project.id }}

{{ project.name }} - -
- - -
-
- - {{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }} - - {{ 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 }}
-
-
-
-
-
@@ -866,21 +804,20 @@ async function addDecision() {
-
- @@ -1348,79 +1285,6 @@ 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 d6f23aa..66081bd 100644 --- a/web/frontend/src/views/TaskDetail.vue +++ b/web/frontend/src/views/TaskDetail.vue @@ -581,55 +581,16 @@ async function saveEdit() {
+ :class="deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
- - {{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }} + + {{ deployResult.success ? '✓ Deploy succeeded' : '✗ Deploy failed' }} - {{ 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 }}
-
-
-
-
+ exit {{ deployResult.exit_code }} · {{ deployResult.duration_seconds }}s +
+
{{ deployResult.stdout }}
+
{{ deployResult.stderr }}