@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66
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/package.json +5 -5
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.diff.test.tsx +8 -73
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +60 -412
- package/src/App.prototype.test.tsx +1 -8
- package/src/App.readonly.test.tsx +85 -62
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.submit-confirm.test.tsx +32 -70
- package/src/App.test.tsx +136 -66
- package/src/App.toast.test.tsx +17 -8
- package/src/App.tsx +243 -284
- package/src/App.walk.test.tsx +1 -1
- package/src/app-screens.tsx +67 -52
- package/src/app-view-model.ts +6 -100
- package/src/components/AnnotationCard.stories.tsx +1 -1
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/AppShell.stories.tsx +84 -0
- package/src/components/AppShell.tsx +127 -0
- package/src/components/Breadcrumb.stories.tsx +1 -1
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +147 -0
- package/src/components/CodeBlock.stories.tsx +1 -1
- package/src/components/CompletionOverlay.stories.tsx +1 -1
- package/src/components/ConfirmCard.stories.tsx +1 -1
- package/src/components/ConfirmCard.tsx +3 -4
- package/src/components/ConfirmDialog.stories.tsx +1 -1
- package/src/components/DiffSheet.stories.tsx +1 -1
- package/src/components/InboxList.stories.tsx +23 -8
- package/src/components/InboxList.tsx +45 -19
- package/src/components/KeybindsDialog.stories.tsx +1 -1
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/MarkerPopover.stories.tsx +1 -1
- package/src/components/MenuBar.stories.tsx +3 -12
- package/src/components/MenuBar.tsx +25 -56
- package/src/components/PromptDialog.stories.tsx +1 -1
- package/src/components/PrototypeSheet.stories.tsx +1 -1
- package/src/components/ReviewPanel.stories.tsx +15 -1
- package/src/components/ReviewRail.stories.tsx +15 -1
- package/src/components/ReviewRail.tsx +13 -6
- package/src/components/SettingsDialog.stories.tsx +7 -1
- package/src/components/SettingsDialog.tsx +112 -66
- package/src/components/SettingsRows.stories.tsx +1 -1
- package/src/components/ShellHeader.stories.tsx +50 -0
- package/src/components/ShellHeader.tsx +137 -0
- package/src/components/ThreadFooter.stories.tsx +30 -0
- package/src/components/ThreadFooter.tsx +49 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +113 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1708 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +57 -0
- package/src/components/Toast.stories.tsx +1 -1
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/WalkWizard.stories.tsx +1 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
- package/src/components/agent-launcher.stories.tsx +1 -1
- package/src/components/file-tree.test.ts +45 -0
- package/src/components/file-tree.ts +57 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +1 -1
- package/src/components/primitives/Card.stories.tsx +1 -1
- package/src/components/primitives/Dialog.stories.tsx +1 -1
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +43 -0
- package/src/components/primitives/StatusBar.stories.tsx +1 -1
- package/src/components/primitives/Tabs.stories.tsx +1 -1
- package/src/components/primitives/Toolbar.stories.tsx +1 -1
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +87 -0
- package/src/components/primitives/icons.ts +38 -0
- package/src/components/primitives/tree-model.test.ts +99 -0
- package/src/components/primitives/tree-model.ts +137 -0
- package/src/components/quick-actions-editor.stories.tsx +1 -1
- package/src/components/session-tree.test.ts +114 -0
- package/src/components/session-tree.ts +94 -0
- package/src/components/stories-app.tsx +142 -82
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +55 -0
- package/src/components/theme-context.stories.tsx +1 -1
- package/src/diff-hunk-curate.ts +4 -104
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +136 -3
- package/src/intent-dispatch.ts +92 -24
- package/src/key-bindings.test.ts +1 -1
- package/src/keymap.test.ts +4 -4
- package/src/keymap.ts +14 -7
- package/src/session-controller.curate.test.ts +89 -2
- package/src/session-controller.share.test.ts +195 -55
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +331 -72
- package/src/share.ts +114 -16
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +64 -0
- package/src/theme.ts +4 -4
- package/src/thread-chords.test.ts +154 -0
- package/src/thread-chords.ts +144 -0
- package/src/thread-selection.test.ts +210 -0
- package/src/thread-selection.ts +243 -0
- package/src/tree-view.test.ts +128 -0
- package/src/tree-view.ts +148 -0
- package/src/use-settings-dialog.tsx +9 -0
- package/src/view-plan.ts +29 -12
- package/src/App.review-panel.test.tsx +0 -143
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -419
- package/src/components/plan-sheet-run-style.ts +0 -88
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { describe, expect, mock, test, type Mock } from "bun:test";
|
|
1
|
+
import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
2
|
import { ManualClock } from "@opentui/core/testing";
|
|
3
3
|
import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
4
4
|
import type { SessionClient } from "@cueloop/daemon/client";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createReviewController,
|
|
7
|
+
SHARE_RECONNECT_MAX_MS,
|
|
8
|
+
SHARE_RECONNECT_MIN_MS,
|
|
9
|
+
type ShareTransport,
|
|
10
|
+
} from "./session-controller";
|
|
11
|
+
import { mergeFromShare, type ShareWatchHandlers } from "./share";
|
|
6
12
|
|
|
7
13
|
const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
8
14
|
let remote: ReviewSession;
|
|
@@ -15,8 +21,10 @@ const shareTransport: ShareTransport = {
|
|
|
15
21
|
publish: publishShare,
|
|
16
22
|
pull: pullShare,
|
|
17
23
|
push: pushShare,
|
|
24
|
+
watch: () => () => {},
|
|
18
25
|
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
19
26
|
collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
|
|
27
|
+
mergeFromShare,
|
|
20
28
|
};
|
|
21
29
|
|
|
22
30
|
function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
@@ -61,6 +69,14 @@ function fakeClient(session: ReviewSession): FakeSessionClient {
|
|
|
61
69
|
sessionAnnotate: mock<SessionClient["sessionAnnotate"]>(async () => session),
|
|
62
70
|
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
63
71
|
sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
|
|
72
|
+
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
73
|
+
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
74
|
+
sessionCurate: unimplemented("sessionCurate"),
|
|
75
|
+
sessionNavigate: unimplemented("sessionNavigate"),
|
|
76
|
+
sessionBranch: unimplemented("sessionBranch"),
|
|
77
|
+
sessionSwitch: unimplemented("sessionSwitch"),
|
|
78
|
+
sessionLabel: unimplemented("sessionLabel"),
|
|
79
|
+
sessionFork: unimplemented("sessionFork"),
|
|
64
80
|
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
65
81
|
sessionSetShareId: mock(
|
|
66
82
|
async (_id: string, shareId: string) => ((session.shareId = shareId), session),
|
|
@@ -85,13 +101,14 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
85
101
|
async function connectedController(
|
|
86
102
|
session: ReviewSession,
|
|
87
103
|
clock?: ManualClock,
|
|
104
|
+
transport: ShareTransport = shareTransport,
|
|
88
105
|
): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeSessionClient }> {
|
|
89
106
|
const client = fakeClient(session);
|
|
90
107
|
const controller = createReviewController({
|
|
91
108
|
sessionId: session.id,
|
|
92
109
|
openClient: async () => client,
|
|
93
110
|
clock,
|
|
94
|
-
shareTransport,
|
|
111
|
+
shareTransport: transport,
|
|
95
112
|
});
|
|
96
113
|
|
|
97
114
|
controller.connect();
|
|
@@ -228,103 +245,226 @@ describe("mirror on annotate", () => {
|
|
|
228
245
|
});
|
|
229
246
|
});
|
|
230
247
|
|
|
231
|
-
describe("
|
|
232
|
-
test("
|
|
248
|
+
describe("reply", () => {
|
|
249
|
+
test("a reply shares the root's anchor, names it in replyTo, and mirrors up", async () => {
|
|
233
250
|
// Arrange
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const { controller } = await connectedController(
|
|
238
|
-
sessionFixture({ shareId: "p_abc123xy" }),
|
|
239
|
-
clock,
|
|
251
|
+
pushShare.mockClear();
|
|
252
|
+
const { controller, client } = await connectedController(
|
|
253
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:ana")] }),
|
|
240
254
|
);
|
|
241
255
|
|
|
242
|
-
// Act
|
|
243
|
-
const
|
|
244
|
-
|
|
256
|
+
// Act
|
|
257
|
+
const id = controller.reply("a1", "agreed");
|
|
245
258
|
await tick();
|
|
246
259
|
|
|
247
260
|
// Assert
|
|
248
|
-
|
|
261
|
+
const wire = client.sessionAnnotate.mock.calls.at(-1)![1];
|
|
262
|
+
|
|
263
|
+
expect(wire).toEqual({
|
|
264
|
+
id: id!,
|
|
265
|
+
kind: "comment",
|
|
266
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
267
|
+
body: "agreed",
|
|
268
|
+
replyTo: "a1",
|
|
269
|
+
});
|
|
270
|
+
expect(pushShare.mock.calls[0]?.[1]).toEqual([wire]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("editing a reply keeps its reply link", async () => {
|
|
274
|
+
// Arrange
|
|
275
|
+
const { controller, client } = await connectedController(
|
|
276
|
+
sessionFixture({
|
|
277
|
+
annotations: [
|
|
278
|
+
annotation("a1", "SHA256:ana"),
|
|
279
|
+
{ ...annotation("a2", "SHA256:me"), replyTo: "a1" },
|
|
280
|
+
],
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
249
283
|
|
|
250
|
-
// Act
|
|
251
|
-
|
|
284
|
+
// Act
|
|
285
|
+
controller.updateAnnotation("a2", "revised reply");
|
|
252
286
|
await tick();
|
|
253
287
|
|
|
254
288
|
// Assert
|
|
255
|
-
expect(
|
|
289
|
+
expect(client.sessionAnnotate.mock.calls.at(-1)?.[1]).toMatchObject({
|
|
290
|
+
id: "a2",
|
|
291
|
+
body: "revised reply",
|
|
292
|
+
replyTo: "a1",
|
|
293
|
+
});
|
|
294
|
+
});
|
|
256
295
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
296
|
+
test("a reply to a reply hangs off the discussion's root comment", async () => {
|
|
297
|
+
// Arrange
|
|
298
|
+
const { controller, client } = await connectedController(
|
|
299
|
+
sessionFixture({
|
|
300
|
+
annotations: [
|
|
301
|
+
annotation("a1", "SHA256:ana"),
|
|
302
|
+
{ ...annotation("a2", "SHA256:bob"), replyTo: "a1" },
|
|
303
|
+
],
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// Act
|
|
308
|
+
controller.reply("a2", "same thread");
|
|
260
309
|
await tick();
|
|
261
310
|
|
|
262
311
|
// Assert
|
|
263
|
-
expect(
|
|
312
|
+
expect(client.sessionAnnotate.mock.calls.at(-1)?.[1]).toMatchObject({ replyTo: "a1" });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("replying to an unknown annotation does nothing", async () => {
|
|
316
|
+
// Arrange
|
|
317
|
+
const { controller, client } = await connectedController(sessionFixture());
|
|
318
|
+
|
|
319
|
+
// Act + Assert
|
|
320
|
+
expect(controller.reply("missing", "x")).toBeUndefined();
|
|
321
|
+
expect(client.sessionAnnotate).not.toHaveBeenCalled();
|
|
264
322
|
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("startShareSync", () => {
|
|
326
|
+
/** A fake watch stream: records handlers so a test can push a session or drop the link. */
|
|
327
|
+
const streams: Array<{ shareId: string; handlers: ShareWatchHandlers; stopped: boolean }> = [];
|
|
328
|
+
const watchShare = mock((shareId: string, handlers: ShareWatchHandlers) => {
|
|
329
|
+
const stream = { shareId, handlers, stopped: false };
|
|
265
330
|
|
|
266
|
-
|
|
267
|
-
|
|
331
|
+
streams.push(stream);
|
|
332
|
+
|
|
333
|
+
return () => {
|
|
334
|
+
stream.stopped = true;
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
const liveTransport: ShareTransport = { ...shareTransport, watch: watchShare };
|
|
338
|
+
|
|
339
|
+
beforeEach(() => {
|
|
340
|
+
streams.length = 0;
|
|
341
|
+
watchShare.mockClear();
|
|
268
342
|
pullShare.mockClear();
|
|
269
343
|
remote = sessionFixture({ annotations: [] });
|
|
270
|
-
|
|
344
|
+
});
|
|
271
345
|
|
|
272
|
-
|
|
273
|
-
|
|
346
|
+
test("connect pulls once to catch up, then follows the stream and merges each session it delivers", async () => {
|
|
347
|
+
// Arrange
|
|
348
|
+
const clock = new ManualClock();
|
|
349
|
+
const { controller, client } = await connectedController(
|
|
350
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
351
|
+
clock,
|
|
352
|
+
liveTransport,
|
|
274
353
|
);
|
|
354
|
+
|
|
355
|
+
// Act
|
|
356
|
+
controller.startShareSync();
|
|
357
|
+
await tick();
|
|
358
|
+
|
|
359
|
+
// Assert - one catch-up pull, one open stream on the share
|
|
360
|
+
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
361
|
+
expect(streams).toHaveLength(1);
|
|
362
|
+
expect(streams[0]!.shareId).toBe("p_abc123xy");
|
|
363
|
+
|
|
364
|
+
// Act - the gateway pushes a change carrying Ana's note
|
|
365
|
+
streams[0]!.handlers.onSession(
|
|
366
|
+
sessionFixture({ annotations: [annotation("a9", "SHA256:ana")] }),
|
|
367
|
+
);
|
|
368
|
+
await tick();
|
|
369
|
+
|
|
370
|
+
// Assert - merged without another pull
|
|
371
|
+
expect(client.sessionMergeShared).toHaveBeenLastCalledWith(
|
|
372
|
+
"ses_1",
|
|
373
|
+
expect.objectContaining({
|
|
374
|
+
annotations: [expect.objectContaining({ id: "a9", author: "SHA256:ana" })],
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("a dropped stream reconnects with doubling backoff and catches up on each connect", async () => {
|
|
381
|
+
// Arrange
|
|
275
382
|
const clock = new ManualClock();
|
|
276
383
|
const { controller } = await connectedController(
|
|
277
384
|
sessionFixture({ shareId: "p_abc123xy" }),
|
|
278
385
|
clock,
|
|
386
|
+
liveTransport,
|
|
279
387
|
);
|
|
280
388
|
|
|
281
|
-
|
|
282
|
-
|
|
389
|
+
controller.startShareSync();
|
|
390
|
+
await tick();
|
|
283
391
|
|
|
392
|
+
// Act - the link drops: nothing until the floor delay has passed
|
|
393
|
+
streams[0]!.handlers.onClose("ssh exited 255");
|
|
394
|
+
clock.advance(SHARE_RECONNECT_MIN_MS - 1);
|
|
284
395
|
await tick();
|
|
285
|
-
expect(
|
|
286
|
-
|
|
287
|
-
release();
|
|
396
|
+
expect(streams).toHaveLength(1);
|
|
397
|
+
clock.advance(1);
|
|
288
398
|
await tick();
|
|
289
|
-
|
|
399
|
+
|
|
400
|
+
// Assert - reconnected, with a fresh catch-up pull
|
|
401
|
+
expect(streams).toHaveLength(2);
|
|
402
|
+
expect(pullShare).toHaveBeenCalledTimes(2);
|
|
403
|
+
|
|
404
|
+
// Act - drops again: the wait doubles
|
|
405
|
+
streams[1]!.handlers.onClose("ssh exited 255");
|
|
406
|
+
clock.advance(SHARE_RECONNECT_MIN_MS);
|
|
290
407
|
await tick();
|
|
408
|
+
expect(streams).toHaveLength(2);
|
|
409
|
+
clock.advance(SHARE_RECONNECT_MIN_MS);
|
|
410
|
+
await tick();
|
|
411
|
+
expect(streams).toHaveLength(3);
|
|
291
412
|
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
413
|
+
// Act - a delivered session resets the backoff to the floor
|
|
414
|
+
streams[2]!.handlers.onSession(remote);
|
|
415
|
+
streams[2]!.handlers.onClose("ssh exited 255");
|
|
416
|
+
clock.advance(SHARE_RECONNECT_MIN_MS);
|
|
417
|
+
await tick();
|
|
295
418
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
remote = sessionFixture({ annotations: [] });
|
|
300
|
-
let release: () => void = () => {};
|
|
419
|
+
// Assert
|
|
420
|
+
expect(streams).toHaveLength(4);
|
|
421
|
+
});
|
|
301
422
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
);
|
|
423
|
+
test("stop closes the stream and a late close never reconnects", async () => {
|
|
424
|
+
// Arrange
|
|
305
425
|
const clock = new ManualClock();
|
|
306
426
|
const { controller } = await connectedController(
|
|
307
427
|
sessionFixture({ shareId: "p_abc123xy" }),
|
|
308
428
|
clock,
|
|
429
|
+
liveTransport,
|
|
309
430
|
);
|
|
431
|
+
const stop = controller.startShareSync();
|
|
310
432
|
|
|
311
|
-
// Act - run A starts (pull hangs), then run B restarts while A is in flight
|
|
312
|
-
controller.startSharePoll();
|
|
313
433
|
await tick();
|
|
314
|
-
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
315
|
-
const stopB = controller.startSharePoll();
|
|
316
434
|
|
|
435
|
+
// Act
|
|
436
|
+
stop();
|
|
437
|
+
streams[0]!.handlers.onClose("killed");
|
|
438
|
+
clock.advance(SHARE_RECONNECT_MAX_MS * 2);
|
|
317
439
|
await tick();
|
|
318
|
-
expect(pullShare).toHaveBeenCalledTimes(2);
|
|
319
440
|
|
|
320
|
-
//
|
|
321
|
-
|
|
441
|
+
// Assert
|
|
442
|
+
expect(streams[0]!.stopped).toBe(true);
|
|
443
|
+
expect(streams).toHaveLength(1);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("a restart replaces the stream instead of doubling it", async () => {
|
|
447
|
+
// Arrange
|
|
448
|
+
const clock = new ManualClock();
|
|
449
|
+
const { controller } = await connectedController(
|
|
450
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
451
|
+
clock,
|
|
452
|
+
liveTransport,
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
controller.startShareSync();
|
|
456
|
+
await tick();
|
|
457
|
+
|
|
458
|
+
// Act
|
|
459
|
+
controller.startShareSync();
|
|
322
460
|
await tick();
|
|
323
|
-
|
|
461
|
+
streams[0]!.handlers.onClose("stale");
|
|
462
|
+
clock.advance(SHARE_RECONNECT_MAX_MS * 2);
|
|
324
463
|
await tick();
|
|
325
464
|
|
|
326
|
-
// Assert -
|
|
327
|
-
expect(
|
|
328
|
-
|
|
465
|
+
// Assert - the first stream was stopped and its close re-armed nothing
|
|
466
|
+
expect(streams[0]!.stopped).toBe(true);
|
|
467
|
+
expect(streams).toHaveLength(2);
|
|
468
|
+
expect(streams[1]!.stopped).toBe(false);
|
|
329
469
|
});
|
|
330
470
|
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The controller's tree primitives over a fake client: a move shows its path
|
|
3
|
+
* before the daemon answers, refusals surface as status, a fork opens the fork,
|
|
4
|
+
* and fork-and-share shares the fork while the current session stays put.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
appendEntry,
|
|
10
|
+
createBranch,
|
|
11
|
+
historyFromLinear,
|
|
12
|
+
SCHEMA_VERSION,
|
|
13
|
+
switchBranch,
|
|
14
|
+
type Annotation,
|
|
15
|
+
type ReviewSession,
|
|
16
|
+
} from "@cueloop/schema";
|
|
17
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
18
|
+
import { createReviewController, type ShareTransport } from "./session-controller";
|
|
19
|
+
import { mergeFromShare } from "./share";
|
|
20
|
+
|
|
21
|
+
const AT = "2026-01-01T00:00:00.000Z";
|
|
22
|
+
|
|
23
|
+
function annotation(id: string, body: string): Annotation {
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
kind: "comment",
|
|
27
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
28
|
+
body,
|
|
29
|
+
createdAt: AT,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A plan with one comment on main and its history. */
|
|
34
|
+
function sessionFixture(): ReviewSession {
|
|
35
|
+
const session: ReviewSession = {
|
|
36
|
+
schemaVersion: SCHEMA_VERSION,
|
|
37
|
+
id: "ses_1",
|
|
38
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
40
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: AT }],
|
|
41
|
+
annotations: [annotation("a1", "first")],
|
|
42
|
+
verdict: null,
|
|
43
|
+
status: "pending",
|
|
44
|
+
createdAt: AT,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
session.history = historyFromLinear(session);
|
|
48
|
+
|
|
49
|
+
return session;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const publish = mock(async (session: ReviewSession) => ({
|
|
53
|
+
line: `ssh p_${session.id}@cueloop.dev`,
|
|
54
|
+
copied: true,
|
|
55
|
+
}));
|
|
56
|
+
const shareTransport: ShareTransport = {
|
|
57
|
+
publish,
|
|
58
|
+
pull: mock(async () => sessionFixture()),
|
|
59
|
+
push: mock(async () => {}),
|
|
60
|
+
watch: () => () => {},
|
|
61
|
+
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
62
|
+
collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
|
|
63
|
+
mergeFromShare,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const unimplemented = (member: string) => () =>
|
|
67
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
68
|
+
|
|
69
|
+
/** Tree requests never resolve here: what the controller shows is its own guess. */
|
|
70
|
+
const pending = () => new Promise<ReviewSession>(() => {});
|
|
71
|
+
|
|
72
|
+
function fakeClient(session: ReviewSession) {
|
|
73
|
+
return {
|
|
74
|
+
onEvent: () => () => {},
|
|
75
|
+
subscribe: async () => {},
|
|
76
|
+
sessionGet: async () => session,
|
|
77
|
+
sessionList: async () => [session],
|
|
78
|
+
sessionAnnotate: unimplemented("sessionAnnotate"),
|
|
79
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
80
|
+
sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
|
|
81
|
+
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
82
|
+
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
83
|
+
sessionCurate: unimplemented("sessionCurate"),
|
|
84
|
+
sessionNavigate: mock(pending),
|
|
85
|
+
sessionBranch: mock(pending),
|
|
86
|
+
sessionSwitch: mock(pending),
|
|
87
|
+
sessionLabel: mock(pending),
|
|
88
|
+
sessionFork: mock(async (id: string) => ({
|
|
89
|
+
...sessionFixture(),
|
|
90
|
+
id: `${id}_fork`,
|
|
91
|
+
parentSessionId: id,
|
|
92
|
+
})),
|
|
93
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
94
|
+
sessionSetShareId: mock(async (_id: string, _shareId: string) => session),
|
|
95
|
+
sessionMergeShared: unimplemented("sessionMergeShared"),
|
|
96
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
97
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
98
|
+
sessionResolve: unimplemented("sessionResolve"),
|
|
99
|
+
close: () => {},
|
|
100
|
+
} satisfies SessionClient;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
104
|
+
|
|
105
|
+
async function connected(session = sessionFixture()) {
|
|
106
|
+
const client = fakeClient(session);
|
|
107
|
+
const controller = createReviewController({
|
|
108
|
+
sessionId: session.id,
|
|
109
|
+
openClient: async () => client,
|
|
110
|
+
shareTransport,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
controller.connect();
|
|
114
|
+
await tick();
|
|
115
|
+
|
|
116
|
+
return { controller, client, session };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
describe("tree primitives", () => {
|
|
120
|
+
test("a move back shows the earlier path at once and asks the daemon for the same move", async () => {
|
|
121
|
+
// Arrange
|
|
122
|
+
const { controller, client, session } = await connected();
|
|
123
|
+
const revision = session.history!.entries[0]!.id;
|
|
124
|
+
|
|
125
|
+
// Act
|
|
126
|
+
controller.goToEntry(revision, "too soon");
|
|
127
|
+
|
|
128
|
+
// Assert: the comment is off the shown path before any answer; the summary travels
|
|
129
|
+
expect(controller.getSnapshot().session!.annotations).toEqual([]);
|
|
130
|
+
expect(controller.getSnapshot().session!.shelvedAnnotations?.map((entry) => entry.id)).toEqual([
|
|
131
|
+
"a1",
|
|
132
|
+
]);
|
|
133
|
+
expect(controller.treeRows().find((row) => row.glyph === "↩")?.isCurrentTip).toBe(true);
|
|
134
|
+
expect(client.sessionNavigate).toHaveBeenCalledWith("ses_1", revision, "too soon", undefined);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a move on another branch is one request that names the branch", async () => {
|
|
138
|
+
// Arrange: alt with its own comment entry; the owner stands on main
|
|
139
|
+
const session = sessionFixture();
|
|
140
|
+
let history = createBranch(session.history!, "alt");
|
|
141
|
+
|
|
142
|
+
history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
|
|
143
|
+
history = appendEntry(history, { type: "comment", annotationId: "b2", createdAt: AT }).history;
|
|
144
|
+
session.history = switchBranch(history, "main");
|
|
145
|
+
session.shelvedAnnotations = [annotation("b1", "alt one"), annotation("b2", "alt two")];
|
|
146
|
+
const { controller, client } = await connected(session);
|
|
147
|
+
const altFirst = history.entries.find(
|
|
148
|
+
(entry) => entry.type === "comment" && entry.annotationId === "b1",
|
|
149
|
+
)!;
|
|
150
|
+
|
|
151
|
+
// Act
|
|
152
|
+
controller.goToEntry(altFirst.id);
|
|
153
|
+
|
|
154
|
+
// Assert
|
|
155
|
+
expect(client.sessionSwitch).not.toHaveBeenCalled();
|
|
156
|
+
expect(client.sessionNavigate).toHaveBeenCalledWith("ses_1", altFirst.id, undefined, "alt");
|
|
157
|
+
expect(controller.getSnapshot().session!.annotations.map((entry) => entry.id)).toEqual([
|
|
158
|
+
"a1",
|
|
159
|
+
"b1",
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a refused move gives way to the daemon's record", async () => {
|
|
164
|
+
// Arrange: the daemon refuses; a re-read hands back the untouched record
|
|
165
|
+
const session = sessionFixture();
|
|
166
|
+
const { controller, client } = await connected(session);
|
|
167
|
+
|
|
168
|
+
client.sessionNavigate.mockImplementationOnce(() => Promise.reject(new Error("resolved")));
|
|
169
|
+
|
|
170
|
+
// Act
|
|
171
|
+
controller.goToEntry(session.history!.entries[0]!.id);
|
|
172
|
+
const guessed = controller.getSnapshot().session!.annotations.length;
|
|
173
|
+
|
|
174
|
+
await tick();
|
|
175
|
+
|
|
176
|
+
// Assert: the guess showed, then the record came back
|
|
177
|
+
expect(guessed).toBe(0);
|
|
178
|
+
expect(controller.getSnapshot().session!.annotations.map((entry) => entry.id)).toEqual(["a1"]);
|
|
179
|
+
expect(controller.getSnapshot().status).toBe("resolved");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("branch and label refuse an empty name and a taken branch name", async () => {
|
|
183
|
+
// Arrange
|
|
184
|
+
const { controller, client } = await connected();
|
|
185
|
+
|
|
186
|
+
// Act
|
|
187
|
+
controller.branch(" ");
|
|
188
|
+
controller.branch("main");
|
|
189
|
+
controller.labelTip("");
|
|
190
|
+
|
|
191
|
+
// Assert
|
|
192
|
+
expect(client.sessionBranch).not.toHaveBeenCalled();
|
|
193
|
+
expect(client.sessionLabel).not.toHaveBeenCalled();
|
|
194
|
+
expect(controller.getSnapshot().status).toBe("a checkpoint needs a name");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("a branch shows up on the tip and becomes the current branch", async () => {
|
|
198
|
+
// Arrange
|
|
199
|
+
const { controller, client } = await connected();
|
|
200
|
+
|
|
201
|
+
// Act
|
|
202
|
+
controller.branch("alt");
|
|
203
|
+
|
|
204
|
+
// Assert
|
|
205
|
+
expect(controller.getSnapshot().session!.history!.branch).toBe("alt");
|
|
206
|
+
expect(controller.treeRows().find((row) => row.isCurrentTip)?.tips).toEqual(["main", "alt"]);
|
|
207
|
+
expect(client.sessionBranch).toHaveBeenCalledWith("ses_1", "alt");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("fork opens the fork; fork-and-share shares the fork and stays on the original", async () => {
|
|
211
|
+
// Arrange
|
|
212
|
+
const forked = await connected();
|
|
213
|
+
const shared = await connected();
|
|
214
|
+
|
|
215
|
+
// Act
|
|
216
|
+
forked.controller.fork();
|
|
217
|
+
shared.controller.forkAndShare();
|
|
218
|
+
await tick();
|
|
219
|
+
await tick();
|
|
220
|
+
|
|
221
|
+
// Assert
|
|
222
|
+
expect(forked.controller.getSnapshot().session!.id).toBe("ses_1_fork");
|
|
223
|
+
expect(shared.controller.getSnapshot().session!.id).toBe("ses_1");
|
|
224
|
+
expect(publish).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_1_fork" }));
|
|
225
|
+
expect(shared.client.sessionSetShareId).toHaveBeenCalledWith("ses_1_fork", "p_ses_1_fork");
|
|
226
|
+
expect(shared.controller.getSnapshot().toast?.title).toBe("fork shared - link copied");
|
|
227
|
+
});
|
|
228
|
+
});
|