@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 3.0.0-beta.83
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/CHANGELOG.md +858 -0
- package/README.md +5 -1
- package/dist/node/index.d.ts +189 -54
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +21 -21
- package/dist/node/index.js.map +1 -1
- package/package.json +16 -21
- package/src/App.tsx +14 -3
- package/src/AppPresentation.tsx +1 -1
- package/src/PermissionPrompt.tsx +50 -17
- package/src/SessionPicker.tsx +27 -7
- package/src/SessionStatusBar.tsx +6 -2
- package/src/StatusBar.tsx +24 -0
- package/src/SupervisedSessionView.tsx +479 -157
- package/src/TuiInteractionChannel.ts +8 -53
- package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +453 -82
- package/src/__tests__/TuiInteractionChannel.display-contract.test.ts +32 -22
- package/src/__tests__/TuiInteractionChannel.lifecycle.test.ts +10 -2
- package/src/__tests__/attached-app-session-switch.test.tsx +317 -0
- package/src/__tests__/checkpoint-store-per-channel.test.ts +81 -0
- package/src/__tests__/command-handoff-pty-e2e.test.ts +2 -2
- package/src/__tests__/default-tui-cli-adapter-host-settings.test.ts +3 -1
- package/src/__tests__/fixtures/command-handoff-driver.tsx +8 -5
- package/src/__tests__/grant-history-reaches-the-session.test.ts +31 -0
- package/src/__tests__/numbered-selection-chunk.test.tsx +41 -0
- package/src/__tests__/palette-consistency.test.ts +0 -1
- package/src/__tests__/permission-prompt-arming.test.tsx +153 -0
- package/src/__tests__/pty/screen-005-prompt-footers.ptytest.ts +2 -1
- package/src/__tests__/pty/screen-006-no-color.ptytest.ts +2 -0
- package/src/__tests__/pty/session-attach.ptytest.ts +144 -0
- package/src/__tests__/render-channel-options.test.ts +6 -6
- package/src/__tests__/sandbox-reaches-the-session.test.ts +37 -0
- package/src/__tests__/screen-reader-menus.test.tsx +1 -1
- package/src/__tests__/session-picker.test.tsx +93 -0
- package/src/__tests__/status-bar.test.tsx +8 -0
- package/src/__tests__/tui-channel-init-failure.test.ts +10 -2
- package/src/__tests__/wire-tui-channel.test.ts +1445 -0
- package/src/__tests__/wire-tui-client-commands.test.ts +243 -0
- package/src/app-view-model.ts +6 -2
- package/src/attached-session-connection.ts +14 -0
- package/src/hooks/__tests__/use-app-detach-key.test.tsx +72 -0
- package/src/hooks/__tests__/use-app-loop-escape.test.tsx +13 -0
- package/src/hooks/useAppController.ts +6 -2
- package/src/hooks/useAppInputBindings.ts +20 -2
- package/src/hooks/useAppInteractionState.ts +3 -0
- package/src/hooks/useAppOverlays.ts +20 -4
- package/src/hooks/useNumberedSelection.ts +19 -2
- package/src/hooks/useTuiChannel.ts +13 -1
- package/src/index.ts +17 -3
- package/src/render.tsx +218 -27
- package/src/short-session-id.ts +9 -0
- package/src/slash-command-input.ts +11 -0
- package/src/tui-app-channel-port.ts +29 -1
- package/src/tui-channel-options.ts +11 -2
- package/src/tui-session-event-projector.ts +0 -2
- package/src/tui-session-events.ts +6 -0
- package/src/tui-session-options.ts +9 -0
- package/src/tui-state-manager.ts +17 -3
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +962 -0
- package/src/wire-tui-client-commands.ts +142 -0
- package/src/wire-tui-projection.ts +136 -0
- package/dist/node/index.cjs +0 -32
- package/dist/node/index.d.cts +0 -1066
- package/dist/node/index.d.cts.map +0 -1
|
@@ -7,15 +7,23 @@ import SupervisedSessionView, { type ISupervisedViewRow } from '../SupervisedSes
|
|
|
7
7
|
|
|
8
8
|
const FIRST: ISupervisedViewRow = {
|
|
9
9
|
id: '8bf9bc27-d773-4e88-b88f-f7a43e9eb1f4',
|
|
10
|
-
liveness: 'alive',
|
|
10
|
+
liveness: 'alive',
|
|
11
|
+
control: 'available',
|
|
12
|
+
activity: 'working',
|
|
13
|
+
generation: 'firstGenerationValue01',
|
|
11
14
|
};
|
|
12
15
|
const SECOND: ISupervisedViewRow = {
|
|
13
16
|
id: 'fe2c7f72-ecb3-4a05-9bb1-2563ec80e615',
|
|
14
|
-
liveness: 'dead',
|
|
17
|
+
liveness: 'dead',
|
|
18
|
+
control: 'unavailable',
|
|
19
|
+
activity: 'unknown',
|
|
15
20
|
};
|
|
16
21
|
const THIRD: ISupervisedViewRow = {
|
|
17
22
|
id: '4fa26e15-b17d-4908-afed-a156f8a8a17c',
|
|
18
|
-
liveness: 'alive',
|
|
23
|
+
liveness: 'alive',
|
|
24
|
+
control: 'available',
|
|
25
|
+
activity: 'idle',
|
|
26
|
+
generation: 'thirdGenerationValue01',
|
|
19
27
|
};
|
|
20
28
|
|
|
21
29
|
describe('supervised session view', () => {
|
|
@@ -28,37 +36,56 @@ describe('supervised session view', () => {
|
|
|
28
36
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('github.com #123'));
|
|
29
37
|
expect(view.lastFrame()).toContain(url);
|
|
30
38
|
expect(onOpenPr).not.toHaveBeenCalled();
|
|
31
|
-
view.stdin.write('
|
|
32
|
-
await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url));
|
|
33
|
-
} finally {
|
|
39
|
+
view.stdin.write('o');
|
|
40
|
+
await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url, FIRST.generation));
|
|
41
|
+
} finally {
|
|
42
|
+
view.unmount();
|
|
43
|
+
}
|
|
34
44
|
});
|
|
35
45
|
|
|
36
46
|
it('hides stale PR links and refuses open when discovery fails', async () => {
|
|
37
47
|
const url = 'https://github.com/team/repo/pull/123';
|
|
38
48
|
let fail = false;
|
|
39
49
|
const onOpenPr = vi.fn(async () => undefined);
|
|
40
|
-
const view = render(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
const view = render(
|
|
51
|
+
<SupervisedSessionView
|
|
52
|
+
refreshMs={20}
|
|
53
|
+
onOpenPr={onOpenPr}
|
|
54
|
+
loadRows={async () => {
|
|
55
|
+
if (fail) throw new Error('offline');
|
|
56
|
+
return [
|
|
57
|
+
{ ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } },
|
|
58
|
+
];
|
|
59
|
+
}}
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
45
62
|
try {
|
|
46
63
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(url));
|
|
47
64
|
fail = true;
|
|
48
65
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('discovery unavailable'));
|
|
49
66
|
expect(view.lastFrame()).not.toContain(url);
|
|
50
|
-
view.stdin.write('
|
|
67
|
+
view.stdin.write('o');
|
|
51
68
|
expect(onOpenPr).not.toHaveBeenCalled();
|
|
52
|
-
} finally {
|
|
69
|
+
} finally {
|
|
70
|
+
view.unmount();
|
|
71
|
+
}
|
|
53
72
|
});
|
|
54
73
|
|
|
55
74
|
it('refreshes a replaced or cleared association without retaining the old URL', async () => {
|
|
56
75
|
const first = 'https://github.com/team/repo/pull/123';
|
|
57
76
|
const next = 'https://git.example.org/team/repo/-/merge_requests/24';
|
|
58
|
-
let association: ISupervisedViewRow['pr'] = {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
77
|
+
let association: ISupervisedViewRow['pr'] = {
|
|
78
|
+
url: first,
|
|
79
|
+
host: 'github.com',
|
|
80
|
+
number: 123,
|
|
81
|
+
kind: 'pull',
|
|
82
|
+
};
|
|
83
|
+
const view = render(
|
|
84
|
+
<SupervisedSessionView
|
|
85
|
+
refreshMs={20}
|
|
86
|
+
loadRows={async () => [{ ...FIRST, ...(association ? { pr: association } : {}) }]}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
62
89
|
try {
|
|
63
90
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(first));
|
|
64
91
|
association = { url: next, host: 'git.example.org', number: 24, kind: 'merge-request' };
|
|
@@ -66,7 +93,9 @@ describe('supervised session view', () => {
|
|
|
66
93
|
expect(view.lastFrame()).not.toContain(first);
|
|
67
94
|
association = undefined;
|
|
68
95
|
await vi.waitFor(() => expect(view.lastFrame()).not.toContain(next));
|
|
69
|
-
} finally {
|
|
96
|
+
} finally {
|
|
97
|
+
view.unmount();
|
|
98
|
+
}
|
|
70
99
|
});
|
|
71
100
|
it('toggles verified directory grouping without losing selection or inventing unknown paths', async () => {
|
|
72
101
|
const rows = [
|
|
@@ -94,7 +123,9 @@ describe('supervised session view', () => {
|
|
|
94
123
|
it('announces directory groups and their selection in screen-reader mode', async () => {
|
|
95
124
|
const view = render(
|
|
96
125
|
<ScreenReaderProvider enabled>
|
|
97
|
-
<SupervisedSessionView
|
|
126
|
+
<SupervisedSessionView
|
|
127
|
+
loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, SECOND]}
|
|
128
|
+
/>
|
|
98
129
|
</ScreenReaderProvider>,
|
|
99
130
|
);
|
|
100
131
|
try {
|
|
@@ -111,13 +142,19 @@ describe('supervised session view', () => {
|
|
|
111
142
|
});
|
|
112
143
|
|
|
113
144
|
it('keeps long directory headings within a narrow terminal viewport', async () => {
|
|
114
|
-
const view = render(
|
|
115
|
-
|
|
116
|
-
|
|
145
|
+
const view = render(
|
|
146
|
+
<SupervisedSessionView
|
|
147
|
+
loadRows={async () => [
|
|
148
|
+
{ ...FIRST, cwd: `/projects/${'very-long-directory-name-'.repeat(8)}` },
|
|
149
|
+
]}
|
|
150
|
+
/>,
|
|
151
|
+
);
|
|
117
152
|
try {
|
|
118
153
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
119
154
|
view.stdout.emit('resize');
|
|
120
|
-
await vi.waitFor(() =>
|
|
155
|
+
await vi.waitFor(() =>
|
|
156
|
+
expect(view.lastFrame()).toContain(`Selected ${FIRST.id.slice(0, 8)}`),
|
|
157
|
+
);
|
|
121
158
|
view.stdin.write('g');
|
|
122
159
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1:'));
|
|
123
160
|
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
@@ -127,10 +164,14 @@ describe('supervised session view', () => {
|
|
|
127
164
|
});
|
|
128
165
|
|
|
129
166
|
it('distinguishes directory groups with a shared prefix in a narrow terminal', async () => {
|
|
130
|
-
const view = render(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
167
|
+
const view = render(
|
|
168
|
+
<SupervisedSessionView
|
|
169
|
+
loadRows={async () => [
|
|
170
|
+
{ ...FIRST, cwd: '/projects/alpha' },
|
|
171
|
+
{ ...THIRD, cwd: '/projects/beta' },
|
|
172
|
+
]}
|
|
173
|
+
/>,
|
|
174
|
+
);
|
|
134
175
|
try {
|
|
135
176
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
136
177
|
view.stdout.emit('resize');
|
|
@@ -144,10 +185,14 @@ describe('supervised session view', () => {
|
|
|
144
185
|
});
|
|
145
186
|
|
|
146
187
|
it('shows differing parent directories when project basenames match at 20 columns', async () => {
|
|
147
|
-
const view = render(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
188
|
+
const view = render(
|
|
189
|
+
<SupervisedSessionView
|
|
190
|
+
loadRows={async () => [
|
|
191
|
+
{ ...FIRST, cwd: '/projects/alpha/app' },
|
|
192
|
+
{ ...THIRD, cwd: '/projects/beta/app' },
|
|
193
|
+
]}
|
|
194
|
+
/>,
|
|
195
|
+
);
|
|
151
196
|
try {
|
|
152
197
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
153
198
|
view.stdout.emit('resize');
|
|
@@ -161,10 +206,14 @@ describe('supervised session view', () => {
|
|
|
161
206
|
});
|
|
162
207
|
|
|
163
208
|
it('keeps the differing path portion visible when long parent names share a prefix', async () => {
|
|
164
|
-
const view = render(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
209
|
+
const view = render(
|
|
210
|
+
<SupervisedSessionView
|
|
211
|
+
loadRows={async () => [
|
|
212
|
+
{ ...FIRST, cwd: '/projects/alpha-very-long-parent/app' },
|
|
213
|
+
{ ...THIRD, cwd: '/projects/alpha-very-long-pardon/app' },
|
|
214
|
+
]}
|
|
215
|
+
/>,
|
|
216
|
+
);
|
|
168
217
|
try {
|
|
169
218
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
170
219
|
view.stdout.emit('resize');
|
|
@@ -178,10 +227,14 @@ describe('supervised session view', () => {
|
|
|
178
227
|
});
|
|
179
228
|
|
|
180
229
|
it('keeps the distinguishing wide character visible in a 20-column terminal', async () => {
|
|
181
|
-
const view = render(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
230
|
+
const view = render(
|
|
231
|
+
<SupervisedSessionView
|
|
232
|
+
loadRows={async () => [
|
|
233
|
+
{ ...FIRST, cwd: '/projects/あいうえおかきくけこ甲' },
|
|
234
|
+
{ ...THIRD, cwd: '/projects/あいうえおかきくけこ乙' },
|
|
235
|
+
]}
|
|
236
|
+
/>,
|
|
237
|
+
);
|
|
185
238
|
try {
|
|
186
239
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
187
240
|
view.stdout.emit('resize');
|
|
@@ -196,9 +249,11 @@ describe('supervised session view', () => {
|
|
|
196
249
|
});
|
|
197
250
|
|
|
198
251
|
it('escapes layout controls in verified directory headings', async () => {
|
|
199
|
-
const view = render(
|
|
200
|
-
|
|
201
|
-
|
|
252
|
+
const view = render(
|
|
253
|
+
<SupervisedSessionView
|
|
254
|
+
loadRows={async () => [{ ...FIRST, cwd: '/projects/line\nbreak/app' }]}
|
|
255
|
+
/>,
|
|
256
|
+
);
|
|
202
257
|
try {
|
|
203
258
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
204
259
|
view.stdin.write('g');
|
|
@@ -214,10 +269,12 @@ describe('supervised session view', () => {
|
|
|
214
269
|
it('clears a pending screen-reader number before directory regrouping', async () => {
|
|
215
270
|
const view = render(
|
|
216
271
|
<ScreenReaderProvider enabled>
|
|
217
|
-
<SupervisedSessionView
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
272
|
+
<SupervisedSessionView
|
|
273
|
+
loadRows={async () => [
|
|
274
|
+
{ ...FIRST, cwd: '/projects/z' },
|
|
275
|
+
{ ...THIRD, cwd: '/projects/a' },
|
|
276
|
+
]}
|
|
277
|
+
/>
|
|
221
278
|
</ScreenReaderProvider>,
|
|
222
279
|
);
|
|
223
280
|
try {
|
|
@@ -235,7 +292,9 @@ describe('supervised session view', () => {
|
|
|
235
292
|
});
|
|
236
293
|
|
|
237
294
|
it('filters by an observed group without treating dead or unverified rows as idle', async () => {
|
|
238
|
-
const view = render(
|
|
295
|
+
const view = render(
|
|
296
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} stateFilter="idle" />,
|
|
297
|
+
);
|
|
239
298
|
try {
|
|
240
299
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
241
300
|
expect(view.lastFrame()).toContain('State: idle');
|
|
@@ -248,14 +307,19 @@ describe('supervised session view', () => {
|
|
|
248
307
|
|
|
249
308
|
it('shows an observed loop eligibility countdown without changing idle into a process state', async () => {
|
|
250
309
|
const nextLoopAt = new Date(Date.now() + 1_200).toISOString();
|
|
251
|
-
const view = render(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
310
|
+
const view = render(
|
|
311
|
+
<SupervisedSessionView
|
|
312
|
+
loadRows={async () => [{ ...THIRD, nextLoopAt }]}
|
|
313
|
+
stateFilter="idle"
|
|
314
|
+
refreshMs={100}
|
|
315
|
+
/>,
|
|
316
|
+
);
|
|
255
317
|
try {
|
|
256
318
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible in'));
|
|
257
319
|
expect(view.lastFrame()).toContain('activity idle');
|
|
258
|
-
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), {
|
|
320
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), {
|
|
321
|
+
timeout: 3_000,
|
|
322
|
+
});
|
|
259
323
|
} finally {
|
|
260
324
|
view.unmount();
|
|
261
325
|
}
|
|
@@ -276,8 +340,24 @@ describe('supervised session view', () => {
|
|
|
276
340
|
}
|
|
277
341
|
});
|
|
278
342
|
|
|
343
|
+
it('never renders loaded rows without a selection', async () => {
|
|
344
|
+
const view = render(
|
|
345
|
+
<SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
|
|
346
|
+
);
|
|
347
|
+
try {
|
|
348
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
|
|
349
|
+
const withRows = view.frames.filter((frame) => frame.includes('Morning review'));
|
|
350
|
+
expect(withRows.length).toBeGreaterThan(0);
|
|
351
|
+
for (const frame of withRows) expect(frame).toContain(`Selected ${THIRD.id}`);
|
|
352
|
+
} finally {
|
|
353
|
+
view.unmount();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
279
357
|
it('shows a verified human name while preserving the exact selected ID', async () => {
|
|
280
|
-
const view = render(
|
|
358
|
+
const view = render(
|
|
359
|
+
<SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
|
|
360
|
+
);
|
|
281
361
|
try {
|
|
282
362
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
|
|
283
363
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
@@ -305,8 +385,13 @@ describe('supervised session view', () => {
|
|
|
305
385
|
|
|
306
386
|
it('keeps stop cancellation separate from starting a session', async () => {
|
|
307
387
|
const start = vi.fn(async () => SECOND.id);
|
|
308
|
-
const view = render(
|
|
309
|
-
|
|
388
|
+
const view = render(
|
|
389
|
+
<SupervisedSessionView
|
|
390
|
+
loadRows={async () => [FIRST]}
|
|
391
|
+
onStart={start}
|
|
392
|
+
onStop={async () => undefined}
|
|
393
|
+
/>,
|
|
394
|
+
);
|
|
310
395
|
try {
|
|
311
396
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
312
397
|
view.stdin.write('s');
|
|
@@ -320,7 +405,9 @@ describe('supervised session view', () => {
|
|
|
320
405
|
});
|
|
321
406
|
|
|
322
407
|
it('can start from an empty view and hides private launch errors', async () => {
|
|
323
|
-
const start = vi
|
|
408
|
+
const start = vi
|
|
409
|
+
.fn()
|
|
410
|
+
.mockRejectedValueOnce(new Error('/private/token-path'))
|
|
324
411
|
.mockResolvedValueOnce(SECOND.id);
|
|
325
412
|
const view = render(<SupervisedSessionView loadRows={async () => []} onStart={start} />);
|
|
326
413
|
try {
|
|
@@ -338,10 +425,22 @@ describe('supervised session view', () => {
|
|
|
338
425
|
|
|
339
426
|
it('does not stop a row that leaves the selected state while confirmation is open', async () => {
|
|
340
427
|
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
341
|
-
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
342
|
-
|
|
428
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
429
|
+
finishRefresh = resolve;
|
|
430
|
+
});
|
|
431
|
+
const loadRows = vi
|
|
432
|
+
.fn()
|
|
433
|
+
.mockResolvedValueOnce([THIRD])
|
|
434
|
+
.mockImplementation(() => refresh);
|
|
343
435
|
const stop = vi.fn(async () => undefined);
|
|
344
|
-
const view = render(
|
|
436
|
+
const view = render(
|
|
437
|
+
<SupervisedSessionView
|
|
438
|
+
loadRows={loadRows}
|
|
439
|
+
onStop={stop}
|
|
440
|
+
stateFilter="idle"
|
|
441
|
+
refreshMs={100}
|
|
442
|
+
/>,
|
|
443
|
+
);
|
|
345
444
|
try {
|
|
346
445
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
347
446
|
view.stdin.write('s');
|
|
@@ -359,16 +458,36 @@ describe('supervised session view', () => {
|
|
|
359
458
|
|
|
360
459
|
it('lists every active key in help even in a narrow, crowded terminal', async () => {
|
|
361
460
|
const rows = Array.from({ length: 18 }, (_, index) => ({
|
|
362
|
-
...FIRST,
|
|
461
|
+
...FIRST,
|
|
462
|
+
id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
|
|
363
463
|
}));
|
|
364
|
-
const view = render(
|
|
464
|
+
const view = render(
|
|
465
|
+
<SupervisedSessionView
|
|
466
|
+
loadRows={async () => rows}
|
|
467
|
+
onStart={async () => SECOND.id}
|
|
468
|
+
onAttach={vi.fn()}
|
|
469
|
+
onOpenPr={async () => undefined}
|
|
470
|
+
/>,
|
|
471
|
+
);
|
|
365
472
|
try {
|
|
366
473
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
367
474
|
view.stdout.emit('resize');
|
|
368
475
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('18 supervised'));
|
|
369
476
|
view.stdin.write('?');
|
|
370
477
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('Keys:'));
|
|
371
|
-
for (const key of [
|
|
478
|
+
for (const key of [
|
|
479
|
+
'↑/↓ Select',
|
|
480
|
+
's Request stop',
|
|
481
|
+
'g Group state/dir',
|
|
482
|
+
'n New session',
|
|
483
|
+
'a Attach (drive)',
|
|
484
|
+
'p Peek (read only)',
|
|
485
|
+
'o Open linked PR',
|
|
486
|
+
'y Confirm',
|
|
487
|
+
'n/Esc Cancel',
|
|
488
|
+
'q/Esc/Ctrl+C Close',
|
|
489
|
+
'? Toggle help',
|
|
490
|
+
]) {
|
|
372
491
|
expect(view.lastFrame()).toContain(key);
|
|
373
492
|
}
|
|
374
493
|
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
@@ -389,7 +508,9 @@ describe('supervised session view', () => {
|
|
|
389
508
|
});
|
|
390
509
|
|
|
391
510
|
it('shows a navigable global background list without inventing completion or attach', async () => {
|
|
392
|
-
const view = render(
|
|
511
|
+
const view = render(
|
|
512
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />,
|
|
513
|
+
);
|
|
393
514
|
try {
|
|
394
515
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
395
516
|
expect(view.lastFrame()).toContain('working');
|
|
@@ -404,7 +525,9 @@ describe('supervised session view', () => {
|
|
|
404
525
|
|
|
405
526
|
it('never overlaps refreshes and aborts an in-flight probe when closed', async () => {
|
|
406
527
|
let resolveSecond: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
407
|
-
const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
528
|
+
const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
529
|
+
resolveSecond = resolve;
|
|
530
|
+
});
|
|
408
531
|
const signals: AbortSignal[] = [];
|
|
409
532
|
const loadRows = vi.fn((signal: AbortSignal) => {
|
|
410
533
|
signals.push(signal);
|
|
@@ -426,18 +549,37 @@ describe('supervised session view', () => {
|
|
|
426
549
|
});
|
|
427
550
|
|
|
428
551
|
it('keeps the selected session when activity changes reorder the groups', async () => {
|
|
429
|
-
|
|
552
|
+
// The reordering refresh is released only after the selection moved, so the test cannot race
|
|
553
|
+
// the refresh timer against the keypress.
|
|
554
|
+
let releaseReorder: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
555
|
+
const reordered = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
556
|
+
releaseReorder = resolve;
|
|
557
|
+
});
|
|
558
|
+
const loadRows = vi
|
|
559
|
+
.fn()
|
|
430
560
|
.mockResolvedValueOnce([FIRST, SECOND])
|
|
431
|
-
.
|
|
432
|
-
|
|
433
|
-
{ ...SECOND, liveness: 'alive', control: 'available', activity: 'working' },
|
|
434
|
-
]);
|
|
435
|
-
const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={150} />);
|
|
561
|
+
.mockImplementation(() => reordered);
|
|
562
|
+
const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={20} />);
|
|
436
563
|
try {
|
|
437
|
-
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
564
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
438
565
|
view.stdin.write('\x1B[B');
|
|
439
566
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
|
|
440
567
|
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
568
|
+
releaseReorder?.([
|
|
569
|
+
{ ...FIRST, liveness: 'dead', control: 'unavailable', activity: 'unknown' },
|
|
570
|
+
{ ...SECOND, liveness: 'alive', control: 'available', activity: 'working', generation: 'secondGenerationValue1' },
|
|
571
|
+
]);
|
|
572
|
+
// Wait for the reordered list itself: SECOND listed under `working:`, FIRST under `dead:`.
|
|
573
|
+
const rowLine = (lines: readonly string[], id: string): number =>
|
|
574
|
+
lines.findIndex((line) => line.includes(id) && !line.startsWith('Selected'));
|
|
575
|
+
await vi.waitFor(() => {
|
|
576
|
+
const lines = (view.lastFrame() ?? '').split('\n');
|
|
577
|
+
const working = lines.indexOf('working:');
|
|
578
|
+
const dead = lines.indexOf('dead:');
|
|
579
|
+
expect(working).toBeGreaterThanOrEqual(0);
|
|
580
|
+
expect(rowLine(lines, SECOND.id)).toBe(working + 1);
|
|
581
|
+
expect(rowLine(lines, FIRST.id)).toBe(dead + 1);
|
|
582
|
+
});
|
|
441
583
|
expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`);
|
|
442
584
|
} finally {
|
|
443
585
|
view.unmount();
|
|
@@ -494,7 +636,9 @@ describe('supervised session view', () => {
|
|
|
494
636
|
...FIRST,
|
|
495
637
|
id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
|
|
496
638
|
}));
|
|
497
|
-
const view = render(
|
|
639
|
+
const view = render(
|
|
640
|
+
<SupervisedSessionView loadRows={async () => rows} onStop={async () => undefined} />,
|
|
641
|
+
);
|
|
498
642
|
try {
|
|
499
643
|
Object.defineProperty(view.stdout, 'columns', { value: 40 });
|
|
500
644
|
view.stdout.emit('resize');
|
|
@@ -512,7 +656,12 @@ describe('supervised session view', () => {
|
|
|
512
656
|
|
|
513
657
|
it('identifies the selected stop target even at 20 columns with matching UUID prefixes', async () => {
|
|
514
658
|
const similar = { ...FIRST, id: `${FIRST.id.slice(0, -8)}ffffffff` };
|
|
515
|
-
const view = render(
|
|
659
|
+
const view = render(
|
|
660
|
+
<SupervisedSessionView
|
|
661
|
+
loadRows={async () => [FIRST, similar]}
|
|
662
|
+
onStop={async () => undefined}
|
|
663
|
+
/>,
|
|
664
|
+
);
|
|
516
665
|
try {
|
|
517
666
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
518
667
|
view.stdin.write('\x1B[B');
|
|
@@ -530,8 +679,15 @@ describe('supervised session view', () => {
|
|
|
530
679
|
|
|
531
680
|
it('requires confirmation and stops only the selected owner-controllable row', async () => {
|
|
532
681
|
let finishStop: (() => void) | undefined;
|
|
533
|
-
const stop = vi.fn(
|
|
534
|
-
|
|
682
|
+
const stop = vi.fn(
|
|
683
|
+
() =>
|
|
684
|
+
new Promise<void>((resolve) => {
|
|
685
|
+
finishStop = resolve;
|
|
686
|
+
}),
|
|
687
|
+
);
|
|
688
|
+
const view = render(
|
|
689
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
|
|
690
|
+
);
|
|
535
691
|
try {
|
|
536
692
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
537
693
|
view.stdin.write('s');
|
|
@@ -543,7 +699,7 @@ describe('supervised session view', () => {
|
|
|
543
699
|
view.stdin.write('s');
|
|
544
700
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
545
701
|
view.stdin.write('y');
|
|
546
|
-
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id));
|
|
702
|
+
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id, FIRST.generation));
|
|
547
703
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('Stopping'));
|
|
548
704
|
expect(view.lastFrame()).toContain('Stop in progress; wait for result.');
|
|
549
705
|
expect(view.lastFrame()).not.toContain('q/Esc Close');
|
|
@@ -561,12 +717,17 @@ describe('supervised session view', () => {
|
|
|
561
717
|
|
|
562
718
|
it('refuses a stop when polling removes control while confirmation is open', async () => {
|
|
563
719
|
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
564
|
-
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
565
|
-
|
|
720
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
721
|
+
finishRefresh = resolve;
|
|
722
|
+
});
|
|
723
|
+
const loadRows = vi
|
|
724
|
+
.fn()
|
|
566
725
|
.mockResolvedValueOnce([FIRST])
|
|
567
726
|
.mockImplementation(() => refresh);
|
|
568
727
|
const stop = vi.fn(async () => undefined);
|
|
569
|
-
const view = render(
|
|
728
|
+
const view = render(
|
|
729
|
+
<SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
|
|
730
|
+
);
|
|
570
731
|
try {
|
|
571
732
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
572
733
|
view.stdin.write('s');
|
|
@@ -583,8 +744,12 @@ describe('supervised session view', () => {
|
|
|
583
744
|
});
|
|
584
745
|
|
|
585
746
|
it('never offers stop for unavailable rows and leaves failures visible', async () => {
|
|
586
|
-
const stop = vi.fn(async () => {
|
|
587
|
-
|
|
747
|
+
const stop = vi.fn(async () => {
|
|
748
|
+
throw new Error('private path must not be shown');
|
|
749
|
+
});
|
|
750
|
+
const view = render(
|
|
751
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
|
|
752
|
+
);
|
|
588
753
|
try {
|
|
589
754
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
590
755
|
view.stdin.write('\x1B[B');
|
|
@@ -605,6 +770,21 @@ describe('supervised session view', () => {
|
|
|
605
770
|
}
|
|
606
771
|
});
|
|
607
772
|
|
|
773
|
+
it('selects when a typed number and Enter arrive in one input chunk', async () => {
|
|
774
|
+
const view = render(
|
|
775
|
+
<ScreenReaderProvider enabled>
|
|
776
|
+
<SupervisedSessionView loadRows={async () => [FIRST, THIRD]} />
|
|
777
|
+
</ScreenReaderProvider>,
|
|
778
|
+
);
|
|
779
|
+
try {
|
|
780
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
781
|
+
view.stdin.write('2\r');
|
|
782
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
783
|
+
} finally {
|
|
784
|
+
view.unmount();
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
|
|
608
788
|
it('uses the same confirmed stop action after screen-reader selection', async () => {
|
|
609
789
|
const stop = vi.fn(async () => undefined);
|
|
610
790
|
const view = render(
|
|
@@ -621,7 +801,198 @@ describe('supervised session view', () => {
|
|
|
621
801
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Stop ${THIRD.id}?`));
|
|
622
802
|
expect(view.lastFrame()).not.toContain('Enter selection');
|
|
623
803
|
view.stdin.write('y');
|
|
624
|
-
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id));
|
|
804
|
+
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id, THIRD.generation));
|
|
805
|
+
} finally {
|
|
806
|
+
view.unmount();
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
it('refuses a stop when the session restarts under the same id while confirmation is open', async () => {
|
|
810
|
+
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
811
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
812
|
+
finishRefresh = resolve;
|
|
813
|
+
});
|
|
814
|
+
const loadRows = vi
|
|
815
|
+
.fn()
|
|
816
|
+
.mockResolvedValueOnce([FIRST])
|
|
817
|
+
.mockImplementation(() => refresh);
|
|
818
|
+
const stop = vi.fn(async () => undefined);
|
|
819
|
+
const view = render(
|
|
820
|
+
<SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
|
|
821
|
+
);
|
|
822
|
+
try {
|
|
823
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
824
|
+
view.stdin.write('s');
|
|
825
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
826
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
827
|
+
finishRefresh?.([{ ...FIRST, generation: 'restartedGeneration001' }]);
|
|
828
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
|
|
829
|
+
view.stdin.write('y');
|
|
830
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
|
|
831
|
+
expect(stop).not.toHaveBeenCalled();
|
|
832
|
+
} finally {
|
|
833
|
+
view.unmount();
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('never offers stop or PR open for a row without a verified generation', async () => {
|
|
838
|
+
const stop = vi.fn(async () => undefined);
|
|
839
|
+
const onOpenPr = vi.fn(async () => undefined);
|
|
840
|
+
const { generation: _generation, ...unbound } = FIRST;
|
|
841
|
+
const row: ISupervisedViewRow = {
|
|
842
|
+
...unbound,
|
|
843
|
+
name: 'Unbound name',
|
|
844
|
+
pr: { url: 'https://github.com/team/repo/pull/9', host: 'github.com', number: 9, kind: 'pull' },
|
|
845
|
+
};
|
|
846
|
+
const view = render(
|
|
847
|
+
<SupervisedSessionView loadRows={async () => [row]} onStop={stop} onOpenPr={onOpenPr} />,
|
|
848
|
+
);
|
|
849
|
+
try {
|
|
850
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
851
|
+
expect(view.lastFrame()).toContain('unverified:');
|
|
852
|
+
expect(view.lastFrame()).not.toContain('Unbound name');
|
|
853
|
+
expect(view.lastFrame()).not.toContain('github.com');
|
|
854
|
+
view.stdin.write('s');
|
|
855
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
|
|
856
|
+
expect(view.lastFrame()).not.toContain('y Yes / n No');
|
|
857
|
+
view.stdin.write('o');
|
|
858
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('No verified PR link'));
|
|
859
|
+
expect(stop).not.toHaveBeenCalled();
|
|
860
|
+
expect(onOpenPr).not.toHaveBeenCalled();
|
|
861
|
+
} finally {
|
|
862
|
+
view.unmount();
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
it('attaches or peeks at a verified row only after a confirmation bound to its generation', async () => {
|
|
866
|
+
for (const [key, mode, wording] of [
|
|
867
|
+
['a', 'drive', 'drive (send prompts, answer its questions)'],
|
|
868
|
+
['p', 'observe', 'observe (read only)'],
|
|
869
|
+
] as const) {
|
|
870
|
+
const onAttach = vi.fn();
|
|
871
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
|
|
872
|
+
try {
|
|
873
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
874
|
+
expect(view.lastFrame()).toContain('a Attach');
|
|
875
|
+
expect(view.lastFrame()).toContain('p Peek');
|
|
876
|
+
view.stdin.write(key);
|
|
877
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(wording));
|
|
878
|
+
expect(view.lastFrame()).toContain('y Yes / n No');
|
|
879
|
+
view.stdin.write('n');
|
|
880
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
|
|
881
|
+
expect(onAttach).not.toHaveBeenCalled();
|
|
882
|
+
view.stdin.write(key);
|
|
883
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
884
|
+
view.stdin.write('y');
|
|
885
|
+
await vi.waitFor(() =>
|
|
886
|
+
expect(onAttach).toHaveBeenCalledExactlyOnceWith({
|
|
887
|
+
id: FIRST.id, generation: FIRST.generation, mode, groupByDirectory: false,
|
|
888
|
+
}),
|
|
889
|
+
);
|
|
890
|
+
} finally {
|
|
891
|
+
view.unmount();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('refuses an attach when the row restarts or leaves control while confirmation is open', async () => {
|
|
897
|
+
for (const replacement of [
|
|
898
|
+
{ ...FIRST, generation: 'restartedGeneration001' },
|
|
899
|
+
{ ...FIRST, control: 'unavailable' as const, activity: 'unknown' as const },
|
|
900
|
+
]) {
|
|
901
|
+
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
902
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
903
|
+
finishRefresh = resolve;
|
|
904
|
+
});
|
|
905
|
+
const loadRows = vi.fn().mockResolvedValueOnce([FIRST]).mockImplementation(() => refresh);
|
|
906
|
+
const onAttach = vi.fn();
|
|
907
|
+
const view = render(<SupervisedSessionView loadRows={loadRows} onAttach={onAttach} refreshMs={100} />);
|
|
908
|
+
try {
|
|
909
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
910
|
+
view.stdin.write('a');
|
|
911
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
912
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
913
|
+
finishRefresh?.([replacement]);
|
|
914
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
|
|
915
|
+
view.stdin.write('y');
|
|
916
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
|
|
917
|
+
expect(onAttach).not.toHaveBeenCalled();
|
|
918
|
+
} finally {
|
|
919
|
+
view.unmount();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it('never offers attach or peek on dead, unknown or unverified rows', async () => {
|
|
925
|
+
const { generation: _generation, ...unbound } = FIRST;
|
|
926
|
+
for (const row of [
|
|
927
|
+
SECOND,
|
|
928
|
+
{ ...FIRST, liveness: 'unknown' as const, control: 'unavailable' as const, activity: 'unknown' as const },
|
|
929
|
+
unbound,
|
|
930
|
+
]) {
|
|
931
|
+
const onAttach = vi.fn();
|
|
932
|
+
const view = render(<SupervisedSessionView loadRows={async () => [row]} onAttach={onAttach} />);
|
|
933
|
+
try {
|
|
934
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${row.id}`));
|
|
935
|
+
expect(view.lastFrame()).not.toContain('a Attach');
|
|
936
|
+
expect(view.lastFrame()).not.toContain('p Peek');
|
|
937
|
+
for (const key of ['a', 'p']) {
|
|
938
|
+
view.stdin.write(key);
|
|
939
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
|
|
940
|
+
expect(view.lastFrame()).not.toContain('y Yes / n No');
|
|
941
|
+
}
|
|
942
|
+
expect(onAttach).not.toHaveBeenCalled();
|
|
943
|
+
} finally {
|
|
944
|
+
view.unmount();
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('names the whole session id and the role in screen-reader mode', async () => {
|
|
950
|
+
const onAttach = vi.fn();
|
|
951
|
+
const view = render(
|
|
952
|
+
<ScreenReaderProvider enabled>
|
|
953
|
+
<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />
|
|
954
|
+
</ScreenReaderProvider>,
|
|
955
|
+
);
|
|
956
|
+
try {
|
|
957
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
958
|
+
expect(view.lastFrame()).toContain('a Attach; p Peek;');
|
|
959
|
+
view.stdin.write('p');
|
|
960
|
+
await vi.waitFor(() =>
|
|
961
|
+
expect(view.lastFrame()).toContain(`Peek at ${FIRST.id} to observe (read only)? y Yes / n No`),
|
|
962
|
+
);
|
|
963
|
+
} finally {
|
|
964
|
+
view.unmount();
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
it('reopens on the row and grouping it was left with', async () => {
|
|
968
|
+
const view = render(
|
|
969
|
+
<SupervisedSessionView
|
|
970
|
+
loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, { ...THIRD, cwd: '/projects/beta' }]}
|
|
971
|
+
initialSelectedId={THIRD.id}
|
|
972
|
+
initialGroupByDirectory
|
|
973
|
+
/>,
|
|
974
|
+
);
|
|
975
|
+
try {
|
|
976
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
977
|
+
expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha');
|
|
978
|
+
} finally {
|
|
979
|
+
view.unmount();
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it('hands back the grouping with a confirmed attach', async () => {
|
|
984
|
+
const onAttach = vi.fn();
|
|
985
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
|
|
986
|
+
try {
|
|
987
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
988
|
+
view.stdin.write('g');
|
|
989
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Directory: unverified'));
|
|
990
|
+
view.stdin.write('p');
|
|
991
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
992
|
+
view.stdin.write('y');
|
|
993
|
+
await vi.waitFor(() => expect(onAttach).toHaveBeenCalledExactlyOnceWith({
|
|
994
|
+
id: FIRST.id, generation: FIRST.generation, mode: 'observe', groupByDirectory: true,
|
|
995
|
+
}));
|
|
625
996
|
} finally {
|
|
626
997
|
view.unmount();
|
|
627
998
|
}
|