@aiwg/cockpit 2026.7.11 → 2026.7.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,30 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { renderHook, act } from '@testing-library/react';
3
- import { stripTerminalAutoResponses, useSession } from './useSession';
3
+ import { sessionTargetFromAttachUrl, stripTerminalAutoResponses, useSession } from './useSession';
4
+
5
+ const terminalWrites = vi.hoisted(() => [] as string[]);
6
+
7
+ vi.mock('@xterm/xterm', () => ({
8
+ Terminal: class {
9
+ options: Record<string, unknown>;
10
+ cols = 80;
11
+ rows = 24;
12
+ constructor(options: Record<string, unknown>) { this.options = options; }
13
+ loadAddon() {}
14
+ onData(_fn: (data: string) => void) {}
15
+ onResize(_fn: (size: { cols: number; rows: number }) => void) {}
16
+ open() {}
17
+ write(data: Uint8Array | string) {
18
+ terminalWrites.push(typeof data === 'string' ? data : new TextDecoder().decode(data));
19
+ }
20
+ reset() { terminalWrites.length = 0; }
21
+ dispose() {}
22
+ },
23
+ }));
24
+
25
+ vi.mock('@xterm/addon-fit', () => ({
26
+ FitAddon: class { fit() {} },
27
+ }));
4
28
 
5
29
  // Minimal WebSocket double: records every constructed socket and lets the test
6
30
  // drive open/close/message. Mirrors the readiness-race timing (#1669).
@@ -18,6 +42,7 @@ class MockWS {
18
42
 
19
43
  beforeEach(() => {
20
44
  MockWS.instances = [];
45
+ terminalWrites.length = 0;
21
46
  (globalThis as unknown as { WebSocket: unknown }).WebSocket = MockWS as unknown;
22
47
  vi.useFakeTimers();
23
48
  });
@@ -34,6 +59,15 @@ describe('stripTerminalAutoResponses', () => {
34
59
  });
35
60
  });
36
61
 
62
+ describe('sessionTargetFromAttachUrl', () => {
63
+ it('parses executor attach URLs into explicit injection targets', () => {
64
+ expect(sessionTargetFromAttachUrl('ws://x/agents/inst%201/sessions/sess%2F1/attach')).toEqual({
65
+ instanceId: 'inst 1',
66
+ sessionId: 'sess/1',
67
+ });
68
+ });
69
+ });
70
+
37
71
  describe('useSession — retry through the PTY-readiness window (#1669)', () => {
38
72
  it('reconnects on an early empty close instead of giving up', () => {
39
73
  const { result } = renderHook(() => useSession());
@@ -89,20 +123,23 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
89
123
  expect(MockWS.instances.length).toBeLessThanOrEqual(7);
90
124
  });
91
125
 
92
- it('ignores stale socket messages after switching sessions', () => {
126
+ it('keeps a backgrounded session alive on switch; the active session drives UI state (#1749)', () => {
93
127
  const { result } = renderHook(() => useSession());
94
128
  act(() => { result.current.attach('ws://x/agents/i/sessions/old/attach', false, 'controller'); });
95
129
  const old = MockWS.instances[0];
96
130
  act(() => { result.current.attach('ws://x/agents/i/sessions/new/attach', false, 'observer'); });
97
131
  const current = MockWS.instances[1];
98
132
 
133
+ // The previous session is NOT torn down when we switch — it keeps its own
134
+ // socket and joins/streams in the background so its scrollback survives.
99
135
  act(() => {
100
136
  old.emit('open');
101
137
  old.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
102
138
  old.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
103
- old.emit('close');
104
139
  });
105
- expect(old.sent).toEqual([]);
140
+ expect(JSON.parse(old.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'controller', replay_from: 0 } });
141
+ // ...but the active (foreground) session is the new one; the backgrounded
142
+ // session's role does not leak into the UI state.
106
143
  expect(result.current.state.url).toBe('ws://x/agents/i/sessions/new/attach');
107
144
  expect(result.current.state.role).toBeNull();
108
145
 
@@ -111,10 +148,59 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
111
148
  current.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
112
149
  current.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'observer' } }) });
113
150
  });
