| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "reflect" |
| 6 | "sync" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/control" |
| 11 | ) |
| 12 | |
| 13 | // A real controller delivers TurnDone while its admission gate is still held. |
| 14 | // The project tree must subsequently receive the settled state, without the |
| 15 | // user opening another tab or causing a catalog reload. |
| 16 | func TestRuntimeStateCompletedTurnPublishesIdleProjectTree(t *testing.T) { |
| 17 | isolateDesktopUserDirs(t) |
| 18 | app := NewApp() |
| 19 | ctx, cancel := context.WithCancel(t.Context()) |
| 20 | defer cancel() |
| 21 | app.ctx = ctx |
| 22 | snapshots := make(chan ProjectTreeRuntimeSnapshot, 64) |
| 23 | app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) { |
| 24 | if name == "project-tree:runtime-changed" { |
| 25 | snapshots <- payload[0].(ProjectTreeRuntimeSnapshot) |
| 26 | } |
| 27 | } |
| 28 | sink := &tabEventSink{tabID: "runtime-regression", app: app} |
| 29 | runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})} |
| 30 | releaseRunner := sync.OnceFunc(func() { close(runner.release) }) |
| 31 | ctrl := control.New(control.Options{ |
| 32 | Runner: runner, SessionDir: t.TempDir(), Label: "runtime regression", Sink: sink, |
| 33 | }) |
| 34 | defer ctrl.Close() |
| 35 | defer releaseRunner() |
| 36 | tab := &WorkspaceTab{ |
| 37 | ID: sink.tabID, Scope: "global", TopicID: "runtime-regression-topic", |
| 38 | Ctrl: ctrl, Ready: true, sink: sink, disabledMCP: map[string]ServerView{}, |
| 39 | } |
| 40 | // Hold the independent metadata-save lane busy: runtime completion must |
| 41 | // not depend on an autosave/catalog refresh happening to repair its state. |
| 42 | // scheduleTabSnapshot will only mark saveAgain while this lane is occupied. |
| 43 | tab.saving = true |
| 44 | app.tabs[tab.ID] = tab |
| 45 | app.tabOrder = []string{tab.ID} |
| 46 | app.activeTabID = tab.ID |
| 47 | ctrl.Submit("complete the isolated test turn") |
| 48 | select { |
| 49 | case <-runner.started: |
| 50 | case <-time.After(5 * time.Second): |
| 51 | t.Fatal("isolated runner did not start") |
| 52 | } |
| 53 | // Observe execution before allowing completion, so a publisher that |
| 54 | // intentionally coalesces intermediate snapshots cannot hide the start. |
| 55 | startDeadline := time.NewTimer(5 * time.Second) |
| 56 | defer startDeadline.Stop() |
| 57 | started: |
| 58 | for { |
| 59 | select { |
| 60 | case snapshot := <-snapshots: |
| 61 | if len(snapshot.Topics) == 1 && snapshot.Topics[0].Node.Running { |
| 62 | break started |
| 63 | } |
| 64 | case <-startDeadline.C: |
| 65 | t.Fatal("running controller never published its activity") |
| 66 | } |
| 67 | } |
| 68 | releaseRunner() |
| 69 | waitNotRunning(t, ctrl) |
| 70 | |
| 71 | deadline := time.NewTimer(5 * time.Second) |
| 72 | defer deadline.Stop() |
| 73 | var last ProjectTreeRuntimeSnapshot |
| 74 | for { |
| 75 | select { |
| 76 | case last = <-snapshots: |
| 77 | if len(last.Topics) != 1 { |
| 78 | continue |
| 79 | } |
| 80 | node := last.Topics[0].Node |
| 81 | if node.Running || node.Status != "" { |
| 82 | continue |
| 83 | } |
| 84 | fresh := app.GetProjectTreeRuntimeSnapshot() |
| 85 | if last.Revision == fresh.Revision && !reflect.DeepEqual(last, fresh) { |
| 86 | t.Fatalf("one revision identifies different runtime snapshots: published=%+v read=%+v", last, fresh) |
| 87 | } |
| 88 | status := ctrl.RuntimeStatus() |
| 89 | if status.Running || status.PendingPrompt || status.BackgroundJobs != 0 { |
| 90 | t.Fatalf("idle publication disagrees with controller: %+v", status) |
| 91 | } |
| 92 | return |
| 93 | case <-deadline.C: |
| 94 | t.Fatalf("completed controller never published idle: controller=%+v last=%+v fresh=%+v", ctrl.RuntimeStatus(), last, app.GetProjectTreeRuntimeSnapshot()) |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 |