From e4abc2efbe055c49ca1436bdeeda78fcc95ff299 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 21:55:21 +0300 Subject: [PATCH 1/8] Add stoppedAt to RemoteSession (domain model D-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server was already sending stopped_at in SessionResponse but Swift client silently discarded it. Adds stoppedAt: Date? with CodingKey "stopped_at" and updates domain-model.md §7 to mark D-13 closed. Co-Authored-By: Claude Sonnet 4.6 --- .../SharedModels/Sources/SharedModels/RemoteSession.swift | 8 +++++++- docs/architecture/domain-model.md | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteSession.swift b/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteSession.swift index 6c5f1fc..bbb2b31 100644 --- a/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteSession.swift +++ b/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteSession.swift @@ -37,6 +37,9 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl /// Время последнего обновления. public let updatedAt: Date + /// Время остановки сессии. nil если сессия ещё не остановлена. + public let stoppedAt: Date? + enum CodingKeys: String, CodingKey { case id case projectId = "project_id" @@ -49,6 +52,7 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl case errorReason = "error_reason" case createdAt = "created_at" case updatedAt = "updated_at" + case stoppedAt = "stopped_at" } public init( @@ -62,7 +66,8 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl workDir: String = "", errorReason: String? = nil, createdAt: Date, - updatedAt: Date + updatedAt: Date, + stoppedAt: Date? = nil ) { self.id = id self.projectId = projectId @@ -75,5 +80,6 @@ public struct RemoteSession: Codable, Equatable, Hashable, Identifiable, Sendabl self.errorReason = errorReason self.createdAt = createdAt self.updatedAt = updatedAt + self.stoppedAt = stoppedAt } } diff --git a/docs/architecture/domain-model.md b/docs/architecture/domain-model.md index d28e956..355561e 100644 --- a/docs/architecture/domain-model.md +++ b/docs/architecture/domain-model.md @@ -232,3 +232,4 @@ Session.image (explicit override) | D-10 | `Project.default_image: String?` в image resolution chain (§5) | Добавлен в DB, struct, REST, Swift. Chain: Session → Project → config. | ✅ Закрыт | | D-11 | `Server.default_image: String?` (клиентская сущность) | `ServerCredential.defaultImage` добавлен. | ✅ Закрыт | | D-12 | `Session.work_dir: Path` (персистентное) | Добавлен в DB, struct, REST, Swift. | ✅ Закрыт | +| D-13 | `Session.stopped_at: Timestamp?` в Swift-модели | `stoppedAt: Date?` добавлен в `RemoteSession` (CodingKey `"stopped_at"`). Сервер уже отдавал поле. | ✅ Закрыт | From ed41f6a61ac729d7e818a848d6083c5e7e9344a7 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 21:59:53 +0300 Subject: [PATCH 2/8] Add TerminalState enum, use it in RemoteTerminal (D-15) RemoteTerminal.state was an untyped String while the domain model defines TerminalState with starting/running/stopped values. Adds TerminalState enum following the SessionState/ProjectState pattern with safe fallback decoder (.stopped on unknown value). Co-Authored-By: Claude Sonnet 4.6 --- .../Sources/SharedModels/RemoteTerminal.swift | 4 ++-- .../Sources/SharedModels/TerminalState.swift | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 MacApp/Packages/SharedModels/Sources/SharedModels/TerminalState.swift diff --git a/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteTerminal.swift b/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteTerminal.swift index 7ba2249..7b0ab1b 100644 --- a/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteTerminal.swift +++ b/MacApp/Packages/SharedModels/Sources/SharedModels/RemoteTerminal.swift @@ -15,7 +15,7 @@ public struct RemoteTerminal: Codable, Equatable, Hashable, Identifiable, Sendab public let profile: String /// Текущее состояние терминала. - public let state: String + public let state: TerminalState /// Время создания терминала. public let createdAt: Date @@ -36,7 +36,7 @@ public struct RemoteTerminal: Codable, Equatable, Hashable, Identifiable, Sendab id: String, sessionId: String, profile: String, - state: String, + state: TerminalState, createdAt: Date, updatedAt: Date ) { diff --git a/MacApp/Packages/SharedModels/Sources/SharedModels/TerminalState.swift b/MacApp/Packages/SharedModels/Sources/SharedModels/TerminalState.swift new file mode 100644 index 0000000..44ff1c4 --- /dev/null +++ b/MacApp/Packages/SharedModels/Sources/SharedModels/TerminalState.swift @@ -0,0 +1,21 @@ +import Foundation + +// MARK: - TerminalState + +/// Состояние PTY-терминала внутри сессии. +public enum TerminalState: String, Codable, Equatable, Hashable, Sendable { + /// Терминал запускается. + case starting + + /// Терминал готов к вводу. + case running + + /// Терминал остановлен. + case stopped + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let rawValue = try container.decode(String.self) + self = TerminalState(rawValue: rawValue) ?? .stopped + } +} From 39b8d65b1482583de39905280508022a6176ff2b Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 22:02:19 +0300 Subject: [PATCH 3/8] =?UTF-8?q?Update=20domain-model.md=20=C2=A77=20with?= =?UTF-8?q?=20D-14=20and=20D-15=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D-14: Profile.id — mark as MVP acceptable (name-based identity) D-15: TerminalState enum — mark as closed Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture/domain-model.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/architecture/domain-model.md b/docs/architecture/domain-model.md index 355561e..6a079cd 100644 --- a/docs/architecture/domain-model.md +++ b/docs/architecture/domain-model.md @@ -233,3 +233,5 @@ Session.image (explicit override) | D-11 | `Server.default_image: String?` (клиентская сущность) | `ServerCredential.defaultImage` добавлен. | ✅ Закрыт | | D-12 | `Session.work_dir: Path` (персистентное) | Добавлен в DB, struct, REST, Swift. | ✅ Закрыт | | D-13 | `Session.stopped_at: Timestamp?` в Swift-модели | `stoppedAt: Date?` добавлен в `RemoteSession` (CodingKey `"stopped_at"`). Сервер уже отдавал поле. | ✅ Закрыт | +| D-14 | `Profile.id: String` отсутствует в REST и Swift | `ProfileResponse` и `SessionProfile` идентифицируют профиль по `name`. Сервер профили не персистирует — хранятся в конфиге. Для MVP приемлемо; добавить `id` при введении серверной персистенции профилей. | 🔵 MVP: по имени | +| D-15 | `Terminal.state` — нетипизированная строка | `TerminalState` enum добавлен (`starting/running/stopped`), `RemoteTerminal.state: TerminalState`. | ✅ Закрыт | From 60be9ce6d241d9dc1a681d1066848a505e5726ed Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 22:07:58 +0300 Subject: [PATCH 4/8] Add domain model compliance test plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers gaps in CloudEntitiesTests: RemoteTerminal, TerminalState, RemoteSession.stoppedAt, ServerCredential.defaultImage, image resolution chain §5, and FK regression cases (15 TCs total). Co-Authored-By: Claude Sonnet 4.6 --- .../domain-model-compliance-test-plan.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/testplans/domain-model-compliance-test-plan.md diff --git a/docs/testplans/domain-model-compliance-test-plan.md b/docs/testplans/domain-model-compliance-test-plan.md new file mode 100644 index 0000000..3e6327c --- /dev/null +++ b/docs/testplans/domain-model-compliance-test-plan.md @@ -0,0 +1,260 @@ +--- +type: test-plan +slug: domain-model-compliance +--- + +# Test Plan — Domain Model Compliance + +**Feature:** Соответствие Swift-моделей утверждённой доменной модели +**Spec:** `docs/architecture/domain-model.md` v1.0 (статус: утверждена) +**Scope:** `SharedModels` package + `ServerCredential` +**Created:** 2026-04-24 + +**Lightweight template applied** — нет UI-поверхности; TCs используют Given/When/Then. + +--- + +## Findings + +Существующие тесты (`CloudEntitiesTests.swift`) покрывают декодирование `ProjectState`, `SessionState`, `SessionProfile`, `RemoteProject`, `RemoteSession`. Пробелы: + +1. `RemoteTerminal` и `TerminalState` — нет ни одного теста (D-15 закрыт кодом, не тестами) +2. `RemoteSession.stoppedAt` — нет (D-13 закрыт кодом, не тестами) +3. `ServerCredential.defaultImage` — нет +4. JSON с `stopped_at` → `stoppedAt: Date?` — не проверено +5. Image resolution chain §5 — не протестирована как поведение (только структуры по отдельности) +6. Lifecycle-инварианты (`stopped_at` = nil при `running`, non-nil при `stopped`) — не проверены + +--- + +## Risk Areas + +| Риск | Уровень | +|------|---------| +| Новые поля (`stoppedAt`) молча теряются если CodingKey неверен | High | +| `TerminalState` unknown fallback → неверный UI | Medium | +| Image resolution chain — неверный приоритет (Project > Server вместо Session > Project > Server) | High | +| `ServerCredential.defaultImage` не попадает в цепочку | Medium | +| Lifecycle-инварианты не соблюдаются при смене состояния | Low | + +--- + +## Test Cases + +### TC-1 — RemoteTerminal: decode из REST-JSON + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Preconditions** | `TerminalState` enum присутствует в `SharedModels` | +| **Scenario** | **Given** JSON с полями `id`, `session_id`, `profile`, `state: "running"`, `created_at`, `updated_at` **When** декодируется как `RemoteTerminal` **Then** все поля разобраны корректно, `state == .running` | +| **Source** | Spec §2 Terminal | + +--- + +### TC-2 — TerminalState: все значения декодируются + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Preconditions** | — | +| **Scenario** | **Given** массив строк `["starting", "running", "stopped"]` **When** декодируется как `[TerminalState]` **Then** возвращает `.starting`, `.running`, `.stopped` | +| **Source** | Spec §2 Terminal, `TerminalState.swift` | + +--- + +### TC-3 — TerminalState: unknown → fallback .stopped + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Preconditions** | — | +| **Scenario** | **Given** строка `"paused"` (не существует в enum) **When** декодируется как `TerminalState` **Then** возвращает `.stopped`, без throw | +| **Source** | `TerminalState.swift` — safe fallback декодер | + +--- + +### TC-4 — RemoteTerminal: encode/decode roundtrip + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Preconditions** | — | +| **Scenario** | **Given** `RemoteTerminal` с `state: .running` **When** encode → decode **Then** все поля сохранены, включая `state` | +| **Source** | Spec §2 Terminal | + +--- + +### TC-5 — RemoteSession.stoppedAt: decode с полем stopped_at + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Preconditions** | `RemoteSession` имеет поле `stoppedAt: Date?` (D-13) | +| **Scenario** | **Given** JSON сессии с `"state": "stopped"` и `"stopped_at": "2026-04-14T15:00:00Z"` **When** декодируется как `RemoteSession` **Then** `session.stoppedAt != nil`, значение соответствует ISO-8601 строке | +| **Source** | Spec §2 Session, D-13 | + +--- + +### TC-6 — RemoteSession.stoppedAt: null для running-сессии + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Preconditions** | — | +| **Scenario** | **Given** JSON сессии с `"state": "running"` и без поля `stopped_at` **When** декодируется как `RemoteSession` **Then** `session.stoppedAt == nil` | +| **Source** | Spec §2 Session lifecycle | + +--- + +### TC-7 — RemoteSession.stoppedAt: encode/decode roundtrip + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Preconditions** | — | +| **Scenario** | **Given** `RemoteSession` с `stoppedAt: Date(...)` **When** encode → decode **Then** `stoppedAt` сохраняется | +| **Source** | D-13 | + +--- + +### TC-8 — ServerCredential.defaultImage: encode/decode roundtrip + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Preconditions** | — | +| **Scenario** | **Given** `ServerCredential` с `defaultImage: "ubuntu:24.04"` **When** encode → decode **Then** `defaultImage == "ubuntu:24.04"` | +| **Source** | Spec §2 Server, D-11 | + +--- + +### TC-9 — ServerCredential.defaultImage: nil по умолчанию + +| | | +|---|---| +| **Priority** | P2 Medium | +| **Tier** | Feature | +| **Preconditions** | — | +| **Scenario** | **Given** `ServerCredential` создан без `defaultImage` **When** читается `defaultImage` **Then** `== nil` | +| **Source** | Spec §2 Server | + +--- + +### TC-10 — Image resolution: Session.image имеет приоритет над Project + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Preconditions** | Логика resolution chain реализована в UI/ViewModel | +| **Scenario** | **Given** `RemoteSession.image = "custom:latest"`, `RemoteProject.defaultImage = "ubuntu:24.04"`, `ServerCredential.defaultImage = "alpine:latest"` **When** вычисляется образ для отображения **Then** результат `"custom:latest"` (Session wins) | +| **Source** | Spec §5 | + +--- + +### TC-11 — Image resolution: Project.defaultImage, когда Session.image = "" + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Preconditions** | Логика resolution chain реализована | +| **Scenario** | **Given** `RemoteSession.image = ""`, `RemoteProject.defaultImage = "node:20"`, `ServerCredential.defaultImage = "alpine:latest"` **When** вычисляется образ **Then** результат `"node:20"` (Project wins над Server) | +| **Source** | Spec §5 | + +--- + +### TC-12 — Image resolution: ServerCredential.defaultImage как последний fallback + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Preconditions** | Логика resolution chain реализована | +| **Scenario** | **Given** `RemoteSession.image = ""`, `RemoteProject.defaultImage = nil`, `ServerCredential.defaultImage = "alpine:latest"` **When** вычисляется образ **Then** результат `"alpine:latest"` | +| **Source** | Spec §5 | + +--- + +### TC-13 — RemoteSession.projectId: FK присутствует + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Regression | +| **Preconditions** | — | +| **Scenario** | **Given** JSON сессии с `"project_id": "proj-abc"` **When** декодируется **Then** `session.projectId == "proj-abc"` | +| **Source** | Spec §3, D-6, D-7 | + +--- + +### TC-14 — RemoteTerminal.sessionId: FK присутствует + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Regression | +| **Preconditions** | — | +| **Scenario** | **Given** JSON терминала с `"session_id": "sess-abc"` **When** декодируется **Then** `terminal.sessionId == "sess-abc"` | +| **Source** | Spec §3, D-2 | + +--- + +### TC-15 — SessionProfile: не содержит поле image + +| | | +|---|---| +| **Priority** | P2 Medium | +| **Tier** | Regression | +| **Preconditions** | — | +| **Scenario** | **Given** JSON профиля с лишним полем `"image": "ubuntu:24.04"` **When** декодируется как `SessionProfile` **Then** нет ошибки, поле молча игнорируется; объект содержит только `name`, `initCommand`, `envVars` | +| **Source** | Spec §2 Profile, D-3 — image убран из профиля | + +--- + +## Edge Cases & Negative Scenarios + +| TC | Сценарий | Ожидаемый результат | +|----|----------|---------------------| +| TC-E1 | `RemoteSession` JSON без опциональных полей (`container_id`, `stopped_at`, `error_reason`, `profile`) | Декодирование успешно, все nil | +| TC-E2 | `TerminalState` для каждого из трёх значений в цикле | Нет неожиданных fallback-ов | +| TC-E3 | `RemoteProject.state = "failed"` + `clone_error` присутствует | Оба поля декодируются | +| TC-E4 | `RemoteSession` с `state: "stopped"` и `stopped_at` одновременно — roundtrip | Данные не теряются при encode/decode | + +--- + +## Coverage Matrix + +| Сущность | Decode | Encode | Roundtrip | Lifecycle | FK | +|----------|--------|--------|-----------|-----------|-----| +| `ProjectState` | ✅ есть | — | — | — | — | +| `SessionState` | ✅ есть | — | — | — | — | +| `TerminalState` | ❌ **TC-2,3** | — | ❌ **TC-4** | — | — | +| `SessionProfile` | ✅ есть | ✅ есть | ✅ есть | — | — | +| `RemoteProject` | ✅ есть | ✅ есть | ✅ есть | — | — | +| `RemoteSession` | ✅ частично | ✅ есть | ✅ есть | ❌ **TC-6** | ❌ **TC-13** | +| `RemoteSession.stoppedAt` | ❌ **TC-5** | — | ❌ **TC-7** | ❌ **TC-6** | — | +| `RemoteTerminal` | ❌ **TC-1** | — | ❌ **TC-4** | — | ❌ **TC-14** | +| `ServerCredential.defaultImage` | — | — | ❌ **TC-8,9** | — | — | +| Image resolution chain §5 | — | — | — | ❌ **TC-10,11,12** | — | + +--- + +## Suggested Automation Candidates + +Все TC подходят для автоматизации через Swift Testing (`@Test`/`@Suite`) в `CloudEntitiesTests.swift`: + +- **TC-1..4** → новый `@Suite("Remote Terminal")` в `CloudEntitiesTests` +- **TC-5..7** → расширение `// MARK: - RemoteSession` в том же файле +- **TC-8..9** → новый `@Suite("ServerCredential")` в `RemoteTerminalTests/ServerCredentialTests.swift` +- **TC-10..12** → отдельный тест если resolution chain станет функцией; пока `[inferred from code]` +- **TC-13..15** → `@Suite("Domain Model FK")` regression suite From 19d036c2c4b2d94634b4112610dc04f9a8aadc2d Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 22:14:51 +0300 Subject: [PATCH 5/8] Add terminal tab switching test plan (12 TCs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers gaps in existing 11-client-ui.md: worktree selection → session directory, PTY isolation between worktrees, Open Terminal availability by session state, and tab persistence across project navigation. Spec source: domain-model.md §2-4. Co-Authored-By: Claude Sonnet 4.6 --- .../terminal-tab-switching-test-plan.md | 268 ++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 docs/testplans/terminal-tab-switching-test-plan.md diff --git a/docs/testplans/terminal-tab-switching-test-plan.md b/docs/testplans/terminal-tab-switching-test-plan.md new file mode 100644 index 0000000..8cc8052 --- /dev/null +++ b/docs/testplans/terminal-tab-switching-test-plan.md @@ -0,0 +1,268 @@ +--- +type: test-plan +slug: terminal-tab-switching +--- + +# Test Plan — Terminal Tab Switching + +**Feature:** Переключение терминальных вкладок при смене сессии / worktree / workspace +**Spec:** `docs/architecture/domain-model.md` §2–4, `docs/testplans/11-client-ui.md`, `docs/testplans/03-session-lifecycle.md` +**Created:** 2026-04-24 + +--- + +## Findings + +Существующие тест-планы (`11-client-ui.md`) имеют TC-11-08..TC-11-10 — базовые сценарии открытия/закрытия вкладок. Но нет TC на: + +1. Переключение активной вкладки при выборе worktree в sidebar +2. Поведение default-вкладки при смене проекта +3. Изоляцию вкладок между worktrees: вкладка одного worktree не влияет на другой +4. Remote: выбор сессии → открытие нужного терминала +5. Состояния сессии, при которых "Open Terminal" недоступен +6. Multiple terminals 1:N внутри одной сессии (вкладки независимы) + +--- + +## Доменные инварианты (из spec) + +Из `domain-model.md` §2–4: + +- **Session : Terminal = 1 : N** — каждый терминал независимый PTY внутри одной сессии +- **Worktree ↔ Session** — каждый worktree имеет свой `work_dir`; сессии между local и remote не переносятся +- **Local**: контейнера нет, PTY-процессы на хосте, `work_dir = git worktree` +- **Remote**: Session = 1 Docker-контейнер; терминалы = `docker exec` +- **Закрытие вкладки** ≠ остановка сессии (detach, не terminate) — TC-11-09 + +--- + +## Risk Areas + +| Риск | Уровень | +|------|---------| +| При смене worktree открывается терминал в неверной директории | High | +| Вкладки разных worktrees влияют друг на друга | High | +| "Open Terminal" доступен на stopped/failed сессии | Medium | +| После закрытия последней вкладки сессия не остаётся running | High | +| Default-вкладка не переключается при открытии другого проекта | Medium | +| Множественные табы одной сессии не изолированы | Medium | + +--- + +## Test Cases + +### TC-1 — Открытие проекта создаёт default вкладку + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Local | +| **Preconditions** | Relay.app запущен; локальный git-репозиторий доступен | +| **Steps** | 1. File → Open Project (или drag-and-drop директории репозитория)
2. Проверить вкладки | +| **Expected** | Создаётся ровно одна вкладка; title содержит имя проекта; вкладка активна; worktree sidebar показывает main worktree | +| **Fail** | Нет вкладки; вкладка не активна; неправильная директория | +| **Source** | domain-model.md §4 Local, 11-client-ui.md | + +--- + +### TC-2 — Смена worktree → новая Claude-сессия запускается в директории выбранного worktree + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Local | +| **Preconditions** | Проект открыт; в репозитории есть 2+ worktrees | +| **Steps** | 1. Sidebar: выбрать worktree `feature/auth` (не main)
2. Нажать "New Claude Session"
3. Проверить рабочую директорию в новой вкладке | +| **Expected** | Новая вкладка открылась; `pwd` в терминале = путь к worktree `feature/auth`; предыдущая вкладка осталась открытой | +| **Fail** | Новая вкладка открылась в директории другого worktree; предыдущая вкладка закрылась | +| **Source** | domain-model.md §4 Local | + +--- + +### TC-3 — Вкладки разных worktrees изолированы + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Feature | +| **Mode** | Local | +| **Preconditions** | Два worktrees: `main`, `feature/auth` — каждый имеет открытую вкладку | +| **Steps** | 1. Перейти на вкладку `main`; выполнить команду `touch /tmp/test-main`
2. Переключиться на вкладку `feature/auth`
3. Проверить, что команда не повлияла на состояние второй вкладки | +| **Expected** | Каждая вкладка — независимый PTY в своей директории; команды в одном не влияют на другой | +| **Fail** | Вывод команды появился в обоих терминалах; PTY-процессы разделены | +| **Source** | domain-model.md §2 Terminal, §4 Local | + +--- + +### TC-4 — Закрытие вкладки не убивает PTY-сессию + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Local | +| **Preconditions** | Открыта вкладка с активным Claude Code агентом | +| **Steps** | 1. Запустить долгую команду в терминале
2. Закрыть вкладку (Cmd+W)
3. Открыть новую вкладку к той же сессии (если возможно)
4. Либо проверить через Activity Monitor, что процесс жив | +| **Expected** | Вкладка закрывается; PTY-процесс продолжает работу (detach) | +| **Fail** | PTY-процесс убит; сессия перешла в `stopped`/`failed` | +| **Source** | domain-model.md §2 Terminal, 11-client-ui.md TC-11-09 | + +--- + +### TC-5 — Remote: "Open Terminal" создаёт новую вкладку для running-сессии + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Remote | +| **Preconditions** | Подключён к серверу; сессия в состоянии `running` | +| **Steps** | 1. Session list → выбрать running сессию
2. Нажать "Open Terminal"
3. Проверить вкладки | +| **Expected** | Новая terminal-вкладка открылась; title содержит имя сессии/проекта; shell prompt в контейнере доступен | +| **Fail** | Вкладка не открылась; нет prompt; вкладка открылась не для той сессии | +| **Source** | domain-model.md §4 Remote, 11-client-ui.md TC-11-08 | + +--- + +### TC-6 — Remote: "Open Terminal" недоступен для stopped-сессии + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Remote | +| **Preconditions** | Сессия в состоянии `stopped` | +| **Steps** | 1. Session list → выбрать stopped сессию
2. Проверить наличие кнопки "Open Terminal" | +| **Expected** | "Open Terminal" недоступна (disabled или отсутствует); доступны только Resume / Delete | +| **Fail** | "Open Terminal" доступна для stopped сессии; нажатие вызывает ошибку без UX | +| **Source** | domain-model.md §2 SessionState lifecycle | + +--- + +### TC-7 — Remote: два "Open Terminal" на одной сессии — два независимых PTY + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Remote | +| **Preconditions** | Сессия `running` | +| **Steps** | 1. "Open Terminal" → вкладка T1
2. Вернуться в session list → "Open Terminal" снова → вкладка T2
3. В T1 запустить `echo "hello from T1"`
4. Проверить T2 | +| **Expected** | T1 и T2 — отдельные PTY (docker exec) внутри одного контейнера; вывод T1 не появляется в T2 | +| **Fail** | T1 и T2 разделяют stdin/stdout; закрытие T1 убивает T2 | +| **Source** | domain-model.md §2 Terminal (1:N), 11-client-ui.md TC-11-10 | + +--- + +### TC-8 — Remote: закрытие terminal-вкладки не останавливает сессию + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Remote | +| **Preconditions** | Одна terminal-вкладка открыта для remote-сессии | +| **Steps** | 1. Закрыть terminal-вкладку
2. Проверить статус сессии в session list | +| **Expected** | Вкладка закрыта; сессия остаётся `running`; контейнер жив | +| **Fail** | Сессия перешла в `stopped`; контейнер убит | +| **Source** | domain-model.md §2 Session lifecycle, 11-client-ui.md TC-11-09 | + +--- + +### TC-9 — Local: переключение между существующими вкладками + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Local | +| **Preconditions** | Открыто 3 вкладки для разных worktrees | +| **Steps** | 1. Кликнуть вкладку T1 → Cmd+1
2. Кликнуть вкладку T3 → Cmd+3
3. Cmd+[ / Cmd+] для перехода между соседними | +| **Expected** | Каждое переключение активирует нужный PTY; контент вкладки соответствует её директории; focus в терминале | +| **Fail** | Неверная вкладка активирована; контент перемешан; фокус потерян | +| **Source** | domain-model.md §4 Local | + +--- + +### TC-10 — Remote: "Open Terminal" недоступен для failed-сессии + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Remote | +| **Preconditions** | Сессия в состоянии `failed` | +| **Steps** | 1. Session list → выбрать failed сессию
2. Проверить доступные действия | +| **Expected** | "Open Terminal" недоступна; доступны Retry / Delete; показан `error_reason` | +| **Fail** | "Open Terminal" активна; нет отображения причины ошибки | +| **Source** | domain-model.md §2 SessionState, 11-client-ui.md | + +--- + +### TC-11 — Local: смена выбранного worktree обновляет директорию для следующей сессии + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Local | +| **Preconditions** | Проект с 3+ worktrees; sidebar загружен | +| **Steps** | 1. Sidebar: выбрать worktree A → New Shell Tab → проверить `pwd`
2. Sidebar: выбрать worktree B → New Shell Tab → проверить `pwd` | +| **Expected** | Первый shell открылся в директории A; второй — в директории B; обе вкладки существуют | +| **Fail** | Оба shell открылись в одной директории; второй переиспользовал вкладку первого | +| **Source** | domain-model.md §2 Session.work_dir | + +--- + +### TC-12 — Remote: переход к другому проекту не закрывает уже открытые terminal-вкладки + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Remote | +| **Preconditions** | Открыты терминалы Project A (сессия SA) и Project B (сессия SB) | +| **Steps** | 1. Navigation: перейти к Project B в sidebar
2. Проверить вкладки для SA | +| **Expected** | Terminal-вкладки SA остаются открытыми; переключение проектов в sidebar не закрывает существующие терминалы | +| **Fail** | Вкладки SA закрылись при переходе к Project B | +| **Source** | domain-model.md §4 Гибридный режим | + +--- + +## Edge Cases & Negative Scenarios + +| TC | Сценарий | Ожидаемый результат | +|----|----------|---------------------| +| TC-E1 | "Open Terminal" при отсутствии сети (remote offline) | Ошибка подключения с понятным сообщением; вкладка не открывается в broken-состоянии | +| TC-E2 | Закрытие единственной вкладки (local, default tab) | Default-вкладка не удаляется (защита); или создаётся замена автоматически | +| TC-E3 | Быстрое переключение между worktrees (< 1с) | Нет race condition; активируется последний выбранный | +| TC-E4 | Remote сессия перешла в `failed` пока открыта её terminal-вкладка | Вкладка показывает disconnect-состояние или ошибку; не зависает | +| TC-E5 | Worktree удалён из git пока открыта его вкладка | Sidebar обновляется; вкладка может остаться или показать ошибку пути | + +--- + +## Coverage Matrix + +| Сценарий | Покрыт в 11-client-ui.md | Новый TC | +|----------|--------------------------|----------| +| Открытие terminal-вкладки (running remote) | TC-11-08 | — | +| Закрытие вкладки → сессия running | TC-11-09 | TC-4, TC-8 | +| Множественные вкладки одной сессии | TC-11-10 | TC-7 | +| Выбор worktree → директория новой сессии | ❌ | **TC-2, TC-11** | +| Изоляция PTY между worktrees | ❌ | **TC-3** | +| Open Terminal на stopped сессии | ❌ | **TC-6, TC-10** | +| Переключение существующих вкладок | ❌ | **TC-9** | +| Смена проекта не закрывает чужие вкладки | ❌ | **TC-12** | +| Default-вкладка при открытии проекта | ❌ | **TC-1** | +| Race condition при быстрой смене worktree | ❌ | **TC-E3** | + +--- + +## Suggested Automation Candidates + +- **TC-1, TC-4, TC-8** — автоматизируемы через `xcodebuild test` (TCA TestStore на `MainFeature`): проверить `state.tabs` после соответствующих actions +- **TC-2, TC-11** — TCA TestStore: `selectWorktree` → `newClaudeSession` → assert `tabs.last.workingDirectory` +- **TC-7** — TCA TestStore: два `.openTerminal(session)` → assert `tabs.count == 2` +- **TC-3, TC-5, TC-6, TC-9, TC-10, TC-12** — ручное тестирование на запущенном приложении (требуют живого runner или PTY) From 94880267bca4407bb20b9def62cf7e808f449082 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 22:16:54 +0300 Subject: [PATCH 6/8] Add session-switch TC-12..TC-14 to terminal tab switching plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TC-12: session with open terminal → active tab switches TC-13: session without terminal → no auto-open (explicit action required) TC-14: tab switch → sidebar selection syncs Co-Authored-By: Claude Sonnet 4.6 --- .../terminal-tab-switching-test-plan.md | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/docs/testplans/terminal-tab-switching-test-plan.md b/docs/testplans/terminal-tab-switching-test-plan.md index 8cc8052..c61df3d 100644 --- a/docs/testplans/terminal-tab-switching-test-plan.md +++ b/docs/testplans/terminal-tab-switching-test-plan.md @@ -216,7 +216,52 @@ slug: terminal-tab-switching --- -### TC-12 — Remote: переход к другому проекту не закрывает уже открытые terminal-вкладки +### TC-12 — Remote: выбор сессии с открытым терминалом переключает активную вкладку + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Remote | +| **Preconditions** | Открыты терминалы для двух сессий SA и SB; активна вкладка SA | +| **Steps** | 1. Session list → выбрать SB (у которой уже открыт терминал)
2. Проверить активную вкладку | +| **Expected** | Активная вкладка переключается на терминал SB; вкладка SA остаётся открытой; содержимое SB-терминала отображается | +| **Fail** | Активная вкладка не изменилась; открылась новая дублирующая вкладка для SB | +| **Source** | domain-model.md §2 Session → Terminal, §3 отношения | + +--- + +### TC-13 — Remote: выбор сессии без открытого терминала не переключает вкладки автоматически + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Remote | +| **Preconditions** | Одна terminal-вкладка открыта для SA; SB — running, терминал не открывался | +| **Steps** | 1. Session list → выбрать SB
2. Проверить вкладки и активный терминал | +| **Expected** | Вкладка SA остаётся активной; для SB не открывается терминал автоматически; доступна кнопка "Open Terminal" для SB | +| **Fail** | Автоматически открылся терминал SB; вкладка SA потеряла фокус | +| **Source** | domain-model.md §2 Terminal — создаётся явным действием, не автоматически | + +--- + +### TC-14 — Local: переключение вкладки обновляет активный worktree в sidebar + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Local | +| **Preconditions** | Открыты вкладки для worktrees A и B; в sidebar выбран A | +| **Steps** | 1. Переключиться на вкладку B (клик или Cmd+2)
2. Проверить выделение в worktree sidebar | +| **Expected** | Sidebar переключается на worktree B; выделение соответствует активной вкладке | +| **Fail** | Sidebar остался на A; sidebar и вкладки рассинхронизированы | +| **Source** | domain-model.md §4 Local — tab = session = worktree | + +--- + +### TC-15 — Remote: переход к другому проекту не закрывает уже открытые terminal-вкладки | | | |---|---| @@ -229,6 +274,7 @@ slug: terminal-tab-switching | **Fail** | Вкладки SA закрылись при переходе к Project B | | **Source** | domain-model.md §4 Гибридный режим | + --- ## Edge Cases & Negative Scenarios @@ -254,7 +300,10 @@ slug: terminal-tab-switching | Изоляция PTY между worktrees | ❌ | **TC-3** | | Open Terminal на stopped сессии | ❌ | **TC-6, TC-10** | | Переключение существующих вкладок | ❌ | **TC-9** | -| Смена проекта не закрывает чужие вкладки | ❌ | **TC-12** | +| Выбор сессии с терминалом → переключает вкладку | ❌ | **TC-12** | +| Выбор сессии без терминала → не открывает автоматически | ❌ | **TC-13** | +| Переключение вкладки → sidebar синхронизируется | ❌ | **TC-14** | +| Смена проекта не закрывает чужие вкладки | ❌ | **TC-15** | | Default-вкладка при открытии проекта | ❌ | **TC-1** | | Race condition при быстрой смене worktree | ❌ | **TC-E3** | From 4b1ad9d2bd694952c8f6202baf66fb07da14d2bd Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 22:19:07 +0300 Subject: [PATCH 7/8] Add terminal state persistence TCs (TC-16..TC-22) Covers client-side VT state preservation on session switch: - Remote scrollback/snapshot on tab switch (TC-16, TC-18) - Accumulated output while tab was inactive (TC-17) - VT state isolation between sessions (TC-19) - Local PTY process continuity across tab switches (TC-20, TC-21) - Auto-reconnect VT restore after network loss (TC-22) Co-Authored-By: Claude Sonnet 4.6 --- .../terminal-tab-switching-test-plan.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/docs/testplans/terminal-tab-switching-test-plan.md b/docs/testplans/terminal-tab-switching-test-plan.md index c61df3d..b2deca4 100644 --- a/docs/testplans/terminal-tab-switching-test-plan.md +++ b/docs/testplans/terminal-tab-switching-test-plan.md @@ -275,6 +275,119 @@ slug: terminal-tab-switching | **Source** | domain-model.md §4 Гибридный режим | +--- + +## Terminal State Persistence on Session Switch + +> Источник: `relay-cloud-architecture.md` §VT parsing, `04-terminal-io.md` TC-04-12. +> Сервер хранит VT state через `vt_parser.rs` (vte crate): screen buffer + scrollback до `scrollback_lines` строк; при attach клиент получает `AttachResponse.screen_snapshot` (до `snapshot_max_lines = 500` строк). +> Локальный PTY остаётся живым при detach — клиент просто теряет ссылку на процесс. + +--- + +### TC-16 — Remote: переключение от сессии и обратно — scrollback сохранён + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Remote | +| **Preconditions** | Открыты вкладки SA и SB; в SA выведен уникальный текст | +| **Steps** | 1. В вкладке SA выполнить `echo "MARKER-12345"`
2. Переключиться на вкладку SB; поработать в ней
3. Переключиться обратно на SA | +| **Expected** | Scrollback SA содержит `MARKER-12345`; визуальное состояние экрана восстановлено из VT snapshot; курсор на правильной позиции | +| **Fail** | Экран SA пустой после возврата; scrollback потерян; курсор сброшен | +| **Source** | relay-cloud-architecture.md §VT snapshot, TC-04-12 | + +--- + +### TC-17 — Remote: процесс продолжал выводить пока вкладка была не активна — вывод накоплен + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Feature | +| **Mode** | Remote | +| **Preconditions** | В SA запущена команда с периодическим выводом (например, `ping localhost`) | +| **Steps** | 1. SA: запустить `ping localhost`
2. Переключиться на SB на 5с
3. Переключиться обратно на SA | +| **Expected** | Scrollback SA содержит строки ping, выведенные пока вкладка была неактивна; вывод непрерывный; процесс не прерывался | +| **Fail** | Вывод прервался на момент переключения; строки пропущены; процесс завис | +| **Source** | relay-cloud-architecture.md §Connection resilience, vt_parser.rs — буфер сохраняет вывод | + +--- + +### TC-18 — Remote: закрытие и повторное открытие terminal-вкладки одной сессии — VT snapshot + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Remote | +| **Preconditions** | Сессия SA, открыт терминал; выведен текст | +| **Steps** | 1. В SA вывести `echo "STATE-BEFORE"`
2. Закрыть вкладку SA (Cmd+W)
3. Session list → SA → "Open Terminal" снова | +| **Expected** | Новая вкладка SA показывает экран из VT snapshot (последние ≤500 строк); `STATE-BEFORE` виден; процесс в сессии не прерывался | +| **Fail** | Вкладка открылась с пустым экраном; scrollback не загружен; сессия перезапустилась | +| **Source** | relay-cloud-architecture.md §VT snapshot, TC-04-12 | + +--- + +### TC-19 — Remote: две сессии — VT state независимы, не смешиваются + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Feature | +| **Mode** | Remote | +| **Preconditions** | SA и SB — разные running сессии, каждая с открытым терминалом | +| **Steps** | 1. SA: `echo "FROM-SESSION-A"`
2. SB: `echo "FROM-SESSION-B"`
3. Переключиться SA → SB → SA несколько раз | +| **Expected** | Scrollback SA содержит только `FROM-SESSION-A`; scrollback SB — только `FROM-SESSION-B`; никакого смешения | +| **Fail** | Вывод одной сессии появился в scrollback другой; состояния перепутаны | +| **Source** | domain-model.md §3 Session → Terminal независимость | + +--- + +### TC-20 — Local: переключение вкладок — PTY-процессы независимы, состояние сохранено + +| | | +|---|---| +| **Priority** | P0 Critical | +| **Tier** | Smoke | +| **Mode** | Local | +| **Preconditions** | Открыты вкладки для двух worktrees | +| **Steps** | 1. Вкладка A: запустить `watch date` (непрерывный вывод)
2. Переключиться на вкладку B; поработать 5с
3. Переключиться обратно на A | +| **Expected** | `watch date` продолжал работать; вывод за время отсутствия накоплен; вкладка B не показывает вывод `watch date` | +| **Fail** | `watch date` остановился; вывод пропал; вывод появился в B | +| **Source** | domain-model.md §4 Local — PTY на хосте, нет детача | + +--- + +### TC-21 — Local: CWD каждой вкладки сохранён при переключении + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Local | +| **Preconditions** | Вкладка A — worktree `main`; вкладка B — worktree `feature/auth` | +| **Steps** | 1. Вкладка A: `cd src && pwd` → запомнить путь
2. Переключиться на B; `cd tests && pwd`
3. Переключиться обратно на A: `pwd` | +| **Expected** | A показывает `.../main/src`; B показывает `.../feature/auth/tests`; каждый PTY сохраняет свой CWD независимо | +| **Fail** | CWD сбросился до корня при переключении; CWD одной вкладки скопировался в другую | +| **Source** | domain-model.md §2 Session.work_dir, §4 Local | + +--- + +### TC-22 — Remote: повторное подключение после потери сети — VT state восстановлен + +| | | +|---|---| +| **Priority** | P1 High | +| **Tier** | Feature | +| **Mode** | Remote | +| **Preconditions** | SA с открытым терминалом; стабильное соединение | +| **Steps** | 1. SA: `echo "BEFORE-DISCONNECT"`
2. Отключить сеть на 5с
3. Включить сеть; ждать reconnect
4. Переключиться на SA-вкладку | +| **Expected** | После reconnect экран восстановлен из VT snapshot; `BEFORE-DISCONNECT` виден; reconnect произошёл автоматически (exponential backoff) | +| **Fail** | Экран пустой; нет auto-reconnect; сессия перешла в `failed` из-за потери сети | +| **Source** | relay-cloud-architecture.md §Connection resilience, TC-04-12 | + --- ## Edge Cases & Negative Scenarios @@ -306,6 +419,13 @@ slug: terminal-tab-switching | Смена проекта не закрывает чужие вкладки | ❌ | **TC-15** | | Default-вкладка при открытии проекта | ❌ | **TC-1** | | Race condition при быстрой смене worktree | ❌ | **TC-E3** | +| Remote scrollback сохранён при switch away/back | TC-04-12 (gRPC) | **TC-16** | +| Remote: накопленный вывод при неактивной вкладке | ❌ | **TC-17** | +| Remote: закрыть/открыть вкладку → VT snapshot | TC-04-12 (gRPC) | **TC-18** | +| Remote: VT state двух сессий не смешивается | ❌ | **TC-19** | +| Local: PTY работает пока вкладка неактивна | ❌ | **TC-20** | +| Local: CWD каждой вкладки независим | ❌ | **TC-21** | +| Remote: auto-reconnect → VT state восстановлен | ❌ | **TC-22** | --- From 54a63f20958437e831c72edb9c43f991508d00f2 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 24 Apr 2026 22:32:47 +0300 Subject: [PATCH 8/8] Add DomainModelAlignmentTests for D-13 and D-15 Tests run in Relay scheme (RelayTests host): - stoppedAt decodes from JSON, nil when absent, roundtrip (D-13) - TerminalState all values + unknown fallback (D-15) - RemoteTerminal full decode, sessionId FK, roundtrip (D-2/D-15) 9 tests, all passing. Co-Authored-By: Claude Sonnet 4.6 --- .../DomainModelAlignmentTests.swift | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 MacApp/RelayTests/DomainModelAlignmentTests.swift diff --git a/MacApp/RelayTests/DomainModelAlignmentTests.swift b/MacApp/RelayTests/DomainModelAlignmentTests.swift new file mode 100644 index 0000000..2108523 --- /dev/null +++ b/MacApp/RelayTests/DomainModelAlignmentTests.swift @@ -0,0 +1,193 @@ +import Foundation +import SharedModels +import Testing + +// Tests for domain model alignment changes introduced in PR #289: +// - D-13: RemoteSession.stoppedAt (was missing; server already sent stopped_at) +// - D-15: TerminalState enum (was untyped String in RemoteTerminal) + +@Suite("Domain Model Alignment") +struct DomainModelAlignmentTests { + + private let decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + + private let encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() + + // MARK: - RemoteSession.stoppedAt (D-13) + + @Test("stoppedAt: decodes stopped_at from JSON") + func stoppedAtDecodesFromJSON() throws { + let json = Data(""" + { + "id": "sess-d13-1", + "project_id": "proj-001", + "branch": "main", + "image": "ubuntu:24.04", + "state": "stopped", + "work_dir": "/data/sessions/sess-d13-1", + "stopped_at": "2026-04-14T15:00:00Z", + "created_at": "2026-04-14T10:00:00Z", + "updated_at": "2026-04-14T15:00:00Z" + } + """.utf8) + + let session = try decoder.decode(RemoteSession.self, from: json) + + #expect(session.state == .stopped) + #expect(session.stoppedAt != nil) + } + + @Test("stoppedAt: nil when field absent in JSON") + func stoppedAtNilWhenAbsent() throws { + let json = Data(""" + { + "id": "sess-d13-2", + "project_id": "proj-001", + "branch": "main", + "image": "ubuntu:24.04", + "state": "running", + "work_dir": "/data/sessions/sess-d13-2", + "created_at": "2026-04-14T10:00:00Z", + "updated_at": "2026-04-14T10:00:00Z" + } + """.utf8) + + let session = try decoder.decode(RemoteSession.self, from: json) + + #expect(session.state == .running) + #expect(session.stoppedAt == nil) + } + + @Test("stoppedAt: encode/decode roundtrip preserves value") + func stoppedAtRoundtrip() throws { + let stopDate = Date(timeIntervalSince1970: 1_744_639_200) + let original = RemoteSession( + id: "sess-d13-3", + projectId: "proj-001", + branch: "main", + state: .stopped, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: stopDate, + stoppedAt: stopDate + ) + + let encoded = try encoder.encode(original) + let decoded = try decoder.decode(RemoteSession.self, from: encoded) + + #expect(decoded.stoppedAt != nil) + // Compare seconds to avoid sub-second ISO-8601 precision loss + #expect(decoded.stoppedAt?.timeIntervalSince1970 == stopDate.timeIntervalSince1970) + } + + @Test("stoppedAt: nil when session is running — roundtrip") + func stoppedAtNilForRunningRoundtrip() throws { + let original = RemoteSession( + id: "sess-d13-4", + projectId: "proj-001", + branch: "main", + state: .running, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 60) + ) + + let encoded = try encoder.encode(original) + let decoded = try decoder.decode(RemoteSession.self, from: encoded) + + #expect(decoded.stoppedAt == nil) + } + + // MARK: - TerminalState (D-15) + + @Test("TerminalState: all three values decode correctly") + func terminalStateAllValues() throws { + let json = Data(""" + { "states": ["starting", "running", "stopped"] } + """.utf8) + + struct Wrapper: Decodable { let states: [TerminalState] } + let result = try decoder.decode(Wrapper.self, from: json) + + #expect(result.states == [.starting, .running, .stopped]) + } + + @Test("TerminalState: unknown value falls back to .stopped without throw") + func terminalStateUnknownFallback() throws { + let json = Data(""" + { "states": ["paused", "zombie", ""] } + """.utf8) + + struct Wrapper: Decodable { let states: [TerminalState] } + let result = try decoder.decode(Wrapper.self, from: json) + + #expect(result.states.allSatisfy { $0 == .stopped }) + } + + // MARK: - RemoteTerminal (D-2 / D-15) + + @Test("RemoteTerminal: decodes all fields including typed state") + func remoteTerminalDecodes() throws { + let json = Data(""" + { + "id": "term-001", + "session_id": "sess-001", + "profile": "claude-code", + "state": "running", + "created_at": "2026-04-13T10:00:00Z", + "updated_at": "2026-04-13T10:30:00Z" + } + """.utf8) + + let terminal = try decoder.decode(RemoteTerminal.self, from: json) + + #expect(terminal.id == "term-001") + #expect(terminal.sessionId == "sess-001") + #expect(terminal.profile == "claude-code") + #expect(terminal.state == .running) + } + + @Test("RemoteTerminal: sessionId FK is preserved") + func remoteTerminalSessionIdFK() throws { + let json = Data(""" + { + "id": "term-002", + "session_id": "sess-abc", + "profile": "shell", + "state": "stopped", + "created_at": "2026-04-13T10:00:00Z", + "updated_at": "2026-04-13T11:00:00Z" + } + """.utf8) + + let terminal = try decoder.decode(RemoteTerminal.self, from: json) + + #expect(terminal.sessionId == "sess-abc") + #expect(terminal.state == .stopped) + } + + @Test("RemoteTerminal: encode/decode roundtrip preserves state") + func remoteTerminalRoundtrip() throws { + let original = RemoteTerminal( + id: "term-003", + sessionId: "sess-003", + profile: "default", + state: .starting, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 60) + ) + + let encoded = try encoder.encode(original) + let decoded = try decoder.decode(RemoteTerminal.self, from: encoded) + + #expect(decoded.id == original.id) + #expect(decoded.sessionId == original.sessionId) + #expect(decoded.state == original.state) + } +}