114
- expect(JSON.parse(current.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer' } });
151
+ expect(JSON.parse(current.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer', replay_from: 0 } });
115
152
  expect(result.current.state.role).toBe('observer');
116
153
  });
117
154
 
155
+ it('switching back to an already-attached session does not reconnect it (#1749)', () => {
156
+ const { result } = renderHook(() => useSession());
157
+ act(() => { result.current.attach('ws://x/agents/i/sessions/a/attach', false, 'controller'); });
158
+ act(() => {
159
+ MockWS.instances[0].emit('open');
160
+ MockWS.instances[0].emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
161
+ });
162
+ act(() => { result.current.attach('ws://x/agents/i/sessions/b/attach', false, 'controller'); });
163
+ act(() => {
164
+ MockWS.instances[1].emit('open');
165
+ MockWS.instances[1].emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
166
+ });
167
+ expect(MockWS.instances).toHaveLength(2);
168
+
169
+ // Switch back to A (same role, not a replay) — it should just re-show, using
170
+ // the socket that's still open. No third WebSocket, no terminal reset.
171
+ act(() => { result.current.attach('ws://x/agents/i/sessions/a/attach', false, 'controller'); });
172
+ expect(MockWS.instances).toHaveLength(2);
173
+ expect(result.current.state.url).toBe('ws://x/agents/i/sessions/a/attach');
174
+ expect(result.current.state.role).toBe('controller');
175
+ });
176
+
177
+ it('requests a bounded ring replay on fresh attach joins (#1744)', () => {
178
+ const { result } = renderHook(() => useSession());
179
+ act(() => { result.current.attach('ws://x/session', false, 'observer'); });
180
+ const ws = MockWS.instances[0];
181
+
182
+ act(() => { ws.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) }); });
183
+
184
+ expect(JSON.parse(ws.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer', replay_from: 0 } });
185
+ expect(ws.url).toBe('ws://x/session');
186
+ });
187
+
188
+ it('paints replayed prior output from the joined stream on a fresh attach (#1744)', () => {
189
+ const { result } = renderHook(() => useSession());
190
+ const host = document.createElement('div');
191
+ document.body.appendChild(host);
192
+ act(() => { result.current.openTerminal(host); });
193
+
194
+ act(() => { result.current.attach('ws://x/session', false, 'observer'); });
195
+ const ws = MockWS.instances[0];
196
+ act(() => {
197
+ ws.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
198
+ ws.emit('message', { data: JSON.stringify({ op: 'output', seq: 8, payload: { data: btoa('prior output') } }) });
199
+ });
200
+
201
+ expect(terminalWrites.join('')).toContain('prior output');
202
+ });
203
+
118
204
  it('does not let a stale close clear the active controller role', () => {
119
205
  const { result } = renderHook(() => useSession());
120
206
  act(() => { result.current.attach('ws://x/old', false, 'observer'); });
@@ -134,7 +220,7 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
134
220
  expect(result.current.state.role).toBe('controller');
135
221
  });
136
222
 
137
- it('reattaches for replay immediately and asks the active socket for replay_from', () => {
223
+ it('reattaches for replay immediately and requests replay_from in the join payload', () => {
138
224
  const { result } = renderHook(() => useSession());
139
225
  act(() => { result.current.attach('ws://x/session', false, 'controller'); });
140
226
  const first = MockWS.instances[0];
@@ -146,9 +232,123 @@ describe('useSession — retry through the PTY-readiness window (#1669)', () =>
146
232
  act(() => { result.current.replay('ws://x/session', 'controller'); });
147
233
  expect(MockWS.instances).toHaveLength(2);
148
234
  const replay = MockWS.instances[1];
149
- expect(replay.url).toBe('ws://x/session?replay_from=12');
235
+ expect(replay.url).toBe('ws://x/session');
150
236
 
151
237
  act(() => { replay.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) }); });
152
238
  expect(JSON.parse(replay.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'controller', replay_from: 12 } });
153
239
  });
240
+
241
+ it('requests a keyframe when a controller socket opens but stays silent (#1746)', () => {
242
+ const { result } = renderHook(() => useSession());
243
+ const host = document.createElement('div');
244
+ document.body.appendChild(host);
245
+ act(() => { result.current.openTerminal(host); });
246
+ act(() => { result.current.attach('ws://x/session', false, 'controller'); });
247
+ const ws = MockWS.instances[0];
248
+
249
+ act(() => {
250
+ ws.emit('open');
251
+ ws.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
252
+ ws.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
253
+ vi.advanceTimersByTime(2000);
254
+ });
255
+ expect(terminalWrites.join('')).toContain('[attached — no output yet]');
256
+
257
+ act(() => {
258
+ vi.advanceTimersByTime(4000);
259
+ });
260
+
261
+ expect(terminalWrites.join('')).toContain('[attached — no output after 4s; requesting repaint]');
262
+ expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.op === 'pty.request_keyframe')).toHaveLength(1);
263
+ expect(MockWS.instances).toHaveLength(1);
264
+ });
265
+
266
+ it('reconnects once with replay_from zero when an observer socket stays silent (#1746)', () => {
267
+ const { result } = renderHook(() => useSession());
268
+ const host = document.createElement('div');
269
+ document.body.appendChild(host);
270
+ act(() => { result.current.openTerminal(host); });
271
+ act(() => { result.current.attach('ws://x/session', false, 'observer'); });
272
+ const first = MockWS.instances[0];
273
+
274
+ act(() => {
275
+ first.emit('open');
276
+ first.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) });
277
+ first.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'observer' } }) });
278
+ vi.advanceTimersByTime(4000);
279
+ });
280
+
281
+ expect(terminalWrites.join('')).toContain('[attached — no output after 4s; requesting repaint]');
282
+ expect(MockWS.instances).toHaveLength(2);
283
+ const replay = MockWS.instances[1];
284
+ act(() => { replay.emit('message', { data: JSON.stringify({ op: 'binding_hello' }) }); });
285
+ expect(JSON.parse(replay.sent[0])).toEqual({ op: 'pty.join_session', payload: { role: 'observer', replay_from: 0 } });
286
+ });
287
+
288
+ it('does not recover when the first frame arrives before the deadline (#1746)', () => {
289
+ const { result } = renderHook(() => useSession());
290
+ const host = document.createElement('div');
291
+ document.body.appendChild(host);
292
+ act(() => { result.current.openTerminal(host); });
293
+ act(() => { result.current.attach('ws://x/session', false, 'controller'); });
294
+ const ws = MockWS.instances[0];
295
+
296
+ act(() => {
297
+ ws.emit('open');
298
+ ws.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
299
+ vi.advanceTimersByTime(1000);
300
+ ws.emit('message', { data: JSON.stringify({ op: 'output', seq: 1, payload: { data: btoa('ready') } }) });
301
+ vi.advanceTimersByTime(4000);
302
+ });
303
+
304
+ expect(terminalWrites.join('')).toContain('ready');
305
+ expect(terminalWrites.join('')).not.toContain('no output after');
306
+ expect(terminalWrites.join('')).not.toContain('no output yet');
307
+ expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.op === 'pty.request_keyframe')).toHaveLength(0);
308
+ expect(MockWS.instances).toHaveLength(1);
309
+ });
310
+
311
+ it('cleans up the first-frame deadline on user detach (#1746)', () => {
312
+ const { result } = renderHook(() => useSession());
313
+ const host = document.createElement('div');
314
+ document.body.appendChild(host);
315
+ act(() => { result.current.openTerminal(host); });
316
+ act(() => { result.current.attach('ws://x/session', false, 'controller'); });
317
+ const ws = MockWS.instances[0];
318
+
319
+ act(() => {
320
+ ws.emit('open');
321
+ ws.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
322
+ result.current.detach();
323
+ vi.advanceTimersByTime(4000);
324
+ });
325
+
326
+ expect(terminalWrites.join('')).not.toContain('no output after');
327
+ expect(terminalWrites.join('')).not.toContain('no output yet');
328
+ expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.op === 'pty.request_keyframe')).toHaveLength(0);
329
+ expect(MockWS.instances).toHaveLength(1);
330
+ });
331
+
332
+ it('refuses targeted input when the requested session does not match the attached socket', () => {
333
+ const { result } = renderHook(() => useSession());
334
+ const host = document.createElement('div');
335
+ document.body.appendChild(host);
336
+ act(() => { result.current.openTerminal(host); });
337
+ act(() => { result.current.attach('ws://x/agents/inst-a/sessions/sess-a/attach', false, 'controller'); });
338
+ const ws = MockWS.instances[0];
339
+
340
+ act(() => {
341
+ ws.emit('open');
342
+ ws.emit('message', { data: JSON.stringify({ op: 'role_assigned', payload: { role: 'controller' } }) });
343
+ });
344
+
345
+ let sent = true;
346
+ act(() => {
347
+ sent = result.current.sendInput('rm -rf /', { instanceId: 'inst-b', sessionId: 'sess-b' });
348
+ });
349
+
350
+ expect(sent).toBe(false);
351
+ expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.op === 'pty.session_input')).toHaveLength(0);
352
+ expect(terminalWrites.join('')).toContain('inject refused: target inst-b:sess-b does not match attached session inst-a:sess-a');
353
+ });
154
354
  });