1191 lines
36 KiB
Go
1191 lines
36 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/control"
|
|
"reasonix/internal/event"
|
|
)
|
|
|
|
func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
|
|
t.Helper()
|
|
isolateDesktopUserDirs(t)
|
|
tabs := make(map[string]*WorkspaceTab, len(ids))
|
|
for _, id := range ids {
|
|
tabs[id] = &WorkspaceTab{
|
|
ID: id,
|
|
Scope: "global",
|
|
TopicID: "topic_" + id,
|
|
TopicTitle: id,
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
}
|
|
return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
|
|
}
|
|
|
|
func installNoopRuntimeEvents(app *App, sinks ...*tabEventSink) {
|
|
emit := func(context.Context, string, ...interface{}) {}
|
|
if app != nil {
|
|
app.runtimeEvents.emit = emit
|
|
}
|
|
for _, sink := range sinks {
|
|
if sink != nil {
|
|
sink.runtimeEvents.emit = emit
|
|
}
|
|
}
|
|
}
|
|
|
|
func tabIDs(tabs []TabMeta) []string {
|
|
ids := make([]string, 0, len(tabs))
|
|
for _, tab := range tabs {
|
|
ids = append(ids, tab.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func assertTabIDs(t *testing.T, got []TabMeta, want ...string) {
|
|
t.Helper()
|
|
gotIDs := tabIDs(got)
|
|
if len(gotIDs) != len(want) {
|
|
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
|
|
}
|
|
for i := range want {
|
|
if gotIDs[i] != want[i] {
|
|
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type resetCountingSession struct {
|
|
control.SessionAPI
|
|
resets int
|
|
}
|
|
|
|
func (s *resetCountingSession) ResetPlannerSession() {
|
|
s.resets++
|
|
}
|
|
|
|
type snapshotObservingSession struct {
|
|
control.SessionAPI
|
|
onSnapshot func()
|
|
}
|
|
|
|
func (s *snapshotObservingSession) Snapshot() error {
|
|
if s.onSnapshot != nil {
|
|
s.onSnapshot()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func expectAppMutexAvailableDuringSnapshot(t *testing.T, app *App, checks chan<- struct{}) func() {
|
|
t.Helper()
|
|
return func() {
|
|
acquired := make(chan struct{})
|
|
go func() {
|
|
app.mu.Lock()
|
|
app.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
|
|
close(acquired)
|
|
}()
|
|
select {
|
|
case <-acquired:
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Error("Snapshot ran while holding app mutex")
|
|
}
|
|
if checks == nil {
|
|
return
|
|
}
|
|
select {
|
|
case checks <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
|
|
|
|
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
|
|
if err := app.SetActiveTab("c"); err != nil {
|
|
t.Fatalf("SetActiveTab: %v", err)
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
|
|
if got := app.activeTabID; got != "c" {
|
|
t.Fatalf("active tab = %q, want c", got)
|
|
}
|
|
}
|
|
|
|
func TestSetActiveTabDoesNotResetPlannerSession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
ctrlA := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "a"})}
|
|
ctrlB := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "b"})}
|
|
defer ctrlA.Close()
|
|
defer ctrlB.Close()
|
|
app := testAppWithOrderedTabs(t, "a", "a", "b")
|
|
app.tabs["a"].Ctrl = ctrlA
|
|
app.tabs["b"].Ctrl = ctrlB
|
|
|
|
if err := app.SetActiveTab("b"); err != nil {
|
|
t.Fatalf("SetActiveTab: %v", err)
|
|
}
|
|
if ctrlA.resets != 0 || ctrlB.resets != 0 {
|
|
t.Fatalf("planner resets on tab activation = active:%d inactive:%d, want 0", ctrlA.resets, ctrlB.resets)
|
|
}
|
|
}
|
|
|
|
func TestSingleSurfaceTabsFileKeepsActiveEntry(t *testing.T) {
|
|
f := desktopTabsFile{
|
|
Tabs: []desktopTabEntry{
|
|
{ID: "a", Scope: "global", TopicID: "topic-a"},
|
|
{ID: "b", Scope: "project", WorkspaceRoot: "/tmp/project", TopicID: "topic-b"},
|
|
{ID: "c", Scope: "global", TopicID: "topic-c"},
|
|
},
|
|
ActiveTab: "b",
|
|
}
|
|
|
|
got := singleSurfaceTabsFile(f)
|
|
if len(got.Tabs) != 1 || got.Tabs[0].ID != "b" || got.ActiveTab != "b" {
|
|
t.Fatalf("single-surface tabs = %+v, want only active b", got)
|
|
}
|
|
}
|
|
|
|
func TestSetDesktopLayoutStyleAppliesPolicyAfterWorkspaceAlias(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
|
|
|
|
if err := app.SetDesktopLayoutStyle("workspace"); err != nil {
|
|
t.Fatalf("SetDesktopLayoutStyle(workspace): %v", err)
|
|
}
|
|
|
|
assertTabIDs(t, app.ListTabs(), "b")
|
|
if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "b" || got.ActiveTab != "b" {
|
|
t.Fatalf("persisted tabs after workspace alias = %+v, want only active b", got)
|
|
}
|
|
}
|
|
|
|
func TestKeepOnlyVisibleTabDetachesRunningHiddenTab(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "running.jsonl")
|
|
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
|
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
|
|
running := &WorkspaceTab{
|
|
ID: "running",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalTabWorkspaceRoot(),
|
|
SessionPath: path,
|
|
Ctrl: ctrl,
|
|
Ready: true,
|
|
sink: &tabEventSink{tabID: "running"},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
target := &WorkspaceTab{ID: "target", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{"running": running, "target": target},
|
|
tabOrder: []string{"running", "target"},
|
|
activeTabID: "running",
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
}
|
|
|
|
ctrl.Submit("block")
|
|
<-runner.started
|
|
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
|
|
t.Fatalf("keepOnlyVisibleTab: %v", err)
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "target")
|
|
if !ctrl.Running() {
|
|
t.Fatal("single-surface pruning cancelled a running controller")
|
|
}
|
|
if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; !ok {
|
|
t.Fatalf("detached runtime missing for %q", path)
|
|
}
|
|
if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "target" {
|
|
t.Fatalf("persisted tabs after pruning = %+v, want only target", got)
|
|
}
|
|
|
|
close(runner.release)
|
|
waitNotRunning(t, ctrl)
|
|
ctrl.Close()
|
|
}
|
|
|
|
func TestKeepOnlyVisibleTabSnapshotsHiddenTabWithoutAppLock(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{},
|
|
tabOrder: []string{"hidden", "target"},
|
|
activeTabID: "hidden",
|
|
}
|
|
snapshotChecks := make(chan struct{}, 2)
|
|
hiddenCtrl := &snapshotObservingSession{
|
|
SessionAPI: control.New(control.Options{Label: "hidden"}),
|
|
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
|
}
|
|
hidden := &WorkspaceTab{
|
|
ID: "hidden",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalTabWorkspaceRoot(),
|
|
TopicID: "topic-hidden",
|
|
Ctrl: hiddenCtrl,
|
|
Ready: true,
|
|
sink: &tabEventSink{tabID: "hidden"},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
target := &WorkspaceTab{
|
|
ID: "target",
|
|
Scope: "global",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app.tabs["hidden"] = hidden
|
|
app.tabs["target"] = target
|
|
|
|
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
|
|
t.Fatalf("keepOnlyVisibleTab: %v", err)
|
|
}
|
|
select {
|
|
case <-snapshotChecks:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("hidden tab was not snapshotted before pruning")
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "target")
|
|
}
|
|
|
|
func TestCloseTabSnapshotsWithoutAppLock(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{},
|
|
tabOrder: []string{"closing", "survivor"},
|
|
activeTabID: "closing",
|
|
}
|
|
snapshotChecks := make(chan struct{}, 1)
|
|
closingCtrl := &snapshotObservingSession{
|
|
SessionAPI: control.New(control.Options{Label: "closing"}),
|
|
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
|
}
|
|
closing := &WorkspaceTab{
|
|
ID: "closing",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalTabWorkspaceRoot(),
|
|
TopicID: "topic-closing",
|
|
Ctrl: closingCtrl,
|
|
Ready: true,
|
|
sink: &tabEventSink{tabID: "closing"},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
survivor := &WorkspaceTab{
|
|
ID: "survivor",
|
|
Scope: "global",
|
|
Ready: true,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app.tabs["closing"] = closing
|
|
app.tabs["survivor"] = survivor
|
|
|
|
if err := app.CloseTab("closing"); err != nil {
|
|
t.Fatalf("CloseTab: %v", err)
|
|
}
|
|
select {
|
|
case <-snapshotChecks:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("closing tab was not snapshotted")
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "survivor")
|
|
}
|
|
|
|
func TestKeepOnlyVisibleTabCancelsBuildingHiddenTab(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
cancelled := false
|
|
building := &WorkspaceTab{
|
|
ID: "building",
|
|
Scope: "global",
|
|
Ready: false,
|
|
buildCancel: func() { cancelled = true },
|
|
sink: &tabEventSink{tabID: "building"},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
target := &WorkspaceTab{ID: "target", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{"building": building, "target": target},
|
|
tabOrder: []string{"building", "target"},
|
|
activeTabID: "building",
|
|
}
|
|
|
|
if _, err := app.keepOnlyVisibleTab("target"); err != nil {
|
|
t.Fatalf("keepOnlyVisibleTab: %v", err)
|
|
}
|
|
|
|
assertTabIDs(t, app.ListTabs(), "target")
|
|
if !cancelled {
|
|
t.Fatal("building tab build was not cancelled")
|
|
}
|
|
if !building.removed {
|
|
t.Fatal("building tab was not marked removed")
|
|
}
|
|
}
|
|
|
|
func TestConcurrentActivateTopicSerializesSingleSurfacePruning(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
app := NewApp()
|
|
t.Cleanup(func() { app.shutdown(context.Background()) })
|
|
|
|
topics := []string{
|
|
"topic-a",
|
|
"topic-b",
|
|
"topic-c",
|
|
"topic-d",
|
|
"topic-e",
|
|
"topic-f",
|
|
"topic-g",
|
|
"topic-h",
|
|
}
|
|
start := make(chan struct{})
|
|
errs := make(chan error, len(topics))
|
|
var wg sync.WaitGroup
|
|
for _, topicID := range topics {
|
|
wg.Add(1)
|
|
go func(topicID string) {
|
|
defer wg.Done()
|
|
<-start
|
|
_, err := app.ActivateTopic("global", "", topicID, "")
|
|
errs <- err
|
|
}(topicID)
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
for err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("ActivateTopic returned error under concurrent navigation: %v", err)
|
|
}
|
|
}
|
|
tabs := app.ListTabs()
|
|
if len(tabs) != 1 {
|
|
t.Fatalf("ListTabs returned %d tabs after single-surface navigation, want 1: %+v", len(tabs), tabs)
|
|
}
|
|
if !tabs[0].Active {
|
|
t.Fatalf("remaining tab is not active: %+v", tabs[0])
|
|
}
|
|
}
|
|
|
|
func TestClearTabBuildCancelKeepsSuccessfulControllerContext(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
tab := &WorkspaceTab{ID: "tab", buildGeneration: 1, buildCancel: cancel}
|
|
app := &App{}
|
|
|
|
app.clearTabBuildCancel(tab, 1, cancel, true)
|
|
|
|
if tab.buildCancel != nil {
|
|
t.Fatal("build cancel was not cleared")
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("successful tab build context was cancelled")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestClearTabBuildCancelCancelsAbandonedBuildContext(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
tab := &WorkspaceTab{ID: "tab", buildGeneration: 1, buildCancel: cancel}
|
|
app := &App{}
|
|
|
|
app.clearTabBuildCancel(tab, 1, cancel, false)
|
|
|
|
if tab.buildCancel != nil {
|
|
t.Fatal("build cancel was not cleared")
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
default:
|
|
t.Fatal("abandoned tab build context was not cancelled")
|
|
}
|
|
}
|
|
|
|
func TestEnsureSessionLeaseSerializesConcurrentSameTabAcquire(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "same-tab-concurrent-lease.jsonl")
|
|
tab := &WorkspaceTab{ID: "tab"}
|
|
t.Cleanup(tab.releaseSessionLease)
|
|
|
|
acquired := make(chan struct{})
|
|
releaseHook := make(chan struct{})
|
|
var once sync.Once
|
|
sessionLeaseAcquireHookForTest = func() {
|
|
once.Do(func() {
|
|
close(acquired)
|
|
<-releaseHook
|
|
})
|
|
}
|
|
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
|
|
|
|
firstErr := make(chan error, 1)
|
|
go func() {
|
|
firstErr <- tab.ensureSessionLease(path)
|
|
}()
|
|
|
|
select {
|
|
case <-acquired:
|
|
case err := <-firstErr:
|
|
t.Fatalf("first ensureSessionLease returned before hook: %v", err)
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("first ensureSessionLease did not acquire lease")
|
|
}
|
|
|
|
secondErr := make(chan error, 1)
|
|
go func() {
|
|
secondErr <- tab.ensureSessionLease(path)
|
|
}()
|
|
|
|
select {
|
|
case err := <-secondErr:
|
|
t.Fatalf("second ensureSessionLease returned while first acquire was unbound: %v", err)
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(releaseHook)
|
|
if err := <-firstErr; err != nil {
|
|
t.Fatalf("first ensureSessionLease: %v", err)
|
|
}
|
|
if err := <-secondErr; err != nil {
|
|
t.Fatalf("second ensureSessionLease should reuse the tab lease: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAttachExistingSessionRuntimeSkipsRemovedTab(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "detached.jsonl")
|
|
key := sessionRuntimeKey(path)
|
|
detachedCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "detached", Sink: event.Discard})
|
|
defer detachedCtrl.Close()
|
|
detached := &WorkspaceTab{
|
|
ID: "detached",
|
|
Scope: "global",
|
|
SessionPath: path,
|
|
Ctrl: detachedCtrl,
|
|
Ready: true,
|
|
SharedHostKey: "detached-host",
|
|
sink: &tabEventSink{tabID: "detached"},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
target := &WorkspaceTab{
|
|
ID: "target",
|
|
Scope: "global",
|
|
SessionPath: path,
|
|
removed: true,
|
|
sink: &tabEventSink{tabID: "target"},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{},
|
|
detachedSessions: map[string]*WorkspaceTab{key: detached},
|
|
}
|
|
|
|
if app.attachExistingSessionRuntime(target, path, nil) {
|
|
t.Fatal("removed tab reattached a detached runtime")
|
|
}
|
|
if app.detachedSessions[key] != detached {
|
|
t.Fatal("detached runtime was removed")
|
|
}
|
|
if target.Ctrl != nil || target.Ready {
|
|
t.Fatal("removed target tab was mutated")
|
|
}
|
|
}
|
|
|
|
func TestRemoveWorkspaceDropsVisibleTabsAndPersistedEntries(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, "Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
|
|
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
|
|
},
|
|
tabOrder: []string{"project", "global"},
|
|
activeTabID: "project",
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
}
|
|
app.mu.Lock()
|
|
app.saveTabsLocked()
|
|
app.mu.Unlock()
|
|
|
|
if err := app.RemoveWorkspace(projectRoot); err != nil {
|
|
t.Fatalf("RemoveWorkspace: %v", err)
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "global")
|
|
if got := app.ListWorkspaces(); len(got) != 0 {
|
|
t.Fatalf("workspaces after remove = %+v, want none", got)
|
|
}
|
|
if got := loadTabsFile(); len(got.Tabs) != 1 || got.Tabs[0].ID != "global" {
|
|
t.Fatalf("persisted tabs after workspace remove = %+v, want only global", got)
|
|
}
|
|
}
|
|
|
|
func TestRemoveWorkspaceSnapshotsProjectTabBeforeRemovingBinding(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, "Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
|
|
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
|
|
},
|
|
tabOrder: []string{"project", "global"},
|
|
activeTabID: "project",
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
}
|
|
sawBindingDuringSnapshot := false
|
|
app.tabs["project"].Ctrl = &snapshotObservingSession{
|
|
SessionAPI: control.New(control.Options{Label: "project"}),
|
|
onSnapshot: func() {
|
|
sawBindingDuringSnapshot = app.tabs["project"] != nil
|
|
},
|
|
}
|
|
|
|
if err := app.RemoveWorkspace(projectRoot); err != nil {
|
|
t.Fatalf("RemoveWorkspace: %v", err)
|
|
}
|
|
if !sawBindingDuringSnapshot {
|
|
t.Fatal("project tab was removed from app.tabs before Snapshot")
|
|
}
|
|
}
|
|
|
|
func TestRemoveWorkspaceSnapshotsProjectTabWithoutAppLock(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, "Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "topic-project", Ready: true, disabledMCP: map[string]ServerView{}},
|
|
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), TopicID: "topic-global", Ready: true, disabledMCP: map[string]ServerView{}},
|
|
},
|
|
tabOrder: []string{"project", "global"},
|
|
activeTabID: "project",
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
}
|
|
snapshotChecks := make(chan struct{}, 1)
|
|
app.tabs["project"].Ctrl = &snapshotObservingSession{
|
|
SessionAPI: control.New(control.Options{Label: "project"}),
|
|
onSnapshot: expectAppMutexAvailableDuringSnapshot(t, app, snapshotChecks),
|
|
}
|
|
|
|
if err := app.RemoveWorkspace(projectRoot); err != nil {
|
|
t.Fatalf("RemoveWorkspace: %v", err)
|
|
}
|
|
select {
|
|
case <-snapshotChecks:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("project tab was not snapshotted before removing workspace")
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "global")
|
|
}
|
|
|
|
func TestRemoveWorkspaceRejectsRunningProjectRuntime(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
projectRoot := t.TempDir()
|
|
if err := addProject(projectRoot, "Project"); err != nil {
|
|
t.Fatalf("add project: %v", err)
|
|
}
|
|
dir := desktopSessionDir(projectRoot)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir project sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "running.jsonl")
|
|
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
|
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
|
|
project := &WorkspaceTab{
|
|
ID: "project",
|
|
Scope: "project",
|
|
WorkspaceRoot: projectRoot,
|
|
TopicID: "topic-project",
|
|
SessionPath: path,
|
|
Ctrl: ctrl,
|
|
Ready: true,
|
|
sink: &tabEventSink{tabID: "project"},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"project": project,
|
|
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalTabWorkspaceRoot(), Ready: true, disabledMCP: map[string]ServerView{}},
|
|
},
|
|
tabOrder: []string{"project", "global"},
|
|
activeTabID: "project",
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
}
|
|
|
|
ctrl.Submit("block")
|
|
<-runner.started
|
|
if err := app.RemoveWorkspace(projectRoot); err == nil {
|
|
t.Fatal("RemoveWorkspace succeeded with a running project session")
|
|
}
|
|
if got := app.ListWorkspaces(); len(got) != 1 || got[0].Path != normalizeProjectRoot(projectRoot) {
|
|
t.Fatalf("workspaces after rejected remove = %+v, want project retained", got)
|
|
}
|
|
|
|
close(runner.release)
|
|
waitNotRunning(t, ctrl)
|
|
ctrl.Close()
|
|
}
|
|
|
|
func TestListTabsRepairsStaleOrderWithoutRacing(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
|
app.tabOrder = []string{"a"}
|
|
|
|
var wg sync.WaitGroup
|
|
start := make(chan struct{})
|
|
errs := make(chan string, 8)
|
|
iterations := 100
|
|
if testing.Short() {
|
|
iterations = 5
|
|
}
|
|
for i := 0; i < 8; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
<-start
|
|
for j := 0; j < iterations; j++ {
|
|
if got := strings.Join(tabIDs(app.ListTabs()), ","); got != "a,b,c" {
|
|
errs <- got
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
close(errs)
|
|
for got := range errs {
|
|
t.Fatalf("tab ids = %q, want a,b,c", got)
|
|
}
|
|
|
|
if got := strings.Join(app.tabOrder, ","); got != "a,b,c" {
|
|
t.Fatalf("repaired tab order = %q, want a,b,c", got)
|
|
}
|
|
}
|
|
|
|
func TestSaveTabsSkipsOlderSnapshot(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "a", "a", "b")
|
|
|
|
app.mu.Lock()
|
|
dir, oldEntries, oldActiveID, oldVersion := app.saveTabsCollectLocked()
|
|
app.activeTabID = "b"
|
|
_, newEntries, newActiveID, newVersion := app.saveTabsCollectLocked()
|
|
app.mu.Unlock()
|
|
|
|
app.saveTabsWrite(dir, newEntries, newActiveID, newVersion)
|
|
app.saveTabsWrite(dir, oldEntries, oldActiveID, oldVersion)
|
|
|
|
if got := loadTabsFile().ActiveTab; got != "b" {
|
|
t.Fatalf("persisted active tab = %q, want b", got)
|
|
}
|
|
}
|
|
|
|
func TestReorderTabsPersistsSubmittedOrder(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
|
|
|
if err := app.ReorderTabs([]string{"c", "a", "b"}); err != nil {
|
|
t.Fatalf("ReorderTabs: %v", err)
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "c", "a", "b")
|
|
if got := app.activeTabID; got != "a" {
|
|
t.Fatalf("active tab = %q, want a", got)
|
|
}
|
|
}
|
|
|
|
func TestCloseActiveTabChoosesNeighborByOrder(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
|
|
if err := app.CloseTab("b"); err != nil {
|
|
t.Fatalf("CloseTab(b): %v", err)
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "a", "c")
|
|
if got := app.activeTabID; got != "c" {
|
|
t.Fatalf("active tab after closing middle = %q, want c", got)
|
|
}
|
|
|
|
if err := app.CloseTab("c"); err != nil {
|
|
t.Fatalf("CloseTab(c): %v", err)
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "a")
|
|
if got := app.activeTabID; got != "a" {
|
|
t.Fatalf("active tab after closing last = %q, want a", got)
|
|
}
|
|
}
|
|
|
|
func TestCloseActiveTabDoesNotResetSurvivorPlannerSession(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
ctrlClosed := control.New(control.Options{Label: "closed"})
|
|
ctrlSurvivor := &resetCountingSession{SessionAPI: control.New(control.Options{Label: "survivor"})}
|
|
defer ctrlSurvivor.Close()
|
|
app := testAppWithOrderedTabs(t, "a", "a", "b")
|
|
app.tabs["a"].Ctrl = ctrlClosed
|
|
app.tabs["b"].Ctrl = ctrlSurvivor
|
|
|
|
if err := app.CloseTab("a"); err != nil {
|
|
t.Fatalf("CloseTab: %v", err)
|
|
}
|
|
if ctrlSurvivor.resets != 0 {
|
|
t.Fatalf("survivor planner resets = %d, want 0", ctrlSurvivor.resets)
|
|
}
|
|
}
|
|
|
|
func TestCloseRunningTabDetachesSessionRuntime(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := config.SessionDir()
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "running.jsonl")
|
|
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
|
ctrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: path, Label: "running", Sink: event.Discard})
|
|
tab := &WorkspaceTab{
|
|
ID: "running",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalTabWorkspaceRoot(),
|
|
SessionPath: path,
|
|
Ctrl: ctrl,
|
|
Ready: true,
|
|
sink: &tabEventSink{tabID: "running"},
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app := &App{
|
|
tabs: map[string]*WorkspaceTab{
|
|
"running": tab,
|
|
"other": {ID: "other", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}},
|
|
},
|
|
tabOrder: []string{"running", "other"},
|
|
activeTabID: "running",
|
|
detachedSessions: map[string]*WorkspaceTab{},
|
|
}
|
|
|
|
ctrl.Submit("block")
|
|
<-runner.started
|
|
if err := app.CloseTab("running"); err != nil {
|
|
t.Fatalf("CloseTab(running): %v", err)
|
|
}
|
|
if !ctrl.Running() {
|
|
t.Fatal("closing a visible tab cancelled its running controller")
|
|
}
|
|
if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; !ok {
|
|
t.Fatalf("detached runtime missing for %q", path)
|
|
}
|
|
if tab.sink.ctx != nil {
|
|
t.Fatal("detached tab sink should stop emitting to the closed view")
|
|
}
|
|
|
|
close(runner.release)
|
|
waitNotRunning(t, ctrl)
|
|
ctrl.Close()
|
|
}
|
|
|
|
func TestBuildTabControllerReattachesDetachedSessionRuntime(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "reattach.jsonl")
|
|
oldSink := &tabEventSink{tabID: "old"}
|
|
oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "detached", Sink: oldSink})
|
|
defer oldCtrl.Close()
|
|
app := NewApp()
|
|
app.detachedSessions[sessionRuntimeKey(path)] = &WorkspaceTab{
|
|
ID: "old",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalTabWorkspaceRoot(),
|
|
SessionPath: path,
|
|
Ctrl: oldCtrl,
|
|
Label: "detached",
|
|
Ready: true,
|
|
sink: oldSink,
|
|
model: "detached-model",
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "new")
|
|
tab.SessionPath = path
|
|
tab.sink = &tabEventSink{tabID: "new", app: app}
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl != oldCtrl {
|
|
t.Fatalf("reattached controller = %p, want detached %p", tab.Ctrl, oldCtrl)
|
|
}
|
|
if tab.sink != oldSink {
|
|
t.Fatalf("reattached sink = %p, want detached %p", tab.sink, oldSink)
|
|
}
|
|
if oldSink.tabID != "new" {
|
|
t.Fatalf("sink tab id = %q, want new", oldSink.tabID)
|
|
}
|
|
if _, ok := app.detachedSessions[sessionRuntimeKey(path)]; ok {
|
|
t.Fatal("detached runtime was not removed after reattach")
|
|
}
|
|
}
|
|
|
|
func TestBuildTabControllerReusesOpenSessionPathRuntime(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "open-runtime.jsonl")
|
|
oldSink := &tabEventSink{tabID: "old"}
|
|
oldCtrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "open", Sink: oldSink})
|
|
defer oldCtrl.Close()
|
|
app := NewApp()
|
|
oldTab := &WorkspaceTab{
|
|
ID: "old",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalTabWorkspaceRoot(),
|
|
SessionPath: path,
|
|
Ctrl: oldCtrl,
|
|
Label: "open",
|
|
Ready: true,
|
|
sink: oldSink,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "new")
|
|
tab.SessionPath = path
|
|
tab.sink = &tabEventSink{tabID: "new", app: app}
|
|
app.tabs[oldTab.ID] = oldTab
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{oldTab.ID, tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl != oldCtrl {
|
|
t.Fatalf("reused controller = %p, want open %p", tab.Ctrl, oldCtrl)
|
|
}
|
|
if oldSink.tabID != "new" {
|
|
t.Fatalf("sink tab id = %q, want new", oldSink.tabID)
|
|
}
|
|
if _, ok := app.tabs[oldTab.ID]; ok {
|
|
t.Fatal("source tab for reused runtime should be removed")
|
|
}
|
|
}
|
|
|
|
func TestBuildTabControllerBlocksWhenSessionLeaseHeld(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "leased.jsonl")
|
|
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
|
t.Fatalf("write placeholder session: %v", err)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(path)
|
|
if err != nil {
|
|
t.Fatalf("TryAcquireSessionLease: %v", err)
|
|
}
|
|
defer lease.Release()
|
|
|
|
app := NewApp()
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "leased")
|
|
tab.SessionPath = path
|
|
tab.sink = &tabEventSink{tabID: "leased", app: app}
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl != nil {
|
|
t.Fatalf("tab controller = %T, want nil when lease is held", tab.Ctrl)
|
|
}
|
|
if !tab.Ready {
|
|
t.Fatal("tab should be ready with startup error")
|
|
}
|
|
// The surfaced startup error is the sanitized busy message: the raw lease
|
|
// error would leak the session path and the holder's host-pid-writer id
|
|
// into the topbar banner.
|
|
if !strings.Contains(tab.StartupErr, "already open in another Reasonix window") {
|
|
t.Fatalf("startup error = %q, want user-facing busy message", tab.StartupErr)
|
|
}
|
|
if strings.Contains(tab.StartupErr, agent.ErrSessionLeaseHeld.Error()) ||
|
|
strings.Contains(tab.StartupErr, path) {
|
|
t.Fatalf("startup error leaked raw lease details: %q", tab.StartupErr)
|
|
}
|
|
}
|
|
|
|
func TestDeferredStartupRetryBuildsAfterLeaseRelease(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
prevInterval := deferredRebuildRetryInterval
|
|
deferredRebuildRetryInterval = 20 * time.Millisecond
|
|
t.Cleanup(func() { deferredRebuildRetryInterval = prevInterval })
|
|
|
|
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "startup-retry.jsonl")
|
|
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
|
t.Fatalf("write placeholder session: %v", err)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(path)
|
|
if err != nil {
|
|
t.Fatalf("TryAcquireSessionLease: %v", err)
|
|
}
|
|
released := false
|
|
t.Cleanup(func() {
|
|
if !released {
|
|
lease.Release()
|
|
}
|
|
})
|
|
|
|
app := NewApp()
|
|
app.ctx = context.Background()
|
|
app.readyHook = func() {}
|
|
app.enableDeferredRebuildRetry()
|
|
t.Cleanup(app.stopDeferredRebuildRetry)
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_retry")
|
|
tab.SessionPath = path
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
installNoopRuntimeEvents(app, tab.sink)
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
t.Cleanup(func() {
|
|
if ctrl := app.controllerForTab(tab); ctrl != nil {
|
|
ctrl.Close()
|
|
}
|
|
tab.releaseSessionLease()
|
|
})
|
|
|
|
app.buildTabController(tab)
|
|
if tab.Ctrl != nil {
|
|
t.Fatalf("controller = %T, want nil while external lease is held", tab.Ctrl)
|
|
}
|
|
if !tab.StartupErrLeaseHeld {
|
|
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
|
|
}
|
|
if !app.deferredRebuildPending(tab.ID) {
|
|
t.Fatal("startup retry was not scheduled while the lease was held")
|
|
}
|
|
|
|
lease.Release()
|
|
released = true
|
|
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if !app.deferredRebuildPending(tab.ID) && app.controllerForTab(tab) != nil {
|
|
break
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
if app.deferredRebuildPending(tab.ID) {
|
|
t.Fatal("startup retry is still pending after the lease was released")
|
|
}
|
|
if ctrl := app.controllerForTab(tab); ctrl == nil {
|
|
t.Fatal("controller was not rebuilt after the lease was released")
|
|
}
|
|
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
|
|
t.Fatalf("startup error after retry = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
|
|
}
|
|
}
|
|
|
|
func TestTabAndCtrlByIDRecoversStartupLeaseBeforeAction(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
path := filepath.Join(dir, "startup-before-action.jsonl")
|
|
if err := os.WriteFile(path, nil, 0o644); err != nil {
|
|
t.Fatalf("write placeholder session: %v", err)
|
|
}
|
|
lease, err := agent.TryAcquireSessionLease(path)
|
|
if err != nil {
|
|
t.Fatalf("TryAcquireSessionLease: %v", err)
|
|
}
|
|
released := false
|
|
t.Cleanup(func() {
|
|
if !released {
|
|
lease.Release()
|
|
}
|
|
})
|
|
|
|
app := NewApp()
|
|
app.ctx = context.Background()
|
|
app.readyHook = func() {}
|
|
tab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "startup_action")
|
|
tab.SessionPath = path
|
|
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
|
installNoopRuntimeEvents(app, tab.sink)
|
|
app.tabs[tab.ID] = tab
|
|
app.tabOrder = []string{tab.ID}
|
|
app.activeTabID = tab.ID
|
|
t.Cleanup(func() {
|
|
if ctrl := app.controllerForTab(tab); ctrl != nil {
|
|
ctrl.Close()
|
|
}
|
|
tab.releaseSessionLease()
|
|
})
|
|
|
|
app.buildTabController(tab)
|
|
if !tab.StartupErrLeaseHeld {
|
|
t.Fatalf("startup retry flag = false, startup err = %q", tab.StartupErr)
|
|
}
|
|
lease.Release()
|
|
released = true
|
|
|
|
gotTab, ctrl := app.tabAndCtrlByID(tab.ID)
|
|
if gotTab != tab {
|
|
t.Fatalf("tabAndCtrlByID tab = %p, want %p", gotTab, tab)
|
|
}
|
|
if ctrl == nil {
|
|
t.Fatal("tabAndCtrlByID did not rebuild the controller before returning")
|
|
}
|
|
if app.deferredRebuildPending(tab.ID) {
|
|
t.Fatal("startup retry remained pending after synchronous recovery")
|
|
}
|
|
if tab.StartupErr != "" || tab.StartupErrLeaseHeld {
|
|
t.Fatalf("startup error after synchronous recovery = %q retryable=%v, want cleared", tab.StartupErr, tab.StartupErrLeaseHeld)
|
|
}
|
|
}
|
|
|
|
func TestOpenGlobalTabResolvesTopicToLatestSessionRuntime(t *testing.T) {
|
|
isolateDesktopUserDirs(t)
|
|
dir := desktopSessionDir(globalTabWorkspaceRoot())
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatalf("mkdir sessions: %v", err)
|
|
}
|
|
topicID := "topic_multi_session"
|
|
topicTitle := "Multi session topic"
|
|
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", topicID, topicTitle, "", "old session prompt", time.Now().Add(-2*time.Hour))
|
|
newPath := writeTopicSessionWithPrompt(t, dir, "new.jsonl", topicID, topicTitle, "", "new session prompt", time.Now().Add(-time.Hour))
|
|
|
|
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
|
oldSink := &tabEventSink{tabID: "topic-tab"}
|
|
oldCtrl := control.New(control.Options{Runner: runner, SessionDir: dir, SessionPath: oldPath, Label: "old", Sink: oldSink})
|
|
defer oldCtrl.Close()
|
|
app := NewApp()
|
|
oldSink.app = app
|
|
oldTab := &WorkspaceTab{
|
|
ID: "topic-tab",
|
|
Scope: "global",
|
|
WorkspaceRoot: globalTabWorkspaceRoot(),
|
|
TopicID: topicID,
|
|
TopicTitle: topicTitle,
|
|
SessionPath: oldPath,
|
|
Ctrl: oldCtrl,
|
|
Ready: true,
|
|
sink: oldSink,
|
|
disabledMCP: map[string]ServerView{},
|
|
}
|
|
app.tabs[oldTab.ID] = oldTab
|
|
app.tabOrder = []string{oldTab.ID}
|
|
app.activeTabID = oldTab.ID
|
|
|
|
oldCtrl.Submit("keep old runtime running")
|
|
<-runner.started
|
|
|
|
meta, err := app.OpenGlobalTab(topicID)
|
|
if err != nil {
|
|
t.Fatalf("OpenGlobalTab: %v", err)
|
|
}
|
|
if meta.ID != oldTab.ID {
|
|
t.Fatalf("OpenGlobalTab reused tab %q, want %q", meta.ID, oldTab.ID)
|
|
}
|
|
if !oldCtrl.Running() {
|
|
t.Fatal("old session runtime was cancelled while selecting topic")
|
|
}
|
|
if detached := app.detachedSessions[sessionRuntimeKey(oldPath)]; detached == nil || detached.Ctrl != oldCtrl {
|
|
t.Fatalf("old runtime was not detached under its session path: %+v", detached)
|
|
}
|
|
visible := app.tabs[oldTab.ID]
|
|
if visible == nil || visible.Ctrl == nil {
|
|
t.Fatalf("visible tab was not rebuilt: %+v", visible)
|
|
}
|
|
if got := filepath.Clean(visible.Ctrl.SessionPath()); got != filepath.Clean(newPath) {
|
|
t.Fatalf("visible session path = %q, want %q", got, newPath)
|
|
}
|
|
history := visible.Ctrl.History()
|
|
if len(history) == 0 || history[0].Content != "new session prompt" {
|
|
t.Fatalf("visible history = %+v, want latest session prompt", history)
|
|
}
|
|
|
|
close(runner.release)
|
|
waitNotRunning(t, oldCtrl)
|
|
}
|
|
|
|
func TestReorderTabsRejectsInvalidOrder(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
|
for name, order := range map[string][]string{
|
|
"missing": {"a", "b"},
|
|
"unknown": {"a", "b", "missing"},
|
|
"duplicate": {"a", "b", "b"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if err := app.ReorderTabs(order); err == nil {
|
|
t.Fatalf("ReorderTabs(%v) succeeded, want error", order)
|
|
}
|
|
})
|
|
}
|
|
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
|
|
}
|
|
|
|
func TestNewUniqueTabIDLockedUsesFreshRandomID(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
|
|
|
app.mu.Lock()
|
|
got := app.newUniqueTabIDLocked()
|
|
app.mu.Unlock()
|
|
if _, exists := app.tabs[got]; exists {
|
|
t.Fatalf("newUniqueTabIDLocked returned existing id %q", got)
|
|
}
|
|
if !strings.HasPrefix(got, "tab_") {
|
|
t.Fatalf("tab id = %q, want tab_ prefix", got)
|
|
}
|
|
if len(got) != len("tab_")+32 {
|
|
t.Fatalf("tab id = %q, length %d, want 36", got, len(got))
|
|
}
|
|
}
|
|
|
|
func TestRestoredTabIDLockedReplacesEmptyAndDuplicateIDs(t *testing.T) {
|
|
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
|
|
|
app.mu.Lock()
|
|
kept := app.restoredTabIDLocked("d")
|
|
duplicate := app.restoredTabIDLocked("a")
|
|
empty := app.restoredTabIDLocked(" ")
|
|
app.mu.Unlock()
|
|
|
|
if kept != "d" {
|
|
t.Fatalf("restored unique id = %q, want d", kept)
|
|
}
|
|
for name, got := range map[string]string{"duplicate": duplicate, "empty": empty} {
|
|
if _, exists := app.tabs[got]; exists {
|
|
t.Fatalf("%s restored id %q already exists", name, got)
|
|
}
|
|
if !strings.HasPrefix(got, "tab_") {
|
|
t.Fatalf("%s restored id = %q, want tab_ prefix", name, got)
|
|
}
|
|
}
|
|
}
|