1package core
2
3import (
4 "context"
5 "errors"
6 "sync/atomic"
7 "testing"
8 "time"
9
10 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
11 "github.com/bnema/neferwl/internal/ports"
12)
13
14func reviewReceive[T any](t *testing.T, ch <-chan T) T {
15 t.Helper()
16 select {
17 case v := <-ch:
18 return v
19 case <-time.After(time.Second):
20 t.Fatal("timed out")
21 var z T
22 return z
23 }
24}
25
26func TestInputOutputSwitchBackpressureKeepsAdmittedEpoch(t *testing.T) {
27 c, _, _ := hiddenCaptureCommands(t)
28 c.addScreen(ports.OutputInfo{Name: "B", Width: 200, Height: 100})
29 var state atomic.Value
30 state.Store(ports.SecurityState{})
31 gate := portsmocks.NewMockSessionSecurity(t)
32 var calls atomic.Int32
33 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
34 v := state.Load().(ports.SecurityState)
35 if calls.Add(1) == 2 {
36 // The epoch changes right after the check that admits the command.
37 state.Store(ports.SecurityState{Generation: 2})
38 }
39 return v
40 })
41 c.opts.Security = gate
42 c.sent.outputs = ports.SetOutputs{Outputs: c.layout(), Focused: c.cur().name(), Off: c.offOutputs()}
43 commands := make(chan ports.ClientCommand, 8)
44 c.ch.Commands = commands
45 err := c.handleInput(context.Background(), ports.PointerMotion{X: 350, Y: 30})
46 cmd := reviewReceive(t, commands).(ports.SecurityCommand)
47 if cmd.State != (ports.SecurityState{}) {
48 t.Fatalf("old handler adopted new epoch: %+v", cmd)
49 }
50 if !errors.Is(err, errSecurityChanged) {
51 t.Fatalf("handler not aborted: %v", err)
52 }
53 if c.security != (ports.SecurityState{}) {
54 t.Fatal("publish adopted generation mid-input")
55 }
56 select {
57 case cmd := <-commands:
58 t.Fatalf("remaining old input command: %+v", cmd)
59 default:
60 }
61}
62
63func TestExactEpochAdmissionPreservesProducerModifierState(t *testing.T) {
64 c, _, _ := hiddenCaptureCommands(t)
65 state := ports.SecurityState{Generation: 2}
66 gate := portsmocks.NewMockSessionSecurity(t)
67 gate.EXPECT().Snapshot().Return(state)
68 c.opts.Security = gate
69 c.syncSecurity()
70 key := ports.KeyEvent{Keycode: 58, Keysym: "Control_L", Pressed: true, Mods: ports.ModCtrl, State: ports.ModState{Depressed: 1 << 19, Latched: 1 << 21, Locked: 1 << 23, Group: 2}}
71 ev, ok := c.admitInput(ports.SecurityInput{State: state, Event: key})
72 if !ok || ev.(ports.KeyEvent) != key {
73 t.Fatalf("producer state reinterpreted: %+v", ev)
74 }
75 if _, ok := c.admitInput(ports.SecurityInput{State: ports.SecurityState{Generation: 1}, Event: key}); ok {
76 t.Fatal("stale producer epoch admitted")
77 }
78}
79
80func TestEpochChangeSuppressesSpawnAndDefersSlots(t *testing.T) {
81 c, _, _ := hiddenCaptureCommands(t)
82 key := slotKey{workspace: "dev", index: 1}
83 c.slots[key] = &slotState{argv: []string{"terminal"}}
84 c.toSpawn = []slotKey{key}
85 var state atomic.Value
86 state.Store(ports.SecurityState{})
87 gate := portsmocks.NewMockSessionSecurity(t)
88 var calls atomic.Int32
89 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
90 v := state.Load().(ports.SecurityState)
91 if calls.Add(1) == 2 {
92 // The gate locks right after the check that admits the slot.
93 state.Store(ports.SecurityState{Generation: 1, Protected: true})
94 }
95 return v
96 })
97 c.opts.Security = gate
98 commands := make(chan ports.ClientCommand, 8)
99 c.ch.Commands = commands
100 spawn := make(chan ports.SpawnRequest, 2)
101 c.ch.Spawn = spawn
102 err := c.spawnSlots(context.Background(), false)
103 if err != nil {
104 t.Fatal(err)
105 }
106 if len(spawn) != 0 || len(c.toSpawn) != 1 || c.placement.slotPending(key) {
107 t.Fatal("spawn leaked or deferred slot lost")
108 }
109 // Retry after unlock, with a real buffered command sink.
110 c.ch.Commands = make(chan ports.ClientCommand, 8)
111 state.Store(ports.SecurityState{Generation: 2})
112 c.syncSecurity()
113 if err := c.spawnSlots(context.Background(), false); err != nil {
114 t.Fatal(err)
115 }
116 if len(spawn) != 1 || len(c.toSpawn) != 0 {
117 t.Fatal("deferred slot did not retry")
118 }
119}
120
121func TestTerminalEpochChangeSuppressesSpawnAndRestoresRetry(t *testing.T) {
122 c, _, _ := hiddenCaptureCommands(t)
123 c.cur().mon.RemoveWindow(1)
124 c.cur().mon.RemoveWindow(2)
125 c.opts.Terminal = true
126 c.cfg.Terminal.AutoOpen = "always"
127 c.cfg.Terminal.Command = []string{"terminal"}
128 var state atomic.Value
129 state.Store(ports.SecurityState{})
130 gate := portsmocks.NewMockSessionSecurity(t)
131 var calls atomic.Int32
132 gate.EXPECT().Snapshot().RunAndReturn(func() ports.SecurityState {
133 v := state.Load().(ports.SecurityState)
134 if calls.Add(1) == 1 {
135 // The gate locks right after the check that admits the spawn.
136 state.Store(ports.SecurityState{Generation: 1, Protected: true})
137 }
138 return v
139 })
140 c.opts.Security = gate
141 commands := make(chan ports.ClientCommand, 8)
142 c.ch.Commands = commands
143 spawn := make(chan ports.SpawnRequest, 1)
144 c.ch.Spawn = spawn
145 err := c.spawnEmpty(context.Background())
146 if err != nil {
147 t.Fatal(err)
148 }
149 if len(spawn) != 0 || c.placement.anyPending() {
150 t.Fatal("terminal spawn/claim crossed lock")
151 }
152}
153
154// The pre-existing slot test can observe a startup placeholder snapshot: a
155// scene send happens before the real-output workspace publication. Reproduce
156// the empty snapshot contract without intentionally panicking a test runner.
157func TestStartupWorkspaceSnapshotHasNoOutputBeforeOutputAdded(t *testing.T) {
158 var cfg ports.Config
159 cfg.Keyboard.CmdKey = "super"
160 cfg.Layout.MaxColumns = 2
161 workspaces := make(chan ports.Workspaces, 1)
162 c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Workspaces: workspaces}, Options{})
163 if err != nil {
164 t.Fatal(err)
165 }
166 c.publishWorkspaces()
167 initial := <-workspaces
168 if len(initial.Outputs) != 0 {
169 t.Fatal(initial)
170 }
171 c.addScreen(ports.OutputInfo{Name: "A", Width: 100, Height: 80})
172 c.publishWorkspaces()
173 if next := <-workspaces; len(next.Outputs) != 1 {
174 t.Fatal(next)
175 }
176}