@robota-sdk/agent-ui-terminal 3.0.0-beta.82 → 3.0.0-beta.84
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 +893 -0
- package/README.md +34 -11
- package/dist/node/index.d.ts +205 -55
- 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 +19 -23
- package/src/App.tsx +14 -3
- package/src/AppPresentation.tsx +1 -1
- package/src/PermissionPrompt.tsx +73 -23
- package/src/SessionPicker.tsx +27 -7
- package/src/SessionStatusBar.tsx +6 -2
- package/src/StatusBar.tsx +24 -0
- package/src/SupervisedSessionView.tsx +568 -161
- package/src/TuiInteractionChannel.ts +17 -54
- package/src/__tests__/SupervisedSessionView-render.test.tsx +28 -0
- package/src/__tests__/SupervisedSessionView.test.tsx +561 -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__/helpers/terminal-profiles.ts +4 -4
- 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__/permission-prompt-requester.test.tsx +34 -0
- package/src/__tests__/pty/behavior-2437-git.ptytest.ts +11 -3
- 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__/pty/trust-prompt.ptytest.ts +134 -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__/tui-interaction-queues.test.ts +10 -0
- package/src/__tests__/tui-state-manager.test.ts +31 -0
- package/src/__tests__/wire-tui-channel.test.ts +1457 -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 +19 -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-interaction-queues.ts +9 -1
- package/src/tui-session-event-projector.ts +12 -3
- package/src/tui-session-events.ts +6 -0
- package/src/tui-session-options.ts +9 -0
- package/src/tui-state-manager.ts +25 -5
- package/src/types.ts +3 -1
- package/src/waiting-loop-stop-notice.ts +23 -0
- package/src/wire-history-sync.ts +159 -0
- package/src/wire-tui-channel.ts +963 -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,28 @@ 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',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const QUESTION = {
|
|
30
|
+
folder: '/work/repo',
|
|
31
|
+
loads: ['Project sources (metadata only; content not read):', ' [file] .robota/settings.json — settings'],
|
|
19
32
|
};
|
|
20
33
|
|
|
21
34
|
describe('supervised session view', () => {
|
|
@@ -28,37 +41,56 @@ describe('supervised session view', () => {
|
|
|
28
41
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('github.com #123'));
|
|
29
42
|
expect(view.lastFrame()).toContain(url);
|
|
30
43
|
expect(onOpenPr).not.toHaveBeenCalled();
|
|
31
|
-
view.stdin.write('
|
|
32
|
-
await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url));
|
|
33
|
-
} finally {
|
|
44
|
+
view.stdin.write('o');
|
|
45
|
+
await vi.waitFor(() => expect(onOpenPr).toHaveBeenCalledExactlyOnceWith(FIRST.id, url, FIRST.generation));
|
|
46
|
+
} finally {
|
|
47
|
+
view.unmount();
|
|
48
|
+
}
|
|
34
49
|
});
|
|
35
50
|
|
|
36
51
|
it('hides stale PR links and refuses open when discovery fails', async () => {
|
|
37
52
|
const url = 'https://github.com/team/repo/pull/123';
|
|
38
53
|
let fail = false;
|
|
39
54
|
const onOpenPr = vi.fn(async () => undefined);
|
|
40
|
-
const view = render(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
const view = render(
|
|
56
|
+
<SupervisedSessionView
|
|
57
|
+
refreshMs={20}
|
|
58
|
+
onOpenPr={onOpenPr}
|
|
59
|
+
loadRows={async () => {
|
|
60
|
+
if (fail) throw new Error('offline');
|
|
61
|
+
return [
|
|
62
|
+
{ ...FIRST, pr: { url, host: 'github.com', number: 123, kind: 'pull' as const } },
|
|
63
|
+
];
|
|
64
|
+
}}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
45
67
|
try {
|
|
46
68
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(url));
|
|
47
69
|
fail = true;
|
|
48
70
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('discovery unavailable'));
|
|
49
71
|
expect(view.lastFrame()).not.toContain(url);
|
|
50
|
-
view.stdin.write('
|
|
72
|
+
view.stdin.write('o');
|
|
51
73
|
expect(onOpenPr).not.toHaveBeenCalled();
|
|
52
|
-
} finally {
|
|
74
|
+
} finally {
|
|
75
|
+
view.unmount();
|
|
76
|
+
}
|
|
53
77
|
});
|
|
54
78
|
|
|
55
79
|
it('refreshes a replaced or cleared association without retaining the old URL', async () => {
|
|
56
80
|
const first = 'https://github.com/team/repo/pull/123';
|
|
57
81
|
const next = 'https://git.example.org/team/repo/-/merge_requests/24';
|
|
58
|
-
let association: ISupervisedViewRow['pr'] = {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
82
|
+
let association: ISupervisedViewRow['pr'] = {
|
|
83
|
+
url: first,
|
|
84
|
+
host: 'github.com',
|
|
85
|
+
number: 123,
|
|
86
|
+
kind: 'pull',
|
|
87
|
+
};
|
|
88
|
+
const view = render(
|
|
89
|
+
<SupervisedSessionView
|
|
90
|
+
refreshMs={20}
|
|
91
|
+
loadRows={async () => [{ ...FIRST, ...(association ? { pr: association } : {}) }]}
|
|
92
|
+
/>,
|
|
93
|
+
);
|
|
62
94
|
try {
|
|
63
95
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(first));
|
|
64
96
|
association = { url: next, host: 'git.example.org', number: 24, kind: 'merge-request' };
|
|
@@ -66,7 +98,9 @@ describe('supervised session view', () => {
|
|
|
66
98
|
expect(view.lastFrame()).not.toContain(first);
|
|
67
99
|
association = undefined;
|
|
68
100
|
await vi.waitFor(() => expect(view.lastFrame()).not.toContain(next));
|
|
69
|
-
} finally {
|
|
101
|
+
} finally {
|
|
102
|
+
view.unmount();
|
|
103
|
+
}
|
|
70
104
|
});
|
|
71
105
|
it('toggles verified directory grouping without losing selection or inventing unknown paths', async () => {
|
|
72
106
|
const rows = [
|
|
@@ -94,7 +128,9 @@ describe('supervised session view', () => {
|
|
|
94
128
|
it('announces directory groups and their selection in screen-reader mode', async () => {
|
|
95
129
|
const view = render(
|
|
96
130
|
<ScreenReaderProvider enabled>
|
|
97
|
-
<SupervisedSessionView
|
|
131
|
+
<SupervisedSessionView
|
|
132
|
+
loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, SECOND]}
|
|
133
|
+
/>
|
|
98
134
|
</ScreenReaderProvider>,
|
|
99
135
|
);
|
|
100
136
|
try {
|
|
@@ -111,13 +147,19 @@ describe('supervised session view', () => {
|
|
|
111
147
|
});
|
|
112
148
|
|
|
113
149
|
it('keeps long directory headings within a narrow terminal viewport', async () => {
|
|
114
|
-
const view = render(
|
|
115
|
-
|
|
116
|
-
|
|
150
|
+
const view = render(
|
|
151
|
+
<SupervisedSessionView
|
|
152
|
+
loadRows={async () => [
|
|
153
|
+
{ ...FIRST, cwd: `/projects/${'very-long-directory-name-'.repeat(8)}` },
|
|
154
|
+
]}
|
|
155
|
+
/>,
|
|
156
|
+
);
|
|
117
157
|
try {
|
|
118
158
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
119
159
|
view.stdout.emit('resize');
|
|
120
|
-
await vi.waitFor(() =>
|
|
160
|
+
await vi.waitFor(() =>
|
|
161
|
+
expect(view.lastFrame()).toContain(`Selected ${FIRST.id.slice(0, 8)}`),
|
|
162
|
+
);
|
|
121
163
|
view.stdin.write('g');
|
|
122
164
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('Dir 1:'));
|
|
123
165
|
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
@@ -127,10 +169,14 @@ describe('supervised session view', () => {
|
|
|
127
169
|
});
|
|
128
170
|
|
|
129
171
|
it('distinguishes directory groups with a shared prefix in a narrow terminal', async () => {
|
|
130
|
-
const view = render(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
172
|
+
const view = render(
|
|
173
|
+
<SupervisedSessionView
|
|
174
|
+
loadRows={async () => [
|
|
175
|
+
{ ...FIRST, cwd: '/projects/alpha' },
|
|
176
|
+
{ ...THIRD, cwd: '/projects/beta' },
|
|
177
|
+
]}
|
|
178
|
+
/>,
|
|
179
|
+
);
|
|
134
180
|
try {
|
|
135
181
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
136
182
|
view.stdout.emit('resize');
|
|
@@ -144,10 +190,14 @@ describe('supervised session view', () => {
|
|
|
144
190
|
});
|
|
145
191
|
|
|
146
192
|
it('shows differing parent directories when project basenames match at 20 columns', async () => {
|
|
147
|
-
const view = render(
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
193
|
+
const view = render(
|
|
194
|
+
<SupervisedSessionView
|
|
195
|
+
loadRows={async () => [
|
|
196
|
+
{ ...FIRST, cwd: '/projects/alpha/app' },
|
|
197
|
+
{ ...THIRD, cwd: '/projects/beta/app' },
|
|
198
|
+
]}
|
|
199
|
+
/>,
|
|
200
|
+
);
|
|
151
201
|
try {
|
|
152
202
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
153
203
|
view.stdout.emit('resize');
|
|
@@ -161,10 +211,14 @@ describe('supervised session view', () => {
|
|
|
161
211
|
});
|
|
162
212
|
|
|
163
213
|
it('keeps the differing path portion visible when long parent names share a prefix', async () => {
|
|
164
|
-
const view = render(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
214
|
+
const view = render(
|
|
215
|
+
<SupervisedSessionView
|
|
216
|
+
loadRows={async () => [
|
|
217
|
+
{ ...FIRST, cwd: '/projects/alpha-very-long-parent/app' },
|
|
218
|
+
{ ...THIRD, cwd: '/projects/alpha-very-long-pardon/app' },
|
|
219
|
+
]}
|
|
220
|
+
/>,
|
|
221
|
+
);
|
|
168
222
|
try {
|
|
169
223
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
170
224
|
view.stdout.emit('resize');
|
|
@@ -178,10 +232,14 @@ describe('supervised session view', () => {
|
|
|
178
232
|
});
|
|
179
233
|
|
|
180
234
|
it('keeps the distinguishing wide character visible in a 20-column terminal', async () => {
|
|
181
|
-
const view = render(
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
235
|
+
const view = render(
|
|
236
|
+
<SupervisedSessionView
|
|
237
|
+
loadRows={async () => [
|
|
238
|
+
{ ...FIRST, cwd: '/projects/あいうえおかきくけこ甲' },
|
|
239
|
+
{ ...THIRD, cwd: '/projects/あいうえおかきくけこ乙' },
|
|
240
|
+
]}
|
|
241
|
+
/>,
|
|
242
|
+
);
|
|
185
243
|
try {
|
|
186
244
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
187
245
|
view.stdout.emit('resize');
|
|
@@ -196,9 +254,11 @@ describe('supervised session view', () => {
|
|
|
196
254
|
});
|
|
197
255
|
|
|
198
256
|
it('escapes layout controls in verified directory headings', async () => {
|
|
199
|
-
const view = render(
|
|
200
|
-
|
|
201
|
-
|
|
257
|
+
const view = render(
|
|
258
|
+
<SupervisedSessionView
|
|
259
|
+
loadRows={async () => [{ ...FIRST, cwd: '/projects/line\nbreak/app' }]}
|
|
260
|
+
/>,
|
|
261
|
+
);
|
|
202
262
|
try {
|
|
203
263
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
204
264
|
view.stdin.write('g');
|
|
@@ -214,10 +274,12 @@ describe('supervised session view', () => {
|
|
|
214
274
|
it('clears a pending screen-reader number before directory regrouping', async () => {
|
|
215
275
|
const view = render(
|
|
216
276
|
<ScreenReaderProvider enabled>
|
|
217
|
-
<SupervisedSessionView
|
|
218
|
-
{
|
|
219
|
-
|
|
220
|
-
|
|
277
|
+
<SupervisedSessionView
|
|
278
|
+
loadRows={async () => [
|
|
279
|
+
{ ...FIRST, cwd: '/projects/z' },
|
|
280
|
+
{ ...THIRD, cwd: '/projects/a' },
|
|
281
|
+
]}
|
|
282
|
+
/>
|
|
221
283
|
</ScreenReaderProvider>,
|
|
222
284
|
);
|
|
223
285
|
try {
|
|
@@ -235,7 +297,9 @@ describe('supervised session view', () => {
|
|
|
235
297
|
});
|
|
236
298
|
|
|
237
299
|
it('filters by an observed group without treating dead or unverified rows as idle', async () => {
|
|
238
|
-
const view = render(
|
|
300
|
+
const view = render(
|
|
301
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND, THIRD]} stateFilter="idle" />,
|
|
302
|
+
);
|
|
239
303
|
try {
|
|
240
304
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
241
305
|
expect(view.lastFrame()).toContain('State: idle');
|
|
@@ -248,14 +312,19 @@ describe('supervised session view', () => {
|
|
|
248
312
|
|
|
249
313
|
it('shows an observed loop eligibility countdown without changing idle into a process state', async () => {
|
|
250
314
|
const nextLoopAt = new Date(Date.now() + 1_200).toISOString();
|
|
251
|
-
const view = render(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
315
|
+
const view = render(
|
|
316
|
+
<SupervisedSessionView
|
|
317
|
+
loadRows={async () => [{ ...THIRD, nextLoopAt }]}
|
|
318
|
+
stateFilter="idle"
|
|
319
|
+
refreshMs={100}
|
|
320
|
+
/>,
|
|
321
|
+
);
|
|
255
322
|
try {
|
|
256
323
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible in'));
|
|
257
324
|
expect(view.lastFrame()).toContain('activity idle');
|
|
258
|
-
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), {
|
|
325
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('loop eligible now'), {
|
|
326
|
+
timeout: 3_000,
|
|
327
|
+
});
|
|
259
328
|
} finally {
|
|
260
329
|
view.unmount();
|
|
261
330
|
}
|
|
@@ -276,8 +345,24 @@ describe('supervised session view', () => {
|
|
|
276
345
|
}
|
|
277
346
|
});
|
|
278
347
|
|
|
348
|
+
it('never renders loaded rows without a selection', async () => {
|
|
349
|
+
const view = render(
|
|
350
|
+
<SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
|
|
351
|
+
);
|
|
352
|
+
try {
|
|
353
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
|
|
354
|
+
const withRows = view.frames.filter((frame) => frame.includes('Morning review'));
|
|
355
|
+
expect(withRows.length).toBeGreaterThan(0);
|
|
356
|
+
for (const frame of withRows) expect(frame).toContain(`Selected ${THIRD.id}`);
|
|
357
|
+
} finally {
|
|
358
|
+
view.unmount();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
279
362
|
it('shows a verified human name while preserving the exact selected ID', async () => {
|
|
280
|
-
const view = render(
|
|
363
|
+
const view = render(
|
|
364
|
+
<SupervisedSessionView loadRows={async () => [{ ...THIRD, name: 'Morning review' }]} />,
|
|
365
|
+
);
|
|
281
366
|
try {
|
|
282
367
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('Morning review'));
|
|
283
368
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
@@ -303,10 +388,118 @@ describe('supervised session view', () => {
|
|
|
303
388
|
}
|
|
304
389
|
});
|
|
305
390
|
|
|
391
|
+
it('asks about a folder not trusted yet, and starts as the answer says (#3268)', async () => {
|
|
392
|
+
const start = vi.fn(async () => SECOND.id);
|
|
393
|
+
const view = render(
|
|
394
|
+
<SupervisedSessionView
|
|
395
|
+
loadRows={async () => [FIRST]}
|
|
396
|
+
onStart={start}
|
|
397
|
+
startTrustQuestion={async () => QUESTION}
|
|
398
|
+
/>,
|
|
399
|
+
);
|
|
400
|
+
try {
|
|
401
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
402
|
+
view.stdin.write('n');
|
|
403
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
404
|
+
// It names the folder and what trusting it would load.
|
|
405
|
+
expect(view.lastFrame()).toContain('Not trusted: /work/repo');
|
|
406
|
+
expect(view.lastFrame()).toContain('.robota/settings.json');
|
|
407
|
+
expect(start).not.toHaveBeenCalled();
|
|
408
|
+
// n cancels: nothing starts.
|
|
409
|
+
view.stdin.write('n');
|
|
410
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('r Start Restricted'));
|
|
411
|
+
expect(start).not.toHaveBeenCalled();
|
|
412
|
+
view.stdin.write('n');
|
|
413
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
414
|
+
view.stdin.write('r');
|
|
415
|
+
await vi.waitFor(() => expect(start).toHaveBeenLastCalledWith('restricted'));
|
|
416
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Started ${SECOND.id}`));
|
|
417
|
+
view.stdin.write('n');
|
|
418
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
419
|
+
view.stdin.write('y');
|
|
420
|
+
await vi.waitFor(() => expect(start).toHaveBeenLastCalledWith('trust'));
|
|
421
|
+
} finally {
|
|
422
|
+
view.unmount();
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('fits a long trust question in a 24-row terminal, the folder and the choices whole', async () => {
|
|
427
|
+
const loads = Array.from({ length: 32 }, (_, index) => ` [absent] source-${index} — a source`);
|
|
428
|
+
const view = render(
|
|
429
|
+
<SupervisedSessionView
|
|
430
|
+
loadRows={async () => [FIRST]}
|
|
431
|
+
onStart={async () => SECOND.id}
|
|
432
|
+
startTrustQuestion={async () => ({ folder: '/work/repo', loads })}
|
|
433
|
+
/>,
|
|
434
|
+
);
|
|
435
|
+
try {
|
|
436
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
437
|
+
view.stdin.write('n');
|
|
438
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
439
|
+
const frame = view.lastFrame() ?? '';
|
|
440
|
+
const lines = frame.split('\n');
|
|
441
|
+
expect(lines.length).toBeLessThanOrEqual(24);
|
|
442
|
+
expect(lines).toContain('Not trusted: /work/repo');
|
|
443
|
+
expect(lines).toContain('y Trust and start / r Start Restricted / n Cancel');
|
|
444
|
+
expect(frame).toMatch(/… \d+ more — see robota trust status/);
|
|
445
|
+
expect(frame).toContain('source-0 — a source');
|
|
446
|
+
} finally {
|
|
447
|
+
view.unmount();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('cancels the trust question on Escape in screen-reader mode, and keeps the view open', async () => {
|
|
452
|
+
const start = vi.fn(async () => SECOND.id);
|
|
453
|
+
const view = render(
|
|
454
|
+
<ScreenReaderProvider enabled>
|
|
455
|
+
<SupervisedSessionView
|
|
456
|
+
loadRows={async () => [FIRST]}
|
|
457
|
+
onStart={start}
|
|
458
|
+
startTrustQuestion={async () => QUESTION}
|
|
459
|
+
/>
|
|
460
|
+
</ScreenReaderProvider>,
|
|
461
|
+
);
|
|
462
|
+
try {
|
|
463
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
464
|
+
view.stdin.write('n');
|
|
465
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('r Start Restricted'));
|
|
466
|
+
view.stdin.write('\u001b');
|
|
467
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('r Start Restricted'));
|
|
468
|
+
expect(view.lastFrame()).toContain(FIRST.id);
|
|
469
|
+
expect(start).not.toHaveBeenCalled();
|
|
470
|
+
} finally {
|
|
471
|
+
view.unmount();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('starts at once where the folder needs no answer', async () => {
|
|
476
|
+
const start = vi.fn(async () => SECOND.id);
|
|
477
|
+
const view = render(
|
|
478
|
+
<SupervisedSessionView
|
|
479
|
+
loadRows={async () => [FIRST]}
|
|
480
|
+
onStart={start}
|
|
481
|
+
startTrustQuestion={async () => undefined}
|
|
482
|
+
/>,
|
|
483
|
+
);
|
|
484
|
+
try {
|
|
485
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
486
|
+
view.stdin.write('n');
|
|
487
|
+
await vi.waitFor(() => expect(start).toHaveBeenCalledExactlyOnceWith(undefined));
|
|
488
|
+
expect(view.lastFrame()).not.toContain('r Start Restricted');
|
|
489
|
+
} finally {
|
|
490
|
+
view.unmount();
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
306
494
|
it('keeps stop cancellation separate from starting a session', async () => {
|
|
307
495
|
const start = vi.fn(async () => SECOND.id);
|
|
308
|
-
const view = render(
|
|
309
|
-
|
|
496
|
+
const view = render(
|
|
497
|
+
<SupervisedSessionView
|
|
498
|
+
loadRows={async () => [FIRST]}
|
|
499
|
+
onStart={start}
|
|
500
|
+
onStop={async () => undefined}
|
|
501
|
+
/>,
|
|
502
|
+
);
|
|
310
503
|
try {
|
|
311
504
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
312
505
|
view.stdin.write('s');
|
|
@@ -320,7 +513,9 @@ describe('supervised session view', () => {
|
|
|
320
513
|
});
|
|
321
514
|
|
|
322
515
|
it('can start from an empty view and hides private launch errors', async () => {
|
|
323
|
-
const start = vi
|
|
516
|
+
const start = vi
|
|
517
|
+
.fn()
|
|
518
|
+
.mockRejectedValueOnce(new Error('/private/token-path'))
|
|
324
519
|
.mockResolvedValueOnce(SECOND.id);
|
|
325
520
|
const view = render(<SupervisedSessionView loadRows={async () => []} onStart={start} />);
|
|
326
521
|
try {
|
|
@@ -338,10 +533,22 @@ describe('supervised session view', () => {
|
|
|
338
533
|
|
|
339
534
|
it('does not stop a row that leaves the selected state while confirmation is open', async () => {
|
|
340
535
|
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
341
|
-
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
342
|
-
|
|
536
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
537
|
+
finishRefresh = resolve;
|
|
538
|
+
});
|
|
539
|
+
const loadRows = vi
|
|
540
|
+
.fn()
|
|
541
|
+
.mockResolvedValueOnce([THIRD])
|
|
542
|
+
.mockImplementation(() => refresh);
|
|
343
543
|
const stop = vi.fn(async () => undefined);
|
|
344
|
-
const view = render(
|
|
544
|
+
const view = render(
|
|
545
|
+
<SupervisedSessionView
|
|
546
|
+
loadRows={loadRows}
|
|
547
|
+
onStop={stop}
|
|
548
|
+
stateFilter="idle"
|
|
549
|
+
refreshMs={100}
|
|
550
|
+
/>,
|
|
551
|
+
);
|
|
345
552
|
try {
|
|
346
553
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
347
554
|
view.stdin.write('s');
|
|
@@ -359,16 +566,36 @@ describe('supervised session view', () => {
|
|
|
359
566
|
|
|
360
567
|
it('lists every active key in help even in a narrow, crowded terminal', async () => {
|
|
361
568
|
const rows = Array.from({ length: 18 }, (_, index) => ({
|
|
362
|
-
...FIRST,
|
|
569
|
+
...FIRST,
|
|
570
|
+
id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
|
|
363
571
|
}));
|
|
364
|
-
const view = render(
|
|
572
|
+
const view = render(
|
|
573
|
+
<SupervisedSessionView
|
|
574
|
+
loadRows={async () => rows}
|
|
575
|
+
onStart={async () => SECOND.id}
|
|
576
|
+
onAttach={vi.fn()}
|
|
577
|
+
onOpenPr={async () => undefined}
|
|
578
|
+
/>,
|
|
579
|
+
);
|
|
365
580
|
try {
|
|
366
581
|
Object.defineProperty(view.stdout, 'columns', { value: 20 });
|
|
367
582
|
view.stdout.emit('resize');
|
|
368
583
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('18 supervised'));
|
|
369
584
|
view.stdin.write('?');
|
|
370
585
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('Keys:'));
|
|
371
|
-
for (const key of [
|
|
586
|
+
for (const key of [
|
|
587
|
+
'↑/↓ Select',
|
|
588
|
+
's Request stop',
|
|
589
|
+
'g Group state/dir',
|
|
590
|
+
'n New session',
|
|
591
|
+
'a Attach (drive)',
|
|
592
|
+
'p Peek (read only)',
|
|
593
|
+
'o Open linked PR',
|
|
594
|
+
'y Confirm',
|
|
595
|
+
'n/Esc Cancel',
|
|
596
|
+
'q/Esc/Ctrl+C Close',
|
|
597
|
+
'? Toggle help',
|
|
598
|
+
]) {
|
|
372
599
|
expect(view.lastFrame()).toContain(key);
|
|
373
600
|
}
|
|
374
601
|
expect((view.lastFrame() ?? '').split('\n').length).toBeLessThanOrEqual(24);
|
|
@@ -389,7 +616,9 @@ describe('supervised session view', () => {
|
|
|
389
616
|
});
|
|
390
617
|
|
|
391
618
|
it('shows a navigable global background list without inventing completion or attach', async () => {
|
|
392
|
-
const view = render(
|
|
619
|
+
const view = render(
|
|
620
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} refreshMs={100} />,
|
|
621
|
+
);
|
|
393
622
|
try {
|
|
394
623
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
395
624
|
expect(view.lastFrame()).toContain('working');
|
|
@@ -404,7 +633,9 @@ describe('supervised session view', () => {
|
|
|
404
633
|
|
|
405
634
|
it('never overlaps refreshes and aborts an in-flight probe when closed', async () => {
|
|
406
635
|
let resolveSecond: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
407
|
-
const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
636
|
+
const pending = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
637
|
+
resolveSecond = resolve;
|
|
638
|
+
});
|
|
408
639
|
const signals: AbortSignal[] = [];
|
|
409
640
|
const loadRows = vi.fn((signal: AbortSignal) => {
|
|
410
641
|
signals.push(signal);
|
|
@@ -426,18 +657,37 @@ describe('supervised session view', () => {
|
|
|
426
657
|
});
|
|
427
658
|
|
|
428
659
|
it('keeps the selected session when activity changes reorder the groups', async () => {
|
|
429
|
-
|
|
660
|
+
// The reordering refresh is released only after the selection moved, so the test cannot race
|
|
661
|
+
// the refresh timer against the keypress.
|
|
662
|
+
let releaseReorder: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
663
|
+
const reordered = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
664
|
+
releaseReorder = resolve;
|
|
665
|
+
});
|
|
666
|
+
const loadRows = vi
|
|
667
|
+
.fn()
|
|
430
668
|
.mockResolvedValueOnce([FIRST, SECOND])
|
|
431
|
-
.
|
|
432
|
-
|
|
433
|
-
{ ...SECOND, liveness: 'alive', control: 'available', activity: 'working' },
|
|
434
|
-
]);
|
|
435
|
-
const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={150} />);
|
|
669
|
+
.mockImplementation(() => reordered);
|
|
670
|
+
const view = render(<SupervisedSessionView loadRows={loadRows} refreshMs={20} />);
|
|
436
671
|
try {
|
|
437
|
-
await vi.waitFor(() => expect(view.lastFrame()).toContain(FIRST.id));
|
|
672
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
438
673
|
view.stdin.write('\x1B[B');
|
|
439
674
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`));
|
|
440
675
|
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
676
|
+
releaseReorder?.([
|
|
677
|
+
{ ...FIRST, liveness: 'dead', control: 'unavailable', activity: 'unknown' },
|
|
678
|
+
{ ...SECOND, liveness: 'alive', control: 'available', activity: 'working', generation: 'secondGenerationValue1' },
|
|
679
|
+
]);
|
|
680
|
+
// Wait for the reordered list itself: SECOND listed under `working:`, FIRST under `dead:`.
|
|
681
|
+
const rowLine = (lines: readonly string[], id: string): number =>
|
|
682
|
+
lines.findIndex((line) => line.includes(id) && !line.startsWith('Selected'));
|
|
683
|
+
await vi.waitFor(() => {
|
|
684
|
+
const lines = (view.lastFrame() ?? '').split('\n');
|
|
685
|
+
const working = lines.indexOf('working:');
|
|
686
|
+
const dead = lines.indexOf('dead:');
|
|
687
|
+
expect(working).toBeGreaterThanOrEqual(0);
|
|
688
|
+
expect(rowLine(lines, SECOND.id)).toBe(working + 1);
|
|
689
|
+
expect(rowLine(lines, FIRST.id)).toBe(dead + 1);
|
|
690
|
+
});
|
|
441
691
|
expect(view.lastFrame()).toContain(`Selected ${SECOND.id}`);
|
|
442
692
|
} finally {
|
|
443
693
|
view.unmount();
|
|
@@ -494,7 +744,9 @@ describe('supervised session view', () => {
|
|
|
494
744
|
...FIRST,
|
|
495
745
|
id: `8bf9bc27-d773-4e88-b88f-${String(index).padStart(12, '0')}`,
|
|
496
746
|
}));
|
|
497
|
-
const view = render(
|
|
747
|
+
const view = render(
|
|
748
|
+
<SupervisedSessionView loadRows={async () => rows} onStop={async () => undefined} />,
|
|
749
|
+
);
|
|
498
750
|
try {
|
|
499
751
|
Object.defineProperty(view.stdout, 'columns', { value: 40 });
|
|
500
752
|
view.stdout.emit('resize');
|
|
@@ -512,7 +764,12 @@ describe('supervised session view', () => {
|
|
|
512
764
|
|
|
513
765
|
it('identifies the selected stop target even at 20 columns with matching UUID prefixes', async () => {
|
|
514
766
|
const similar = { ...FIRST, id: `${FIRST.id.slice(0, -8)}ffffffff` };
|
|
515
|
-
const view = render(
|
|
767
|
+
const view = render(
|
|
768
|
+
<SupervisedSessionView
|
|
769
|
+
loadRows={async () => [FIRST, similar]}
|
|
770
|
+
onStop={async () => undefined}
|
|
771
|
+
/>,
|
|
772
|
+
);
|
|
516
773
|
try {
|
|
517
774
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
518
775
|
view.stdin.write('\x1B[B');
|
|
@@ -530,8 +787,15 @@ describe('supervised session view', () => {
|
|
|
530
787
|
|
|
531
788
|
it('requires confirmation and stops only the selected owner-controllable row', async () => {
|
|
532
789
|
let finishStop: (() => void) | undefined;
|
|
533
|
-
const stop = vi.fn(
|
|
534
|
-
|
|
790
|
+
const stop = vi.fn(
|
|
791
|
+
() =>
|
|
792
|
+
new Promise<void>((resolve) => {
|
|
793
|
+
finishStop = resolve;
|
|
794
|
+
}),
|
|
795
|
+
);
|
|
796
|
+
const view = render(
|
|
797
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
|
|
798
|
+
);
|
|
535
799
|
try {
|
|
536
800
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
537
801
|
view.stdin.write('s');
|
|
@@ -543,7 +807,7 @@ describe('supervised session view', () => {
|
|
|
543
807
|
view.stdin.write('s');
|
|
544
808
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
545
809
|
view.stdin.write('y');
|
|
546
|
-
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id));
|
|
810
|
+
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(FIRST.id, FIRST.generation));
|
|
547
811
|
await vi.waitFor(() => expect(view.lastFrame()).toContain('Stopping'));
|
|
548
812
|
expect(view.lastFrame()).toContain('Stop in progress; wait for result.');
|
|
549
813
|
expect(view.lastFrame()).not.toContain('q/Esc Close');
|
|
@@ -561,12 +825,17 @@ describe('supervised session view', () => {
|
|
|
561
825
|
|
|
562
826
|
it('refuses a stop when polling removes control while confirmation is open', async () => {
|
|
563
827
|
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
564
|
-
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
565
|
-
|
|
828
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
829
|
+
finishRefresh = resolve;
|
|
830
|
+
});
|
|
831
|
+
const loadRows = vi
|
|
832
|
+
.fn()
|
|
566
833
|
.mockResolvedValueOnce([FIRST])
|
|
567
834
|
.mockImplementation(() => refresh);
|
|
568
835
|
const stop = vi.fn(async () => undefined);
|
|
569
|
-
const view = render(
|
|
836
|
+
const view = render(
|
|
837
|
+
<SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
|
|
838
|
+
);
|
|
570
839
|
try {
|
|
571
840
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
572
841
|
view.stdin.write('s');
|
|
@@ -583,8 +852,12 @@ describe('supervised session view', () => {
|
|
|
583
852
|
});
|
|
584
853
|
|
|
585
854
|
it('never offers stop for unavailable rows and leaves failures visible', async () => {
|
|
586
|
-
const stop = vi.fn(async () => {
|
|
587
|
-
|
|
855
|
+
const stop = vi.fn(async () => {
|
|
856
|
+
throw new Error('private path must not be shown');
|
|
857
|
+
});
|
|
858
|
+
const view = render(
|
|
859
|
+
<SupervisedSessionView loadRows={async () => [FIRST, SECOND]} onStop={stop} />,
|
|
860
|
+
);
|
|
588
861
|
try {
|
|
589
862
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
590
863
|
view.stdin.write('\x1B[B');
|
|
@@ -605,6 +878,21 @@ describe('supervised session view', () => {
|
|
|
605
878
|
}
|
|
606
879
|
});
|
|
607
880
|
|
|
881
|
+
it('selects when a typed number and Enter arrive in one input chunk', async () => {
|
|
882
|
+
const view = render(
|
|
883
|
+
<ScreenReaderProvider enabled>
|
|
884
|
+
<SupervisedSessionView loadRows={async () => [FIRST, THIRD]} />
|
|
885
|
+
</ScreenReaderProvider>,
|
|
886
|
+
);
|
|
887
|
+
try {
|
|
888
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
889
|
+
view.stdin.write('2\r');
|
|
890
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
891
|
+
} finally {
|
|
892
|
+
view.unmount();
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
608
896
|
it('uses the same confirmed stop action after screen-reader selection', async () => {
|
|
609
897
|
const stop = vi.fn(async () => undefined);
|
|
610
898
|
const view = render(
|
|
@@ -621,7 +909,198 @@ describe('supervised session view', () => {
|
|
|
621
909
|
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Stop ${THIRD.id}?`));
|
|
622
910
|
expect(view.lastFrame()).not.toContain('Enter selection');
|
|
623
911
|
view.stdin.write('y');
|
|
624
|
-
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id));
|
|
912
|
+
await vi.waitFor(() => expect(stop).toHaveBeenCalledExactlyOnceWith(THIRD.id, THIRD.generation));
|
|
913
|
+
} finally {
|
|
914
|
+
view.unmount();
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
it('refuses a stop when the session restarts under the same id while confirmation is open', async () => {
|
|
918
|
+
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
919
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
920
|
+
finishRefresh = resolve;
|
|
921
|
+
});
|
|
922
|
+
const loadRows = vi
|
|
923
|
+
.fn()
|
|
924
|
+
.mockResolvedValueOnce([FIRST])
|
|
925
|
+
.mockImplementation(() => refresh);
|
|
926
|
+
const stop = vi.fn(async () => undefined);
|
|
927
|
+
const view = render(
|
|
928
|
+
<SupervisedSessionView loadRows={loadRows} onStop={stop} refreshMs={100} />,
|
|
929
|
+
);
|
|
930
|
+
try {
|
|
931
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
932
|
+
view.stdin.write('s');
|
|
933
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
934
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
935
|
+
finishRefresh?.([{ ...FIRST, generation: 'restartedGeneration001' }]);
|
|
936
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
|
|
937
|
+
view.stdin.write('y');
|
|
938
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
|
|
939
|
+
expect(stop).not.toHaveBeenCalled();
|
|
940
|
+
} finally {
|
|
941
|
+
view.unmount();
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it('never offers stop or PR open for a row without a verified generation', async () => {
|
|
946
|
+
const stop = vi.fn(async () => undefined);
|
|
947
|
+
const onOpenPr = vi.fn(async () => undefined);
|
|
948
|
+
const { generation: _generation, ...unbound } = FIRST;
|
|
949
|
+
const row: ISupervisedViewRow = {
|
|
950
|
+
...unbound,
|
|
951
|
+
name: 'Unbound name',
|
|
952
|
+
pr: { url: 'https://github.com/team/repo/pull/9', host: 'github.com', number: 9, kind: 'pull' },
|
|
953
|
+
};
|
|
954
|
+
const view = render(
|
|
955
|
+
<SupervisedSessionView loadRows={async () => [row]} onStop={stop} onOpenPr={onOpenPr} />,
|
|
956
|
+
);
|
|
957
|
+
try {
|
|
958
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
959
|
+
expect(view.lastFrame()).toContain('unverified:');
|
|
960
|
+
expect(view.lastFrame()).not.toContain('Unbound name');
|
|
961
|
+
expect(view.lastFrame()).not.toContain('github.com');
|
|
962
|
+
view.stdin.write('s');
|
|
963
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be stopped'));
|
|
964
|
+
expect(view.lastFrame()).not.toContain('y Yes / n No');
|
|
965
|
+
view.stdin.write('o');
|
|
966
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('No verified PR link'));
|
|
967
|
+
expect(stop).not.toHaveBeenCalled();
|
|
968
|
+
expect(onOpenPr).not.toHaveBeenCalled();
|
|
969
|
+
} finally {
|
|
970
|
+
view.unmount();
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
it('attaches or peeks at a verified row only after a confirmation bound to its generation', async () => {
|
|
974
|
+
for (const [key, mode, wording] of [
|
|
975
|
+
['a', 'drive', 'drive (send prompts, answer its questions)'],
|
|
976
|
+
['p', 'observe', 'observe (read only)'],
|
|
977
|
+
] as const) {
|
|
978
|
+
const onAttach = vi.fn();
|
|
979
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
|
|
980
|
+
try {
|
|
981
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
982
|
+
expect(view.lastFrame()).toContain('a Attach');
|
|
983
|
+
expect(view.lastFrame()).toContain('p Peek');
|
|
984
|
+
view.stdin.write(key);
|
|
985
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(wording));
|
|
986
|
+
expect(view.lastFrame()).toContain('y Yes / n No');
|
|
987
|
+
view.stdin.write('n');
|
|
988
|
+
await vi.waitFor(() => expect(view.lastFrame()).not.toContain('y Yes / n No'));
|
|
989
|
+
expect(onAttach).not.toHaveBeenCalled();
|
|
990
|
+
view.stdin.write(key);
|
|
991
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
992
|
+
view.stdin.write('y');
|
|
993
|
+
await vi.waitFor(() =>
|
|
994
|
+
expect(onAttach).toHaveBeenCalledExactlyOnceWith({
|
|
995
|
+
id: FIRST.id, generation: FIRST.generation, mode, groupByDirectory: false,
|
|
996
|
+
}),
|
|
997
|
+
);
|
|
998
|
+
} finally {
|
|
999
|
+
view.unmount();
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it('refuses an attach when the row restarts or leaves control while confirmation is open', async () => {
|
|
1005
|
+
for (const replacement of [
|
|
1006
|
+
{ ...FIRST, generation: 'restartedGeneration001' },
|
|
1007
|
+
{ ...FIRST, control: 'unavailable' as const, activity: 'unknown' as const },
|
|
1008
|
+
]) {
|
|
1009
|
+
let finishRefresh: ((rows: readonly ISupervisedViewRow[]) => void) | undefined;
|
|
1010
|
+
const refresh = new Promise<readonly ISupervisedViewRow[]>((resolve) => {
|
|
1011
|
+
finishRefresh = resolve;
|
|
1012
|
+
});
|
|
1013
|
+
const loadRows = vi.fn().mockResolvedValueOnce([FIRST]).mockImplementation(() => refresh);
|
|
1014
|
+
const onAttach = vi.fn();
|
|
1015
|
+
const view = render(<SupervisedSessionView loadRows={loadRows} onAttach={onAttach} refreshMs={100} />);
|
|
1016
|
+
try {
|
|
1017
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
1018
|
+
view.stdin.write('a');
|
|
1019
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
1020
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(2));
|
|
1021
|
+
finishRefresh?.([replacement]);
|
|
1022
|
+
await vi.waitFor(() => expect(loadRows).toHaveBeenCalledTimes(3));
|
|
1023
|
+
view.stdin.write('y');
|
|
1024
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
|
|
1025
|
+
expect(onAttach).not.toHaveBeenCalled();
|
|
1026
|
+
} finally {
|
|
1027
|
+
view.unmount();
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('never offers attach or peek on dead, unknown or unverified rows', async () => {
|
|
1033
|
+
const { generation: _generation, ...unbound } = FIRST;
|
|
1034
|
+
for (const row of [
|
|
1035
|
+
SECOND,
|
|
1036
|
+
{ ...FIRST, liveness: 'unknown' as const, control: 'unavailable' as const, activity: 'unknown' as const },
|
|
1037
|
+
unbound,
|
|
1038
|
+
]) {
|
|
1039
|
+
const onAttach = vi.fn();
|
|
1040
|
+
const view = render(<SupervisedSessionView loadRows={async () => [row]} onAttach={onAttach} />);
|
|
1041
|
+
try {
|
|
1042
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${row.id}`));
|
|
1043
|
+
expect(view.lastFrame()).not.toContain('a Attach');
|
|
1044
|
+
expect(view.lastFrame()).not.toContain('p Peek');
|
|
1045
|
+
for (const key of ['a', 'p']) {
|
|
1046
|
+
view.stdin.write(key);
|
|
1047
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('cannot be attached to'));
|
|
1048
|
+
expect(view.lastFrame()).not.toContain('y Yes / n No');
|
|
1049
|
+
}
|
|
1050
|
+
expect(onAttach).not.toHaveBeenCalled();
|
|
1051
|
+
} finally {
|
|
1052
|
+
view.unmount();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('names the whole session id and the role in screen-reader mode', async () => {
|
|
1058
|
+
const onAttach = vi.fn();
|
|
1059
|
+
const view = render(
|
|
1060
|
+
<ScreenReaderProvider enabled>
|
|
1061
|
+
<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />
|
|
1062
|
+
</ScreenReaderProvider>,
|
|
1063
|
+
);
|
|
1064
|
+
try {
|
|
1065
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
1066
|
+
expect(view.lastFrame()).toContain('a Attach; p Peek;');
|
|
1067
|
+
view.stdin.write('p');
|
|
1068
|
+
await vi.waitFor(() =>
|
|
1069
|
+
expect(view.lastFrame()).toContain(`Peek at ${FIRST.id} to observe (read only)? y Yes / n No`),
|
|
1070
|
+
);
|
|
1071
|
+
} finally {
|
|
1072
|
+
view.unmount();
|
|
1073
|
+
}
|
|
1074
|
+
});
|
|
1075
|
+
it('reopens on the row and grouping it was left with', async () => {
|
|
1076
|
+
const view = render(
|
|
1077
|
+
<SupervisedSessionView
|
|
1078
|
+
loadRows={async () => [{ ...FIRST, cwd: '/projects/alpha' }, { ...THIRD, cwd: '/projects/beta' }]}
|
|
1079
|
+
initialSelectedId={THIRD.id}
|
|
1080
|
+
initialGroupByDirectory
|
|
1081
|
+
/>,
|
|
1082
|
+
);
|
|
1083
|
+
try {
|
|
1084
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${THIRD.id}`));
|
|
1085
|
+
expect(view.lastFrame()).toContain('Dir 1: alpha — /projects/alpha');
|
|
1086
|
+
} finally {
|
|
1087
|
+
view.unmount();
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('hands back the grouping with a confirmed attach', async () => {
|
|
1092
|
+
const onAttach = vi.fn();
|
|
1093
|
+
const view = render(<SupervisedSessionView loadRows={async () => [FIRST]} onAttach={onAttach} />);
|
|
1094
|
+
try {
|
|
1095
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain(`Selected ${FIRST.id}`));
|
|
1096
|
+
view.stdin.write('g');
|
|
1097
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('Directory: unverified'));
|
|
1098
|
+
view.stdin.write('p');
|
|
1099
|
+
await vi.waitFor(() => expect(view.lastFrame()).toContain('y Yes / n No'));
|
|
1100
|
+
view.stdin.write('y');
|
|
1101
|
+
await vi.waitFor(() => expect(onAttach).toHaveBeenCalledExactlyOnceWith({
|
|
1102
|
+
id: FIRST.id, generation: FIRST.generation, mode: 'observe', groupByDirectory: true,
|
|
1103
|
+
}));
|
|
625
1104
|
} finally {
|
|
626
1105
|
view.unmount();
|
|
627
1106
|
}
|