@cueloop/client 0.1.0-alpha.63 → 0.1.0-alpha.65
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 +7 -6
- package/src/App.autoclose.test.tsx +2 -2
- package/src/App.export.test.tsx +2 -2
- package/src/App.plan-review.test.tsx +76 -247
- package/src/App.readonly.test.tsx +80 -35
- package/src/App.rendering.test.tsx +5 -6
- package/src/App.review-panel.test.tsx +29 -32
- package/src/App.submit-confirm.test.tsx +27 -12
- package/src/App.test.tsx +73 -49
- package/src/App.toast.test.tsx +34 -22
- package/src/App.tree.test.tsx +224 -0
- package/src/App.tsx +134 -87
- package/src/app-screens.tsx +27 -1
- package/src/app-view-model.ts +7 -109
- package/src/components/AnnotationCard.tsx +2 -2
- package/src/components/DiffSheet.scroll.test.tsx +5 -2
- package/src/components/DiffSheet.tsx +1 -6
- package/src/components/KeybindsDialog.tsx +61 -17
- package/src/components/PrototypeSheet.tsx +5 -9
- package/src/components/ReviewPanel.stories.tsx +14 -0
- package/src/components/ReviewRail.stories.tsx +14 -0
- package/src/components/ReviewRail.tsx +15 -6
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +1620 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +156 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
- package/src/components/primitives/Tabs.tsx +1 -1
- package/src/components/story-fixtures.ts +6 -2
- package/src/components/story.ts +16 -4
- package/src/components/syntax-highlight.ts +10 -2
- package/src/components/terminal-pane.test.tsx +1 -1
- package/src/components/terminal-pane.ts +7 -1
- package/src/config.ts +112 -99
- package/src/diff-hunk-curate.ts +4 -104
- package/src/editor.ts +5 -1
- package/src/ghostty-terminal.test.ts +1 -1
- package/src/herdr.ts +10 -2
- package/src/index.ts +1 -0
- package/src/intent-dispatch.test.ts +235 -55
- package/src/intent-dispatch.ts +104 -34
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +16 -7
- package/src/keymap.test.ts +9 -11
- package/src/keymap.ts +14 -7
- package/src/kitty-image.test.ts +3 -3
- package/src/prototype-browser.ts +7 -6
- package/src/serve.ts +1 -1
- package/src/session-controller.curate.test.ts +106 -4
- package/src/session-controller.share.test.ts +220 -70
- package/src/session-controller.tree.test.ts +228 -0
- package/src/session-controller.ts +370 -94
- package/src/share.ts +117 -17
- package/src/test-support.test.ts +193 -1
- package/src/test-support.ts +69 -1
- package/src/theme-presets.ts +9 -2
- 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/view-diff.ts +1 -4
- package/src/view-plan.ts +35 -19
- package/src/components/PlanSheet.stories.tsx +0 -113
- package/src/components/PlanSheet.tsx +0 -422
- package/src/components/plan-sheet-run-style.ts +0 -90
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import { describe, expect, mock, test } 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 {
|
|
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";
|
|
5
12
|
|
|
6
|
-
// Stub the ssh transport so no subprocess spawns; the controller under test
|
|
7
|
-
// imports these from "./share".
|
|
8
13
|
const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
9
14
|
let remote: ReviewSession;
|
|
10
15
|
const pullShare = mock(async () => remote);
|
|
@@ -12,16 +17,15 @@ const pushShare = mock(
|
|
|
12
17
|
async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
|
|
13
18
|
);
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
publishShare,
|
|
17
|
-
pullShare,
|
|
18
|
-
pushShare,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
|
|
20
|
+
const shareTransport: ShareTransport = {
|
|
21
|
+
publish: publishShare,
|
|
22
|
+
pull: pullShare,
|
|
23
|
+
push: pushShare,
|
|
24
|
+
watch: () => () => {},
|
|
25
|
+
parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
|
|
26
|
+
collaboratorAnnotations: (session) => session.annotations.filter((entry) => entry.author),
|
|
27
|
+
mergeFromShare,
|
|
28
|
+
};
|
|
25
29
|
|
|
26
30
|
function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
27
31
|
return {
|
|
@@ -49,13 +53,31 @@ function annotation(id: string, author: string): Annotation {
|
|
|
49
53
|
};
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
interface FakeSessionClient extends SessionClient {
|
|
57
|
+
sessionAnnotate: Mock<SessionClient["sessionAnnotate"]>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const unimplemented = (member: string) => () =>
|
|
61
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
62
|
+
|
|
63
|
+
function fakeClient(session: ReviewSession): FakeSessionClient {
|
|
53
64
|
return {
|
|
54
65
|
onEvent: () => () => {},
|
|
55
66
|
subscribe: async () => {},
|
|
56
67
|
sessionGet: async () => session,
|
|
57
68
|
sessionList: async () => [session],
|
|
58
|
-
sessionAnnotate: mock(async () => session),
|
|
69
|
+
sessionAnnotate: mock<SessionClient["sessionAnnotate"]>(async () => session),
|
|
70
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
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"),
|
|
80
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
59
81
|
sessionSetShareId: mock(
|
|
60
82
|
async (_id: string, shareId: string) => ((session.shareId = shareId), session),
|
|
61
83
|
),
|
|
@@ -67,8 +89,11 @@ function fakeClient(session: ReviewSession): SessionClient {
|
|
|
67
89
|
|
|
68
90
|
return session;
|
|
69
91
|
}),
|
|
92
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
93
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
94
|
+
sessionResolve: unimplemented("sessionResolve"),
|
|
70
95
|
close: () => {},
|
|
71
|
-
}
|
|
96
|
+
};
|
|
72
97
|
}
|
|
73
98
|
|
|
74
99
|
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -76,12 +101,14 @@ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
76
101
|
async function connectedController(
|
|
77
102
|
session: ReviewSession,
|
|
78
103
|
clock?: ManualClock,
|
|
79
|
-
|
|
104
|
+
transport: ShareTransport = shareTransport,
|
|
105
|
+
): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeSessionClient }> {
|
|
80
106
|
const client = fakeClient(session);
|
|
81
107
|
const controller = createReviewController({
|
|
82
108
|
sessionId: session.id,
|
|
83
109
|
openClient: async () => client,
|
|
84
110
|
clock,
|
|
111
|
+
shareTransport: transport,
|
|
85
112
|
});
|
|
86
113
|
|
|
87
114
|
controller.connect();
|
|
@@ -205,7 +232,7 @@ describe("mirror on annotate", () => {
|
|
|
205
232
|
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
|
|
206
233
|
);
|
|
207
234
|
|
|
208
|
-
|
|
235
|
+
client.sessionAnnotate.mockImplementationOnce(async () => {
|
|
209
236
|
throw new Error("session is resolved");
|
|
210
237
|
});
|
|
211
238
|
|
|
@@ -218,103 +245,226 @@ describe("mirror on annotate", () => {
|
|
|
218
245
|
});
|
|
219
246
|
});
|
|
220
247
|
|
|
221
|
-
describe("
|
|
222
|
-
test("
|
|
248
|
+
describe("reply", () => {
|
|
249
|
+
test("a reply shares the root's anchor, names it in replyTo, and mirrors up", async () => {
|
|
223
250
|
// Arrange
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const { controller } = await connectedController(
|
|
228
|
-
sessionFixture({ shareId: "p_abc123xy" }),
|
|
229
|
-
clock,
|
|
251
|
+
pushShare.mockClear();
|
|
252
|
+
const { controller, client } = await connectedController(
|
|
253
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:ana")] }),
|
|
230
254
|
);
|
|
231
255
|
|
|
232
|
-
// Act
|
|
233
|
-
const
|
|
234
|
-
|
|
256
|
+
// Act
|
|
257
|
+
const id = controller.reply("a1", "agreed");
|
|
235
258
|
await tick();
|
|
236
259
|
|
|
237
260
|
// Assert
|
|
238
|
-
|
|
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
|
+
});
|
|
239
272
|
|
|
240
|
-
|
|
241
|
-
|
|
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
|
+
);
|
|
283
|
+
|
|
284
|
+
// Act
|
|
285
|
+
controller.updateAnnotation("a2", "revised reply");
|
|
242
286
|
await tick();
|
|
243
287
|
|
|
244
288
|
// Assert
|
|
245
|
-
expect(
|
|
289
|
+
expect(client.sessionAnnotate.mock.calls.at(-1)?.[1]).toMatchObject({
|
|
290
|
+
id: "a2",
|
|
291
|
+
body: "revised reply",
|
|
292
|
+
replyTo: "a1",
|
|
293
|
+
});
|
|
294
|
+
});
|
|
246
295
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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");
|
|
250
309
|
await tick();
|
|
251
310
|
|
|
252
311
|
// Assert
|
|
253
|
-
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();
|
|
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 };
|
|
330
|
+
|
|
331
|
+
streams.push(stream);
|
|
332
|
+
|
|
333
|
+
return () => {
|
|
334
|
+
stream.stopped = true;
|
|
335
|
+
};
|
|
254
336
|
});
|
|
337
|
+
const liveTransport: ShareTransport = { ...shareTransport, watch: watchShare };
|
|
255
338
|
|
|
256
|
-
|
|
257
|
-
|
|
339
|
+
beforeEach(() => {
|
|
340
|
+
streams.length = 0;
|
|
341
|
+
watchShare.mockClear();
|
|
258
342
|
pullShare.mockClear();
|
|
259
343
|
remote = sessionFixture({ annotations: [] });
|
|
260
|
-
|
|
344
|
+
});
|
|
345
|
+
|
|
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,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
// Act
|
|
356
|
+
controller.startShareSync();
|
|
357
|
+
await tick();
|
|
261
358
|
|
|
262
|
-
|
|
263
|
-
|
|
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
|
+
}),
|
|
264
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
|
|
265
382
|
const clock = new ManualClock();
|
|
266
383
|
const { controller } = await connectedController(
|
|
267
384
|
sessionFixture({ shareId: "p_abc123xy" }),
|
|
268
385
|
clock,
|
|
386
|
+
liveTransport,
|
|
269
387
|
);
|
|
270
388
|
|
|
271
|
-
|
|
272
|
-
|
|
389
|
+
controller.startShareSync();
|
|
390
|
+
await tick();
|
|
273
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);
|
|
274
395
|
await tick();
|
|
275
|
-
expect(
|
|
276
|
-
|
|
277
|
-
release();
|
|
396
|
+
expect(streams).toHaveLength(1);
|
|
397
|
+
clock.advance(1);
|
|
278
398
|
await tick();
|
|
279
|
-
|
|
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);
|
|
280
407
|
await tick();
|
|
408
|
+
expect(streams).toHaveLength(2);
|
|
409
|
+
clock.advance(SHARE_RECONNECT_MIN_MS);
|
|
410
|
+
await tick();
|
|
411
|
+
expect(streams).toHaveLength(3);
|
|
281
412
|
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
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();
|
|
285
418
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
remote = sessionFixture({ annotations: [] });
|
|
290
|
-
let release: () => void = () => {};
|
|
419
|
+
// Assert
|
|
420
|
+
expect(streams).toHaveLength(4);
|
|
421
|
+
});
|
|
291
422
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
);
|
|
423
|
+
test("stop closes the stream and a late close never reconnects", async () => {
|
|
424
|
+
// Arrange
|
|
295
425
|
const clock = new ManualClock();
|
|
296
426
|
const { controller } = await connectedController(
|
|
297
427
|
sessionFixture({ shareId: "p_abc123xy" }),
|
|
298
428
|
clock,
|
|
429
|
+
liveTransport,
|
|
299
430
|
);
|
|
431
|
+
const stop = controller.startShareSync();
|
|
300
432
|
|
|
301
|
-
// Act - run A starts (pull hangs), then run B restarts while A is in flight
|
|
302
|
-
controller.startSharePoll();
|
|
303
433
|
await tick();
|
|
304
|
-
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
305
|
-
const stopB = controller.startSharePoll();
|
|
306
434
|
|
|
435
|
+
// Act
|
|
436
|
+
stop();
|
|
437
|
+
streams[0]!.handlers.onClose("killed");
|
|
438
|
+
clock.advance(SHARE_RECONNECT_MAX_MS * 2);
|
|
307
439
|
await tick();
|
|
308
|
-
expect(pullShare).toHaveBeenCalledTimes(2);
|
|
309
440
|
|
|
310
|
-
//
|
|
311
|
-
|
|
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();
|
|
312
460
|
await tick();
|
|
313
|
-
|
|
461
|
+
streams[0]!.handlers.onClose("stale");
|
|
462
|
+
clock.advance(SHARE_RECONNECT_MAX_MS * 2);
|
|
314
463
|
await tick();
|
|
315
464
|
|
|
316
|
-
// Assert -
|
|
317
|
-
expect(
|
|
318
|
-
|
|
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);
|
|
319
469
|
});
|
|
320
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
|
+
});
|