Implement full E2E, integration, component, and unit tests, and resolve all leftover tmux-to-herdr issues in UI and core scripts

This commit is contained in:
2026-07-19 20:05:48 +09:00
parent 30e606b0fa
commit ff7a2873f9
29 changed files with 3099 additions and 95 deletions
@@ -1,6 +1,6 @@
import 'package:meta/meta.dart';
/// The tmux pane backing a session row, as reported by `sessions_detail`.
/// The herdr pane backing a session row, as reported by `sessions_detail`.
@immutable
class Pane {
final int? pid;
@@ -13,7 +13,7 @@ class SessionRow {
final String name;
final String server;
final String status;
final bool tmuxAlive;
final bool herdrAlive;
final Pane pane;
final String? role;
final String resumeState;
@@ -28,7 +28,7 @@ class SessionRow {
required this.name,
required this.server,
required this.status,
required this.tmuxAlive,
required this.herdrAlive,
required this.pane,
this.role,
required this.resumeState,
@@ -49,7 +49,7 @@ class SessionRow {
name: json['name'] as String? ?? '?',
server: json['server'] as String? ?? 'default',
status: json['status'] as String? ?? '?',
tmuxAlive: json['tmux_alive'] as bool? ?? false,
herdrAlive: json['herdr_alive'] as bool? ?? false,
pane: Pane.fromSessionJson(json),
role: json['role'] as String?,
resumeState: json['resume_state'] as String? ?? '?',
@@ -64,3 +64,4 @@ class SessionRow {
);
}
}
@@ -4,14 +4,14 @@ import 'drift_entry.dart';
import 'session_row.dart';
/// The full parsed result of one `status.sh --json` call: the untouched
/// pre-D8 keys (timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/
/// pre-D8 keys (timestamp/yaml_path/herdr_sessions_alive/herdr_confirmed/
/// drifts/actions) plus the additive `sessions_detail` -> [SessionRow].
@immutable
class SessionsSnapshot {
final DateTime timestamp;
final String yamlPath;
final List<String> tmuxSessionsAlive;
final bool tmuxConfirmed;
final List<String> herdrSessionsAlive;
final bool herdrConfirmed;
final List<DriftEntry> drifts;
final List<String> actions;
final List<SessionRow> sessions;
@@ -19,8 +19,8 @@ class SessionsSnapshot {
const SessionsSnapshot({
required this.timestamp,
required this.yamlPath,
required this.tmuxSessionsAlive,
required this.tmuxConfirmed,
required this.herdrSessionsAlive,
required this.herdrConfirmed,
required this.drifts,
required this.actions,
required this.sessions,
@@ -32,11 +32,11 @@ class SessionsSnapshot {
DateTime.tryParse(json['timestamp'] as String? ?? '')?.toUtc() ??
DateTime.now().toUtc(),
yamlPath: json['yaml_path'] as String? ?? '',
tmuxSessionsAlive:
(json['tmux_sessions_alive'] as List<dynamic>? ?? const [])
herdrSessionsAlive:
(json['herdr_sessions_alive'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(growable: false),
tmuxConfirmed: json['tmux_confirmed'] as bool? ?? false,
herdrConfirmed: json['herdr_confirmed'] as bool? ?? false,
drifts: (json['drifts'] as List<dynamic>? ?? const [])
.map((e) => DriftEntry.fromJson(e as Map<String, dynamic>))
.toList(growable: false),
@@ -49,3 +49,4 @@ class SessionsSnapshot {
);
}
}
@@ -37,9 +37,9 @@ class SessionService {
throw StatusFetchException('preflight failed: bash is missing or not executable');
}
final tmuxResult = await runCommand(['tmux', '-V'], timeout: timeout);
if (tmuxResult.rc != 0) {
throw StatusFetchException('preflight failed: tmux is missing or not executable');
final herdrResult = await runCommand(['herdr', '-V'], timeout: timeout);
if (herdrResult.rc != 0) {
throw StatusFetchException('preflight failed: herdr is missing or not executable');
}
_preflightChecked = true;
@@ -24,18 +24,18 @@ void main() {
{
"timestamp": "2026-07-16T11:56:54Z",
"yaml_path": "/x/.mam/agent-sessions.yaml",
"tmux_sessions_alive": ["a|default"],
"tmux_confirmed": true,
"herdr_sessions_alive": ["a|default"],
"herdr_confirmed": true,
"drifts": [{"class": "B", "name": "a", "msg": "registered: a"}],
"actions": ["registered: a"],
"sessions_detail": [
{
"name": "a", "server": "default", "status": "running",
"tmux_alive": true, "cmd": "claude", "role": "creator",
"herdr_alive": true, "cmd": "claude", "role": "creator",
"resume_state": "yes", "job_id": "-", "job_status": "-",
"pane_cwd": "/x", "attach_command": "tmux attach -t a",
"pane_cwd": "/x", "attach_command": "herdr session attach a",
"drift_classes": ["B"], "pane_pid": 123, "cmd_full": "claude --foo",
"start_command": "tmux new-session ...", "last_visible_status": "running"
"start_command": "herdr agent start ...", "last_visible_status": "running"
}
]
}
@@ -43,7 +43,7 @@ void main() {
final snapshot = SessionsSnapshot.fromJson(json);
expect(snapshot.tmuxConfirmed, isTrue);
expect(snapshot.herdrConfirmed, isTrue);
expect(snapshot.drifts, hasLength(1));
expect(snapshot.drifts.single.driftClass, 'B');
expect(snapshot.sessions, hasLength(1));
@@ -56,7 +56,7 @@ void main() {
expect(row.isTerminal, isFalse);
expect(row.pane.pid, 123);
expect(row.pane.cmdFull, 'claude --foo');
expect(row.attachCommand, 'tmux attach -t a');
expect(row.attachCommand, 'herdr session attach a');
});
test('SessionsSnapshot.fromJson tolerates missing optional fields', () {
@@ -64,7 +64,7 @@ void main() {
as Map<String, dynamic>;
final snapshot = SessionsSnapshot.fromJson(json);
expect(snapshot.tmuxConfirmed, isFalse);
expect(snapshot.herdrConfirmed, isFalse);
expect(snapshot.sessions.single.name, 'bare');
expect(snapshot.sessions.single.resumeState, '?');
expect(snapshot.sessions.single.pane.pid, isNull);
@@ -85,8 +85,8 @@ void main() {
final snapshot = await service.fetchSnapshot();
expect(snapshot.yamlPath, isNotEmpty);
// sessions_detail row count must match tmux_sessions_alive's distinct names.
final aliveNames = snapshot.tmuxSessionsAlive
// sessions_detail row count must match herdr_sessions_alive's distinct names.
final aliveNames = snapshot.herdrSessionsAlive
.map((entry) => entry.split('|').first)
.toSet();
final detailNames = snapshot.sessions.map((s) => s.name).toSet();
@@ -95,3 +95,4 @@ void main() {
timeout: const Timeout(Duration(seconds: 15)),
);
}
@@ -182,7 +182,9 @@ class PtySession {
// A. Disassociate controlling terminal
setsid();
// B. Isolate from nested TMUX environments (§6.7)
// B. Isolate from nested TMUX/Herdr environments (§6.7)
// We must unset TMUX and TMUX_PANE to ensure the child shell is properly isolated
// from any parent terminal multiplexer session, preventing unexpected nested behavior.
unsetenv(tmuxNamePtr.cast<ffi.Char>());
unsetenv(tmuxPaneNamePtr.cast<ffi.Char>());