kin: auto-commit after pipeline
This commit is contained in:
parent
4144c521be
commit
3c902eaeab
6 changed files with 1354 additions and 7 deletions
171
tests/test_api_pipeline_logs.py
Normal file
171
tests/test_api_pipeline_logs.py
Normal file
|
|
@ -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> возвращает только записи с 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=<last_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() == []
|
||||
169
tests/test_migrate_pipeline_log.py
Normal file
169
tests/test_migrate_pipeline_log.py
Normal file
|
|
@ -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()
|
||||
204
web/frontend/src/__tests__/deploy-api.test.ts
Normal file
204
web/frontend/src/__tests__/deploy-api.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
691
web/frontend/src/__tests__/deploy-standardized.test.ts
Normal file
691
web/frontend/src/__tests__/deploy-standardized.test.ts
Normal file
|
|
@ -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<typeof import('../api')>()
|
||||
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: '<div />' }
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
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<typeof mount>) {
|
||||
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<typeof BASE_PROJECT> = {}) {
|
||||
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 в <details>', 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 рендерится через <pre>', 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -1348,6 +1348,79 @@ async function addDecision() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Links Tab -->
|
||||
<div v-if="activeTab === 'links'">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs text-gray-500">Связи между проектами</span>
|
||||
<button @click="showAddLink = true"
|
||||
class="px-3 py-1 text-xs bg-gray-800 text-gray-300 border border-gray-700 rounded hover:bg-gray-700">
|
||||
+ Add Link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="linksLoading" class="text-gray-500 text-sm">Загрузка...</p>
|
||||
<p v-else-if="linksError" class="text-red-400 text-sm">{{ linksError }}</p>
|
||||
<div v-else-if="links.length === 0" class="text-gray-600 text-sm">Нет связей. Добавьте зависимости между проектами.</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="link in links" :key="link.id"
|
||||
class="px-4 py-3 border border-gray-800 rounded hover:border-gray-700 flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<span class="text-gray-400 font-mono text-xs">{{ link.from_project }}</span>
|
||||
<span class="text-gray-600">-></span>
|
||||
<span class="text-gray-400 font-mono text-xs">{{ link.to_project }}</span>
|
||||
<span class="px-1.5 py-0.5 text-[10px] bg-indigo-900/30 text-indigo-400 border border-indigo-800 rounded">{{ link.link_type }}</span>
|
||||
<span v-if="link.description" class="text-gray-500 text-xs">{{ link.description }}</span>
|
||||
</div>
|
||||
<button @click="deleteLink(link.id)"
|
||||
class="px-2 py-0.5 text-xs bg-gray-800 text-red-500 border border-gray-700 rounded hover:bg-red-950/30 hover:border-red-800 shrink-0">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Link Modal -->
|
||||
<Modal v-if="showAddLink" title="Add Link" @close="showAddLink = false; linkForm = { to_project: '', link_type: 'depends_on', description: '' }; linkFormError = ''">
|
||||
<form @submit.prevent="addLink" class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">From (current project)</label>
|
||||
<input :value="props.id" disabled
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-500 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">To project</label>
|
||||
<select v-model="linkForm.to_project" required
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
|
||||
<option value="">— выберите проект —</option>
|
||||
<option v-for="p in allProjects.filter(p => p.id !== props.id)" :key="p.id" :value="p.id">{{ p.id }} — {{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Link type</label>
|
||||
<select v-model="linkForm.link_type"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
|
||||
<option value="depends_on">depends_on</option>
|
||||
<option value="triggers">triggers</option>
|
||||
<option value="related_to">related_to</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-gray-500 mb-1">Description (optional)</label>
|
||||
<input v-model="linkForm.description" placeholder="e.g. API used by frontend"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
|
||||
</div>
|
||||
<p v-if="linkFormError" class="text-red-400 text-xs">{{ linkFormError }}</p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" @click="showAddLink = false; linkFormError = ''"
|
||||
class="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-200">Отмена</button>
|
||||
<button type="submit" :disabled="linkSaving"
|
||||
class="px-4 py-1.5 text-sm bg-blue-900/50 text-blue-400 border border-blue-800 rounded hover:bg-blue-900 disabled:opacity-50">
|
||||
{{ linkSaving ? 'Saving...' : 'Add Link' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<!-- Add Task Modal -->
|
||||
<Modal v-if="showAddTask" title="Add Task" @close="closeAddTaskModal">
|
||||
<form @submit.prevent="addTask" class="space-y-3">
|
||||
|
|
|
|||
|
|
@ -581,16 +581,55 @@ async function saveEdit() {
|
|||
|
||||
<!-- Deploy result inline block -->
|
||||
<div v-if="deployResult" class="mx-0 mt-2 p-3 rounded border text-xs font-mono"
|
||||
:class="deployResult.success ? 'border-teal-800 bg-teal-950/30 text-teal-300' : 'border-red-800 bg-red-950/30 text-red-300'">
|
||||
: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'">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span :class="deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
|
||||
{{ deployResult.success ? '✓ Deploy succeeded' : '✗ Deploy failed' }}
|
||||
<span :class="deployResult.overall_success !== false && deployResult.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold">
|
||||
{{ deployResult.overall_success !== false && deployResult.success ? 'Deploy succeeded' : 'Deploy failed' }}
|
||||
</span>
|
||||
<span class="text-gray-500">exit {{ deployResult.exit_code }} · {{ deployResult.duration_seconds }}s</span>
|
||||
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">✕</button>
|
||||
<span class="text-gray-500">{{ deployResult.duration_seconds }}s</span>
|
||||
<button @click.stop="deployResult = null" class="ml-auto text-gray-600 hover:text-gray-400 bg-transparent border-none cursor-pointer text-xs">x</button>
|
||||
</div>
|
||||
<!-- Structured steps -->
|
||||
<div v-if="deployResult.results?.length" class="space-y-1 mt-1">
|
||||
<details v-for="step in deployResult.results" :key="step.step" class="border border-gray-700 rounded">
|
||||
<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer list-none">
|
||||
<span :class="step.exit_code === 0 ? 'text-teal-400' : 'text-red-400'" class="font-semibold text-[10px]">{{ step.exit_code === 0 ? 'ok' : 'fail' }}</span>
|
||||
<span class="text-gray-300 text-[11px]">{{ step.step }}</span>
|
||||
<span class="text-gray-600 text-[10px] ml-auto">exit {{ step.exit_code }}</span>
|
||||
</summary>
|
||||
<div class="px-2 pb-2">
|
||||
<pre v-if="step.stdout" class="whitespace-pre-wrap text-gray-300 max-h-32 overflow-y-auto text-[10px]">{{ step.stdout }}</pre>
|
||||
<pre v-if="step.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-32 overflow-y-auto text-[10px] mt-1">{{ step.stderr }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Legacy output -->
|
||||
<template v-else>
|
||||
<pre v-if="deployResult.stdout" class="whitespace-pre-wrap text-gray-300 max-h-40 overflow-y-auto">{{ deployResult.stdout }}</pre>
|
||||
<pre v-if="deployResult.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-40 overflow-y-auto mt-1">{{ deployResult.stderr }}</pre>
|
||||
</template>
|
||||
<!-- Dependents -->
|
||||
<div v-if="deployResult.dependents_deployed?.length" class="mt-2 border-t border-gray-700 pt-2">
|
||||
<p class="text-xs text-gray-400 font-semibold mb-1">Зависимые проекты:</p>
|
||||
<details v-for="dep in deployResult.dependents_deployed" :key="dep.project_id" class="border border-gray-700 rounded mb-1">
|
||||
<summary class="flex items-center gap-2 px-2 py-1 cursor-pointer list-none">
|
||||
<span :class="dep.success ? 'text-teal-400' : 'text-red-400'" class="font-semibold text-[10px]">{{ dep.success ? 'ok' : 'fail' }}</span>
|
||||
<span class="text-gray-300 text-[11px]">{{ dep.project_name }}</span>
|
||||
</summary>
|
||||
<div class="px-2 pb-2 space-y-1">
|
||||
<details v-for="step in dep.results" :key="step.step" class="border border-gray-800 rounded">
|
||||
<summary class="flex items-center gap-2 px-2 py-0.5 cursor-pointer list-none">
|
||||
<span :class="step.exit_code === 0 ? 'text-teal-400' : 'text-red-400'" class="text-[10px]">{{ step.exit_code === 0 ? 'ok' : 'fail' }}</span>
|
||||
<span class="text-gray-400 text-[10px]">{{ step.step }}</span>
|
||||
</summary>
|
||||
<div class="px-2 pb-1">
|
||||
<pre v-if="step.stdout" class="whitespace-pre-wrap text-gray-300 max-h-24 overflow-y-auto text-[10px]">{{ step.stdout }}</pre>
|
||||
<pre v-if="step.stderr" class="whitespace-pre-wrap text-red-400/80 max-h-24 overflow-y-auto text-[10px]">{{ step.stderr }}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approve Modal -->
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue