@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.
- package/README.md +80 -12
- package/bridge/src/server.mjs +301 -21
- package/bridge/src/smoke.mjs +3 -3
- package/package.json +2 -2
- package/runtime-docs/README.md +3 -2
- package/shell-core/keychain.mjs +35 -5
- package/vscode/extension.js +13 -26
- package/vscode/smoke.mjs +47 -0
- package/web/src/App.test.tsx +119 -1
- package/web/src/App.tsx +58 -27
- package/web/src/components/Actions.tsx +1 -1
- package/web/src/components/Inventory.tsx +61 -14
- package/web/src/components/Library.tsx +1 -1
- package/web/src/components/Sessions.test.tsx +248 -10
- package/web/src/components/Sessions.tsx +236 -65
- package/web/src/components/StartSessionModal.test.tsx +1 -1
- package/web/src/components/StartSessionModal.tsx +7 -1
- package/web/src/components/Welcome.tsx +11 -10
- package/web/src/sessionMonitor.test.tsx +85 -0
- package/web/src/sessionMonitor.ts +72 -0
- package/web/src/sessionRegistry.test.ts +112 -0
- package/web/src/sessionRegistry.ts +192 -0
- package/web/src/styles.css +20 -2
- package/web/src/types.ts +23 -1
- package/web/src/useSession.test.tsx +207 -7
- package/web/src/useSession.ts +353 -193
- package/web/src/util.ts +8 -1
|
@@ -29,7 +29,7 @@ export function Library({ session, setComposer, goSessions }: { session: Session
|
|
|
29
29
|
};
|
|
30
30
|
const use = (a: LibraryAsset) => {
|
|
31
31
|
const cmd = capRef(a.type, a.name.replace(/\.(md|markdown|ya?ml|json)$/i, ''));
|
|
32
|
-
if (!(session.isController && session.sendInput(cmd))) setComposer(cmd);
|
|
32
|
+
if (!(session.isController && session.state.target && session.sendInput(cmd, session.state.target))) setComposer(cmd);
|
|
33
33
|
goSessions();
|
|
34
34
|
};
|
|
35
35
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { render, screen, waitFor, fireEvent, cleanup, within } from '@testing-library/react';
|
|
2
|
+
import { render, screen, waitFor, fireEvent, cleanup, within, act } from '@testing-library/react';
|
|
3
3
|
import { Sessions } from './Sessions';
|
|
4
4
|
import type { SessionApi } from '../useSession';
|
|
5
|
+
import { resetSessionRegistryForTest, updateRegistrySessionSnapshot } from '../sessionRegistry';
|
|
5
6
|
|
|
6
7
|
const INSTANCE = {
|
|
7
8
|
id: 'inst-1',
|
|
@@ -21,10 +22,40 @@ const INSTANCE_NEXT = {
|
|
|
21
22
|
id: 'inst-2',
|
|
22
23
|
launch_context: { name: 'docker-two', loadout: 'agentic-dev' },
|
|
23
24
|
};
|
|
25
|
+
const STALE_INSTANCE = {
|
|
26
|
+
...INSTANCE,
|
|
27
|
+
id: 'stale-docker',
|
|
28
|
+
agent_ready: false,
|
|
29
|
+
launch_context: { name: 'stale-docker', loadout: 'agentic-dev' },
|
|
30
|
+
session_backends: [{
|
|
31
|
+
mode: 'managed',
|
|
32
|
+
backend: 'tmux',
|
|
33
|
+
available: false,
|
|
34
|
+
drive: true,
|
|
35
|
+
keyframe: true,
|
|
36
|
+
reason: 'container is running but the agent has not registered; PTY sessions are not ready',
|
|
37
|
+
}],
|
|
38
|
+
};
|
|
39
|
+
const STALE_VM_INSTANCE = {
|
|
40
|
+
...STALE_INSTANCE,
|
|
41
|
+
id: 'stale-vm',
|
|
42
|
+
runtime: 'vm',
|
|
43
|
+
runtime_posture: { kind: 'vm', isolation: 'strong', label: 'VM / hardware boundary' },
|
|
44
|
+
launch_context: { name: 'stale-vm', loadout: 'agentic-dev' },
|
|
45
|
+
session_backends: [{
|
|
46
|
+
mode: 'managed',
|
|
47
|
+
backend: 'tmux',
|
|
48
|
+
available: false,
|
|
49
|
+
drive: true,
|
|
50
|
+
keyframe: true,
|
|
51
|
+
reason: 'VM is running but the agent has not registered; PTY sessions are not ready',
|
|
52
|
+
}],
|
|
53
|
+
};
|
|
24
54
|
|
|
25
|
-
function stubSession(): SessionApi {
|
|
55
|
+
function stubSession(state: Partial<SessionApi['state']> = {}): SessionApi {
|
|
56
|
+
const nextState = { attached: true, role: 'controller', url: 'ws://x/agents/inst-1/sessions/sess-1/attach', ...state };
|
|
26
57
|
return {
|
|
27
|
-
state:
|
|
58
|
+
state: nextState,
|
|
28
59
|
responseNeeded: { needed: false, prompt: '', since: null, source: 'pty' },
|
|
29
60
|
attach: vi.fn(),
|
|
30
61
|
detach: vi.fn(),
|
|
@@ -32,12 +63,13 @@ function stubSession(): SessionApi {
|
|
|
32
63
|
requestKeyframe: vi.fn(),
|
|
33
64
|
sendInput: vi.fn(),
|
|
34
65
|
openTerminal: vi.fn(),
|
|
35
|
-
isController:
|
|
66
|
+
isController: nextState.role === 'controller',
|
|
36
67
|
} as unknown as SessionApi;
|
|
37
68
|
}
|
|
38
69
|
|
|
39
70
|
beforeEach(() => {
|
|
40
71
|
(window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPIT_TOKEN__ = 'test-token';
|
|
72
|
+
resetSessionRegistryForTest();
|
|
41
73
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
|
42
74
|
});
|
|
43
75
|
|
|
@@ -74,11 +106,11 @@ describe('Sessions', () => {
|
|
|
74
106
|
expect.stringContaining('/api/instances/inst-1/sessions/sess-1'),
|
|
75
107
|
expect.objectContaining({ method: 'DELETE' }),
|
|
76
108
|
));
|
|
77
|
-
expect(session.detach).toHaveBeenCalled();
|
|
109
|
+
await waitFor(() => expect(session.detach).toHaveBeenCalled());
|
|
78
110
|
});
|
|
79
111
|
|
|
80
112
|
it('refreshes stale recovered inventory and stops offering dead session attach URLs', async () => {
|
|
81
|
-
const session = stubSession();
|
|
113
|
+
const session = stubSession({ attached: false, role: null, url: null });
|
|
82
114
|
const inventories = [
|
|
83
115
|
{ instances: [INSTANCE] },
|
|
84
116
|
{ instances: [INSTANCE_NEXT] },
|
|
@@ -121,6 +153,56 @@ describe('Sessions', () => {
|
|
|
121
153
|
expect(await within(nav).findByTitle('sess-new')).toBeTruthy();
|
|
122
154
|
});
|
|
123
155
|
|
|
156
|
+
it('keeps a stale running container visible with reconnect guidance', async () => {
|
|
157
|
+
const session = stubSession({ attached: false, role: null, url: null });
|
|
158
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
159
|
+
const url = String(input);
|
|
160
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [STALE_INSTANCE] });
|
|
161
|
+
if (url.includes('/api/sessions?instance=stale-docker')) return jsonResponse({ sessions: [] });
|
|
162
|
+
if (url.includes('/api/instances/stale-docker/reconnect') && init?.method === 'POST') {
|
|
163
|
+
return jsonResponse({ state: 'reconnecting' });
|
|
164
|
+
}
|
|
165
|
+
return new Response('{}', { status: 404 });
|
|
166
|
+
});
|
|
167
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
168
|
+
|
|
169
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={60_000} />);
|
|
170
|
+
|
|
171
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
172
|
+
expect(await within(nav).findByText('stale-docker')).toBeTruthy();
|
|
173
|
+
expect(await screen.findByText(/agent is unreachable while the runtime is still running/i)).toBeTruthy();
|
|
174
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }));
|
|
175
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
176
|
+
expect.stringContaining('/api/instances/stale-docker/reconnect'),
|
|
177
|
+
expect.objectContaining({ method: 'POST' }),
|
|
178
|
+
));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('keeps a stale running VM visible with reconnect guidance (#1778)', async () => {
|
|
182
|
+
const session = stubSession({ attached: false, role: null, url: null });
|
|
183
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
184
|
+
const url = String(input);
|
|
185
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [STALE_VM_INSTANCE] });
|
|
186
|
+
if (url.includes('/api/sessions?instance=stale-vm')) return jsonResponse({ sessions: [] });
|
|
187
|
+
if (url.includes('/api/instances/stale-vm/reconnect') && init?.method === 'POST') {
|
|
188
|
+
return jsonResponse({ state: 'reconnecting' });
|
|
189
|
+
}
|
|
190
|
+
return new Response('{}', { status: 404 });
|
|
191
|
+
});
|
|
192
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
193
|
+
|
|
194
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={60_000} />);
|
|
195
|
+
|
|
196
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
197
|
+
expect(await within(nav).findByText('stale-vm')).toBeTruthy();
|
|
198
|
+
expect(await screen.findByText(/agent is unreachable while the runtime is still running/i)).toBeTruthy();
|
|
199
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reconnect' }));
|
|
200
|
+
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
|
201
|
+
expect.stringContaining('/api/instances/stale-vm/reconnect'),
|
|
202
|
+
expect.objectContaining({ method: 'POST' }),
|
|
203
|
+
));
|
|
204
|
+
});
|
|
205
|
+
|
|
124
206
|
it('keeps controller posture when a different session is selected while driving (#1670)', async () => {
|
|
125
207
|
const session = stubSession(); // currently attached to .../sessions/sess-1/attach as controller
|
|
126
208
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
@@ -139,7 +221,109 @@ describe('Sessions', () => {
|
|
|
139
221
|
fireEvent.click(sessBtn);
|
|
140
222
|
// Selecting a not-yet-attached session should not silently downgrade an
|
|
141
223
|
// operator who is already driving another session.
|
|
142
|
-
expect(session.attach).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-2/attach', false, 'controller');
|
|
224
|
+
expect(session.attach).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-2/attach', false, 'controller', { instanceId: 'inst-1', sessionId: 'sess-2' });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('does not detach the live session when browsing another instance (#1739)', async () => {
|
|
228
|
+
const session = stubSession();
|
|
229
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
230
|
+
const url = String(input);
|
|
231
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE, INSTANCE_NEXT] });
|
|
232
|
+
if (url.includes('instance=inst-1')) return jsonResponse({
|
|
233
|
+
sessions: [{ id: 'sess-1', session_name: 'terminal-main', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
|
|
234
|
+
});
|
|
235
|
+
if (url.includes('instance=inst-2')) return jsonResponse({
|
|
236
|
+
sessions: [{ id: 'sess-2', session_name: 'terminal-other', instance_id: 'inst-2', attach_url: 'ws://x/agents/inst-2/sessions/sess-2/attach', mode: 'managed', backend: 'tmux' }],
|
|
237
|
+
});
|
|
238
|
+
return new Response('{}', { status: 404 });
|
|
239
|
+
});
|
|
240
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
241
|
+
|
|
242
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
|
|
243
|
+
|
|
244
|
+
expect((await screen.findAllByTitle('sess-1')).length).toBeGreaterThan(0);
|
|
245
|
+
fireEvent.click(await screen.findByText('docker-two'));
|
|
246
|
+
expect((await screen.findAllByText('terminal-other')).length).toBeGreaterThan(0);
|
|
247
|
+
|
|
248
|
+
expect(session.detach).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('treats the instance/session pair as live identity when attach URLs diverge (#1741)', async () => {
|
|
252
|
+
const session = stubSession({ url: 'ws://executor-a/agents/inst-1/sessions/sess-1/attach?from=create' });
|
|
253
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
254
|
+
const url = String(input);
|
|
255
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
256
|
+
if (url.includes('/api/sessions?instance=')) return jsonResponse({
|
|
257
|
+
sessions: [{
|
|
258
|
+
id: 'sess-1',
|
|
259
|
+
session_name: 'terminal-main',
|
|
260
|
+
instance_id: 'inst-1',
|
|
261
|
+
attach_url: 'ws://executor-b/agents/inst-1/sessions/sess-1/attach',
|
|
262
|
+
mode: 'managed',
|
|
263
|
+
backend: 'tmux',
|
|
264
|
+
}],
|
|
265
|
+
});
|
|
266
|
+
return new Response('{}', { status: 404 });
|
|
267
|
+
});
|
|
268
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
269
|
+
|
|
270
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
|
|
271
|
+
|
|
272
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
273
|
+
expect(await within(nav).findByTitle('Attached here')).toBeTruthy();
|
|
274
|
+
expect(session.detach).not.toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('ignores stale session-list responses after switching instances (#1740)', async () => {
|
|
278
|
+
const session = stubSession({ attached: false, role: null, url: null });
|
|
279
|
+
let resolveInst1: (value: Response) => void = () => {};
|
|
280
|
+
const inst1Sessions = new Promise<Response>((resolve) => { resolveInst1 = resolve; });
|
|
281
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
282
|
+
const url = String(input);
|
|
283
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE, INSTANCE_NEXT] });
|
|
284
|
+
if (url.includes('instance=inst-1')) return inst1Sessions;
|
|
285
|
+
if (url.includes('instance=inst-2')) return jsonResponse({
|
|
286
|
+
sessions: [{ id: 'sess-2', session_name: 'terminal-two', instance_id: 'inst-2', attach_url: 'ws://x/agents/inst-2/sessions/sess-2/attach', mode: 'managed', backend: 'tmux' }],
|
|
287
|
+
});
|
|
288
|
+
return new Response('{}', { status: 404 });
|
|
289
|
+
});
|
|
290
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
291
|
+
|
|
292
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={60_000} />);
|
|
293
|
+
|
|
294
|
+
fireEvent.click(await screen.findByText('docker-two'));
|
|
295
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
296
|
+
expect(await within(nav).findByTitle('sess-2')).toBeTruthy();
|
|
297
|
+
|
|
298
|
+
resolveInst1(jsonResponse({
|
|
299
|
+
sessions: [{ id: 'sess-1', session_name: 'terminal-one', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
|
|
300
|
+
}));
|
|
301
|
+
|
|
302
|
+
await waitFor(() => expect(within(nav).queryByText('terminal-one')).toBeNull());
|
|
303
|
+
expect(within(nav).getByText('terminal-two')).toBeTruthy();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('requires two consecutive missing polls before detaching the attached session (#1740)', async () => {
|
|
307
|
+
const session = stubSession();
|
|
308
|
+
const sessionResponses = [
|
|
309
|
+
[{ id: 'sess-1', session_name: 'terminal-main', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
|
|
310
|
+
[{ id: 'sess-1', session_name: 'terminal-main', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-1/attach', mode: 'managed', backend: 'tmux' }],
|
|
311
|
+
[{ id: 'sess-other', session_name: 'terminal-other', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-other/attach', mode: 'managed', backend: 'tmux' }],
|
|
312
|
+
[{ id: 'sess-other', session_name: 'terminal-other', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-other/attach', mode: 'managed', backend: 'tmux' }],
|
|
313
|
+
];
|
|
314
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
315
|
+
const url = String(input);
|
|
316
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
317
|
+
if (url.includes('/api/sessions?instance=')) return jsonResponse({ sessions: sessionResponses.shift() ?? sessionResponses[sessionResponses.length - 1] ?? [] });
|
|
318
|
+
return new Response('{}', { status: 404 });
|
|
319
|
+
});
|
|
320
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
321
|
+
|
|
322
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} refreshMs={250} />);
|
|
323
|
+
expect(await screen.findByTitle('sess-other')).toBeTruthy();
|
|
324
|
+
expect(session.detach).not.toHaveBeenCalled();
|
|
325
|
+
|
|
326
|
+
await waitFor(() => expect(session.detach).toHaveBeenCalledTimes(1), { timeout: 1_200 });
|
|
143
327
|
});
|
|
144
328
|
|
|
145
329
|
it('reattaches with replay instead of downgrading when re-selecting the session already attached', async () => {
|
|
@@ -160,7 +344,7 @@ describe('Sessions', () => {
|
|
|
160
344
|
// Clicking the session we already drive replays/reasserts controller; it
|
|
161
345
|
// must not downgrade us back to observer.
|
|
162
346
|
expect(session.attach).not.toHaveBeenCalled();
|
|
163
|
-
expect(session.replay).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-1/attach', 'controller');
|
|
347
|
+
expect(session.replay).toHaveBeenCalledWith('ws://x/agents/inst-1/sessions/sess-1/attach', 'controller', { instanceId: 'inst-1', sessionId: 'sess-1' });
|
|
164
348
|
});
|
|
165
349
|
|
|
166
350
|
it('distinguishes sessions by name + backend + viewer count in the nav (#1670)', async () => {
|
|
@@ -170,8 +354,8 @@ describe('Sessions', () => {
|
|
|
170
354
|
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
171
355
|
if (url.includes('/api/sessions?instance=')) return jsonResponse({
|
|
172
356
|
sessions: [
|
|
173
|
-
{ id: 'sess-a', session_name: 'terminal-alpha', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-a/attach',
|
|
174
|
-
{ id: 'sess-b', session_name: 'terminal-beta', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-b/attach',
|
|
357
|
+
{ id: 'sess-a', session_name: 'terminal-alpha', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-a/attach', session_class: 'managed', session_backend: 'tmux', membership: { controllers: ['c1'], observers: ['o1'], attachment_count: 2 } },
|
|
358
|
+
{ id: 'sess-b', session_name: 'terminal-beta', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-b/attach', session_class: 'direct', session_backend: 'native', membership: { controllers: [], observers: [], attachment_count: 0 } },
|
|
175
359
|
],
|
|
176
360
|
});
|
|
177
361
|
return new Response('{}', { status: 404 });
|
|
@@ -191,6 +375,60 @@ describe('Sessions', () => {
|
|
|
191
375
|
// sess-a has a controller connected → it carries the ctrl badge; sess-b does not.
|
|
192
376
|
expect(within(nav).getByTitle('A controller is connected')).toBeTruthy();
|
|
193
377
|
});
|
|
378
|
+
|
|
379
|
+
it('shows registry unread and response-needed badges and clears unread on view', async () => {
|
|
380
|
+
const session = stubSession({ attached: false, role: null, url: null });
|
|
381
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
382
|
+
const url = String(input);
|
|
383
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
384
|
+
if (url.includes('/api/sessions?instance=')) return jsonResponse({
|
|
385
|
+
sessions: [
|
|
386
|
+
{ id: 'sess-a', session_name: 'terminal-alpha', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-a/attach', session_class: 'managed', session_backend: 'tmux' },
|
|
387
|
+
{ id: 'sess-b', session_name: 'terminal-beta', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-b/attach', session_class: 'managed', session_backend: 'tmux' },
|
|
388
|
+
],
|
|
389
|
+
});
|
|
390
|
+
return new Response('{}', { status: 404 });
|
|
391
|
+
});
|
|
392
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
393
|
+
|
|
394
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
|
|
395
|
+
|
|
396
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
397
|
+
const beta = await within(nav).findByTitle('sess-b');
|
|
398
|
+
act(() => updateRegistrySessionSnapshot('inst-1', 'sess-b', 'Deploy to prod? [y/N]\n'));
|
|
399
|
+
|
|
400
|
+
expect(await within(nav).findByTitle('Unread output')).toBeTruthy();
|
|
401
|
+
expect(within(nav).getByTitle('Response needed')).toBeTruthy();
|
|
402
|
+
|
|
403
|
+
fireEvent.click(beta);
|
|
404
|
+
await waitFor(() => expect(within(nav).queryByTitle('Unread output')).toBeNull());
|
|
405
|
+
expect(within(nav).getByTitle('Response needed')).toBeTruthy();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('omits viewer counts when the session source does not provide membership fields (#1745)', async () => {
|
|
409
|
+
const session = stubSession();
|
|
410
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
411
|
+
const url = String(input);
|
|
412
|
+
if (url.includes('/api/inventory')) return jsonResponse({ instances: [INSTANCE] });
|
|
413
|
+
if (url.includes('/api/sessions?instance=')) return jsonResponse({
|
|
414
|
+
sessions: [
|
|
415
|
+
{ id: 'sess-bare', session_name: 'terminal-bare', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-bare/attach', session_class: 'managed', session_backend: 'tmux' },
|
|
416
|
+
{ id: 'sess-rich', session_name: 'terminal-rich', instance_id: 'inst-1', attach_url: 'ws://x/agents/inst-1/sessions/sess-rich/attach', session_class: 'managed', session_backend: 'tmux', membership: { controllers: ['c1'], observers: ['o1'], attachment_count: 2 } },
|
|
417
|
+
],
|
|
418
|
+
});
|
|
419
|
+
return new Response('{}', { status: 404 });
|
|
420
|
+
});
|
|
421
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
422
|
+
|
|
423
|
+
render(<Sessions session={session} composer="" setComposer={() => {}} onRequestStart={() => {}} />);
|
|
424
|
+
|
|
425
|
+
await screen.findByText('terminal-rich');
|
|
426
|
+
const nav = screen.getByLabelText('Instances and sessions');
|
|
427
|
+
expect(within(nav).getAllByText('managed/tmux')).toHaveLength(1);
|
|
428
|
+
expect(within(nav).getByText('managed/tmux · 2 viewers')).toBeTruthy();
|
|
429
|
+
expect(within(nav).getByTitle('A controller is connected')).toBeTruthy();
|
|
430
|
+
expect(within(nav).queryByText('0 viewers')).toBeNull();
|
|
431
|
+
});
|
|
194
432
|
});
|
|
195
433
|
|
|
196
434
|
function jsonResponse(body: unknown): Response {
|