@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70
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/native/build-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +39 -32
- package/src/App.appearance.test.tsx +53 -0
- package/src/App.autoclose.test.tsx +76 -50
- package/src/App.diff.test.tsx +156 -42
- package/src/App.export.test.tsx +58 -40
- package/src/App.plan-review.test.tsx +363 -0
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +176 -0
- package/src/App.readonly.test.tsx +128 -67
- package/src/App.rendering.test.tsx +61 -35
- package/src/App.submit-confirm.test.tsx +191 -0
- package/src/App.test.tsx +310 -114
- package/src/App.toast.test.tsx +89 -0
- package/src/App.tsx +1039 -799
- package/src/App.walk.test.tsx +298 -0
- package/src/App.workbench.test.tsx +461 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/annotation-palette.ts +61 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +432 -0
- package/src/app-view-model.ts +159 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.stories.tsx +87 -0
- package/src/components/AnnotationCards.tsx +303 -0
- package/src/components/AppHeader.stories.tsx +50 -0
- package/src/components/AppHeader.tsx +83 -0
- package/src/components/AppShell.stories.tsx +89 -0
- package/src/components/AppShell.tsx +286 -0
- package/src/components/AppShell.zoom.test.tsx +94 -0
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +64 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/ChangesColumn.stories.tsx +39 -0
- package/src/components/ChangesColumn.tsx +171 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +128 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffContentView.scroll.test.tsx +121 -0
- package/src/components/DiffContentView.split.test.tsx +140 -0
- package/src/components/DiffContentView.stories.tsx +80 -0
- package/src/components/DiffContentView.syntax.test.tsx +82 -0
- package/src/components/DiffContentView.tsx +859 -0
- package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
- package/src/components/DiscussionMarkerRail.tsx +181 -0
- package/src/components/EditorGrid.stories.tsx +59 -0
- package/src/components/EditorGrid.tabs.test.tsx +118 -0
- package/src/components/EditorGrid.tsx +368 -0
- package/src/components/EditorGrid.zoom.test.tsx +68 -0
- package/src/components/FileContentsView.stories.tsx +16 -0
- package/src/components/FileContentsView.test.tsx +29 -0
- package/src/components/FileContentsView.tsx +98 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +103 -0
- package/src/components/MenuBar.stories.tsx +28 -0
- package/src/components/MenuBar.tsx +44 -0
- package/src/components/OverlayScrollbar.stories.tsx +33 -0
- package/src/components/OverlayScrollbar.test.tsx +139 -0
- package/src/components/OverlayScrollbar.tsx +129 -0
- package/src/components/PaneGrid.stories.tsx +84 -0
- package/src/components/PaneGrid.tsx +128 -0
- package/src/components/PanelColumn.stories.tsx +25 -0
- package/src/components/PanelColumn.tsx +95 -0
- package/src/components/ProjectTreeView.stories.tsx +25 -0
- package/src/components/ProjectTreeView.test.tsx +32 -0
- package/src/components/ProjectTreeView.tsx +83 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +87 -0
- package/src/components/PrototypeContentView.stories.tsx +19 -0
- package/src/components/PrototypeContentView.tsx +446 -0
- package/src/components/RootOverlay.stories.tsx +44 -0
- package/src/components/RootOverlay.tsx +35 -0
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.stories.tsx +117 -0
- package/src/components/SettingsDialog.tsx +195 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- 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.test.tsx +31 -0
- package/src/components/ThreadFooter.tsx +54 -0
- package/src/components/ThreadTree.stories.tsx +46 -0
- package/src/components/ThreadTree.tsx +219 -0
- package/src/components/ThreadView.selection.test.tsx +812 -0
- package/src/components/ThreadView.slash.test.tsx +158 -0
- package/src/components/ThreadView.stories.tsx +26 -0
- package/src/components/ThreadView.tsx +330 -0
- package/src/components/ThreadsSidebar.stories.tsx +36 -0
- package/src/components/ThreadsSidebar.tsx +71 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/Tooltip.stories.tsx +26 -0
- package/src/components/Tooltip.tsx +72 -0
- package/src/components/TreePane.stories.tsx +106 -0
- package/src/components/TreePane.tsx +157 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-content-view-layout.test.ts +198 -0
- package/src/components/diff-content-view-layout.ts +84 -0
- package/src/components/editor-grid.test.ts +86 -0
- package/src/components/editor-grid.ts +138 -0
- package/src/components/fade-title.test.ts +49 -0
- package/src/components/fade-title.ts +61 -0
- package/src/components/file-tree.test.ts +60 -0
- package/src/components/file-tree.ts +112 -0
- package/src/components/pages/Workspace.stories.tsx +181 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/IconButton.stories.tsx +36 -0
- package/src/components/primitives/IconButton.tsx +62 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/Tree.stories.tsx +124 -0
- package/src/components/primitives/Tree.tsx +96 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/primitives/icons.ts +61 -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 +54 -0
- package/src/components/quick-actions-editor.test.tsx +49 -0
- package/src/components/quick-actions-editor.tsx +150 -0
- package/src/components/session-tree.test.ts +134 -0
- package/src/components/session-tree.ts +108 -0
- package/src/components/stories-app.tsx +184 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +102 -0
- package/src/components/story-tree.test.ts +100 -0
- package/src/components/story.ts +142 -0
- package/src/components/syntax-highlight.ts +110 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +199 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +461 -31
- package/src/config.ts +389 -57
- package/src/diff-fold.test.ts +78 -0
- package/src/diff-fold.ts +126 -0
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +153 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +112 -0
- package/src/diff-syntax.ts +179 -0
- package/src/discussions.ts +79 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +139 -9
- package/src/ghostty-terminal.test.ts +85 -0
- package/src/ghostty-terminal.ts +199 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +25 -21
- package/src/index.ts +11 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +698 -0
- package/src/intent-dispatch.ts +700 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +465 -0
- package/src/keymap.test.ts +611 -0
- package/src/keymap.ts +410 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/mark-runs.ts +120 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +307 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +245 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/run.ts +30 -9
- package/src/serve.test.ts +24 -12
- package/src/serve.ts +20 -9
- package/src/share.ts +215 -0
- package/src/skills.test.ts +74 -0
- package/src/skills.ts +68 -0
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +104 -0
- package/src/split-diff.test.ts +110 -0
- package/src/split-diff.ts +149 -0
- package/src/test-support.test.ts +264 -0
- package/src/test-support.ts +355 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +214 -0
- package/src/theme.ts +84 -19
- package/src/thread-chords.test.ts +170 -0
- package/src/thread-chords.ts +169 -0
- package/src/thread-controller.curate.test.ts +438 -0
- package/src/thread-controller.live-diff.test.ts +199 -0
- package/src/thread-controller.share.test.ts +472 -0
- package/src/thread-controller.tree.test.ts +255 -0
- package/src/thread-controller.ts +1509 -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-annotation-surface.tsx +851 -0
- package/src/use-changes-workbench.ts +151 -0
- package/src/use-frame-measure.ts +38 -0
- package/src/use-settings-dialog.tsx +274 -0
- package/src/use-terminal-virtualizer.ts +233 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +66 -9
- package/src/view-diff.ts +231 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +570 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -312
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test, type Mock } from "bun:test";
|
|
2
|
+
import { ManualClock } from "@opentui/core/testing";
|
|
3
|
+
import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
|
|
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 "./thread-controller";
|
|
11
|
+
import { mergeFromShare, type ShareWatchHandlers } from "./share";
|
|
12
|
+
|
|
13
|
+
const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
14
|
+
let remote: Thread;
|
|
15
|
+
const pullShare = mock(async () => remote);
|
|
16
|
+
const pushShare = mock(
|
|
17
|
+
async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
|
|
18
|
+
);
|
|
19
|
+
|
|
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
|
+
};
|
|
29
|
+
|
|
30
|
+
function sessionFixture(overrides: Partial<Thread> = {}): Thread {
|
|
31
|
+
return {
|
|
32
|
+
schemaVersion: SCHEMA_VERSION,
|
|
33
|
+
id: "ses_1",
|
|
34
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
35
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
36
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
37
|
+
annotations: [],
|
|
38
|
+
verdict: null,
|
|
39
|
+
status: "pending",
|
|
40
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function annotation(id: string, author: string): Annotation {
|
|
46
|
+
return {
|
|
47
|
+
id,
|
|
48
|
+
kind: "comment",
|
|
49
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
50
|
+
body: "note",
|
|
51
|
+
author,
|
|
52
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface FakeSessionClient extends SessionClient {
|
|
57
|
+
sessionComment: Mock<SessionClient["sessionComment"]>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const unimplemented = (member: string) => () =>
|
|
61
|
+
Promise.reject(new Error(`fakeClient does not implement ${member}`));
|
|
62
|
+
|
|
63
|
+
function fakeClient(session: Thread): FakeSessionClient {
|
|
64
|
+
return {
|
|
65
|
+
onEvent: () => () => {},
|
|
66
|
+
subscribe: async () => {},
|
|
67
|
+
sessionGet: async () => session,
|
|
68
|
+
sessionList: async () => [session],
|
|
69
|
+
sessionComment: mock<SessionClient["sessionComment"]>(async () => session),
|
|
70
|
+
sessionAnnotate: unimplemented("sessionAnnotate"),
|
|
71
|
+
sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
|
|
72
|
+
sessionSetWorkingCopy: unimplemented("sessionSetWorkingCopy"),
|
|
73
|
+
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
74
|
+
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
75
|
+
sessionCurate: unimplemented("sessionCurate"),
|
|
76
|
+
sessionNavigate: unimplemented("sessionNavigate"),
|
|
77
|
+
sessionBranch: unimplemented("sessionBranch"),
|
|
78
|
+
sessionSwitch: unimplemented("sessionSwitch"),
|
|
79
|
+
sessionLabel: unimplemented("sessionLabel"),
|
|
80
|
+
sessionFork: unimplemented("sessionFork"),
|
|
81
|
+
sessionSetViewed: unimplemented("sessionSetViewed"),
|
|
82
|
+
sessionSetTitle: unimplemented("sessionSetTitle"),
|
|
83
|
+
sessionSetShareId: mock(
|
|
84
|
+
async (_id: string, shareId: string) => ((session.shareId = shareId), session),
|
|
85
|
+
),
|
|
86
|
+
sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
|
|
87
|
+
const known = new Set(session.annotations.map((existing) => existing.id));
|
|
88
|
+
|
|
89
|
+
for (const note of incoming.annotations)
|
|
90
|
+
if (!known.has(note.id)) session.annotations.push(note);
|
|
91
|
+
|
|
92
|
+
return session;
|
|
93
|
+
}),
|
|
94
|
+
sessionDelete: unimplemented("sessionDelete"),
|
|
95
|
+
sessionSetSelfName: unimplemented("sessionSetSelfName"),
|
|
96
|
+
sessionResolve: unimplemented("sessionResolve"),
|
|
97
|
+
close: () => {},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
102
|
+
|
|
103
|
+
async function connectedController(
|
|
104
|
+
session: Thread,
|
|
105
|
+
clock?: ManualClock,
|
|
106
|
+
transport: ShareTransport = shareTransport,
|
|
107
|
+
): Promise<{ controller: ReturnType<typeof createReviewController>; client: FakeSessionClient }> {
|
|
108
|
+
const client = fakeClient(session);
|
|
109
|
+
const controller = createReviewController({
|
|
110
|
+
sessionId: session.id,
|
|
111
|
+
openClient: async () => client,
|
|
112
|
+
clock,
|
|
113
|
+
shareTransport: transport,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
controller.connect();
|
|
117
|
+
await tick();
|
|
118
|
+
|
|
119
|
+
return { controller, client };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("share", () => {
|
|
123
|
+
test("stamps the returned share id back on the session", async () => {
|
|
124
|
+
// Arrange
|
|
125
|
+
const { controller, client } = await connectedController(sessionFixture());
|
|
126
|
+
|
|
127
|
+
// Act
|
|
128
|
+
controller.share();
|
|
129
|
+
await tick();
|
|
130
|
+
|
|
131
|
+
// Assert
|
|
132
|
+
expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("surfaces the ssh line as a toast, not an inline status", async () => {
|
|
136
|
+
// Arrange
|
|
137
|
+
const { controller } = await connectedController(sessionFixture());
|
|
138
|
+
|
|
139
|
+
// Act
|
|
140
|
+
controller.share();
|
|
141
|
+
await tick();
|
|
142
|
+
|
|
143
|
+
// Assert
|
|
144
|
+
expect(controller.getSnapshot().toast).toEqual({
|
|
145
|
+
body: "ssh p_abc123xy@cueloop.dev",
|
|
146
|
+
title: "share link copied",
|
|
147
|
+
});
|
|
148
|
+
expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("pullShared", () => {
|
|
153
|
+
test("unions collaborator notes and identities in when the plan was shared", async () => {
|
|
154
|
+
// Arrange
|
|
155
|
+
remote = sessionFixture({
|
|
156
|
+
annotations: [annotation("a1", "SHA256:mate")],
|
|
157
|
+
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
158
|
+
});
|
|
159
|
+
const { controller, client } = await connectedController(
|
|
160
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Act
|
|
164
|
+
controller.pullShared();
|
|
165
|
+
await tick();
|
|
166
|
+
|
|
167
|
+
// Assert
|
|
168
|
+
expect(pullShare).toHaveBeenCalledWith("p_abc123xy");
|
|
169
|
+
expect(client.sessionMergeShared).toHaveBeenCalledWith("ses_1", {
|
|
170
|
+
annotations: remote.annotations,
|
|
171
|
+
participants: remote.participants,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("does nothing for a plan that was never shared", async () => {
|
|
176
|
+
// Arrange
|
|
177
|
+
pullShare.mockClear();
|
|
178
|
+
const { controller, client } = await connectedController(sessionFixture());
|
|
179
|
+
|
|
180
|
+
// Act
|
|
181
|
+
controller.pullShared();
|
|
182
|
+
await tick();
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
185
|
+
expect(pullShare).not.toHaveBeenCalled();
|
|
186
|
+
expect(client.sessionMergeShared).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("mirror on annotate", () => {
|
|
191
|
+
test("pushes an edited note up when the plan is shared", async () => {
|
|
192
|
+
// Arrange
|
|
193
|
+
pushShare.mockClear();
|
|
194
|
+
const { controller } = await connectedController(
|
|
195
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Act
|
|
199
|
+
controller.updateAnnotation("a1", "revised body");
|
|
200
|
+
await tick();
|
|
201
|
+
|
|
202
|
+
// Assert
|
|
203
|
+
expect(pushShare).toHaveBeenCalledTimes(1);
|
|
204
|
+
expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
|
|
205
|
+
expect(pushShare.mock.calls[0]?.[1]).toEqual([
|
|
206
|
+
{
|
|
207
|
+
id: "a1",
|
|
208
|
+
kind: "comment",
|
|
209
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
210
|
+
body: "revised body",
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("does not push when the plan was never shared", async () => {
|
|
216
|
+
// Arrange
|
|
217
|
+
pushShare.mockClear();
|
|
218
|
+
const { controller } = await connectedController(
|
|
219
|
+
sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }),
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// Act
|
|
223
|
+
controller.updateAnnotation("a1", "revised body");
|
|
224
|
+
await tick();
|
|
225
|
+
|
|
226
|
+
// Assert
|
|
227
|
+
expect(pushShare).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("does not push when the local write is rejected", async () => {
|
|
231
|
+
// Arrange - the daemon write fails (e.g. a resolved session)
|
|
232
|
+
pushShare.mockClear();
|
|
233
|
+
const { controller, client } = await connectedController(
|
|
234
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
client.sessionComment.mockImplementationOnce(async () => {
|
|
238
|
+
throw new Error("session is resolved");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Act
|
|
242
|
+
controller.updateAnnotation("a1", "revised body");
|
|
243
|
+
await tick();
|
|
244
|
+
|
|
245
|
+
// Assert - a failed local write must never leak to the share
|
|
246
|
+
expect(pushShare).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("reply", () => {
|
|
251
|
+
test("a reply shares the root's anchor, names it in replyTo, and mirrors up", async () => {
|
|
252
|
+
// Arrange
|
|
253
|
+
pushShare.mockClear();
|
|
254
|
+
const { controller, client } = await connectedController(
|
|
255
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:ana")] }),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Act
|
|
259
|
+
const id = controller.reply("a1", "agreed");
|
|
260
|
+
await tick();
|
|
261
|
+
|
|
262
|
+
// Assert
|
|
263
|
+
const wire = client.sessionComment.mock.calls.at(-1)![1];
|
|
264
|
+
|
|
265
|
+
expect(wire).toEqual({
|
|
266
|
+
id: id!,
|
|
267
|
+
kind: "comment",
|
|
268
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
269
|
+
body: "agreed",
|
|
270
|
+
replyTo: "a1",
|
|
271
|
+
});
|
|
272
|
+
expect(pushShare.mock.calls[0]?.[1]).toEqual([wire]);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("editing a reply keeps its reply link", async () => {
|
|
276
|
+
// Arrange
|
|
277
|
+
const { controller, client } = await connectedController(
|
|
278
|
+
sessionFixture({
|
|
279
|
+
annotations: [
|
|
280
|
+
annotation("a1", "SHA256:ana"),
|
|
281
|
+
{ ...annotation("a2", "SHA256:me"), replyTo: "a1" },
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Act
|
|
287
|
+
controller.updateAnnotation("a2", "revised reply");
|
|
288
|
+
await tick();
|
|
289
|
+
|
|
290
|
+
// Assert
|
|
291
|
+
expect(client.sessionComment.mock.calls.at(-1)?.[1]).toMatchObject({
|
|
292
|
+
id: "a2",
|
|
293
|
+
body: "revised reply",
|
|
294
|
+
replyTo: "a1",
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a reply to a reply hangs off the discussion's root comment", async () => {
|
|
299
|
+
// Arrange
|
|
300
|
+
const { controller, client } = await connectedController(
|
|
301
|
+
sessionFixture({
|
|
302
|
+
annotations: [
|
|
303
|
+
annotation("a1", "SHA256:ana"),
|
|
304
|
+
{ ...annotation("a2", "SHA256:bob"), replyTo: "a1" },
|
|
305
|
+
],
|
|
306
|
+
}),
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
// Act
|
|
310
|
+
controller.reply("a2", "same thread");
|
|
311
|
+
await tick();
|
|
312
|
+
|
|
313
|
+
// Assert
|
|
314
|
+
expect(client.sessionComment.mock.calls.at(-1)?.[1]).toMatchObject({ replyTo: "a1" });
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("replying to an unknown annotation does nothing", async () => {
|
|
318
|
+
// Arrange
|
|
319
|
+
const { controller, client } = await connectedController(sessionFixture());
|
|
320
|
+
|
|
321
|
+
// Act + Assert
|
|
322
|
+
expect(controller.reply("missing", "x")).toBeUndefined();
|
|
323
|
+
expect(client.sessionComment).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe("startShareSync", () => {
|
|
328
|
+
/** A fake watch stream: records handlers so a test can push a session or drop the link. */
|
|
329
|
+
const streams: Array<{ shareId: string; handlers: ShareWatchHandlers; stopped: boolean }> = [];
|
|
330
|
+
const watchShare = mock((shareId: string, handlers: ShareWatchHandlers) => {
|
|
331
|
+
const stream = { shareId, handlers, stopped: false };
|
|
332
|
+
|
|
333
|
+
streams.push(stream);
|
|
334
|
+
|
|
335
|
+
return () => {
|
|
336
|
+
stream.stopped = true;
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
const liveTransport: ShareTransport = { ...shareTransport, watch: watchShare };
|
|
340
|
+
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
streams.length = 0;
|
|
343
|
+
watchShare.mockClear();
|
|
344
|
+
pullShare.mockClear();
|
|
345
|
+
remote = sessionFixture({ annotations: [] });
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("connect pulls once to catch up, then follows the stream and merges each session it delivers", async () => {
|
|
349
|
+
// Arrange
|
|
350
|
+
const clock = new ManualClock();
|
|
351
|
+
const { controller, client } = await connectedController(
|
|
352
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
353
|
+
clock,
|
|
354
|
+
liveTransport,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Act
|
|
358
|
+
controller.startShareSync();
|
|
359
|
+
await tick();
|
|
360
|
+
|
|
361
|
+
// Assert - one catch-up pull, one open stream on the share
|
|
362
|
+
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
363
|
+
expect(streams).toHaveLength(1);
|
|
364
|
+
expect(streams[0]!.shareId).toBe("p_abc123xy");
|
|
365
|
+
|
|
366
|
+
// Act - the gateway pushes a change carrying Ana's note
|
|
367
|
+
streams[0]!.handlers.onSession(
|
|
368
|
+
sessionFixture({ annotations: [annotation("a9", "SHA256:ana")] }),
|
|
369
|
+
);
|
|
370
|
+
await tick();
|
|
371
|
+
|
|
372
|
+
// Assert - merged without another pull
|
|
373
|
+
expect(client.sessionMergeShared).toHaveBeenLastCalledWith(
|
|
374
|
+
"ses_1",
|
|
375
|
+
expect.objectContaining({
|
|
376
|
+
annotations: [expect.objectContaining({ id: "a9", author: "SHA256:ana" })],
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("a dropped stream reconnects with doubling backoff and catches up on each connect", async () => {
|
|
383
|
+
// Arrange
|
|
384
|
+
const clock = new ManualClock();
|
|
385
|
+
const { controller } = await connectedController(
|
|
386
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
387
|
+
clock,
|
|
388
|
+
liveTransport,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
controller.startShareSync();
|
|
392
|
+
await tick();
|
|
393
|
+
|
|
394
|
+
// Act - the link drops: nothing until the floor delay has passed
|
|
395
|
+
streams[0]!.handlers.onClose("ssh exited 255");
|
|
396
|
+
clock.advance(SHARE_RECONNECT_MIN_MS - 1);
|
|
397
|
+
await tick();
|
|
398
|
+
expect(streams).toHaveLength(1);
|
|
399
|
+
clock.advance(1);
|
|
400
|
+
await tick();
|
|
401
|
+
|
|
402
|
+
// Assert - reconnected, with a fresh catch-up pull
|
|
403
|
+
expect(streams).toHaveLength(2);
|
|
404
|
+
expect(pullShare).toHaveBeenCalledTimes(2);
|
|
405
|
+
|
|
406
|
+
// Act - drops again: the wait doubles
|
|
407
|
+
streams[1]!.handlers.onClose("ssh exited 255");
|
|
408
|
+
clock.advance(SHARE_RECONNECT_MIN_MS);
|
|
409
|
+
await tick();
|
|
410
|
+
expect(streams).toHaveLength(2);
|
|
411
|
+
clock.advance(SHARE_RECONNECT_MIN_MS);
|
|
412
|
+
await tick();
|
|
413
|
+
expect(streams).toHaveLength(3);
|
|
414
|
+
|
|
415
|
+
// Act - a delivered session resets the backoff to the floor
|
|
416
|
+
streams[2]!.handlers.onSession(remote);
|
|
417
|
+
streams[2]!.handlers.onClose("ssh exited 255");
|
|
418
|
+
clock.advance(SHARE_RECONNECT_MIN_MS);
|
|
419
|
+
await tick();
|
|
420
|
+
|
|
421
|
+
// Assert
|
|
422
|
+
expect(streams).toHaveLength(4);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("stop closes the stream and a late close never reconnects", async () => {
|
|
426
|
+
// Arrange
|
|
427
|
+
const clock = new ManualClock();
|
|
428
|
+
const { controller } = await connectedController(
|
|
429
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
430
|
+
clock,
|
|
431
|
+
liveTransport,
|
|
432
|
+
);
|
|
433
|
+
const stop = controller.startShareSync();
|
|
434
|
+
|
|
435
|
+
await tick();
|
|
436
|
+
|
|
437
|
+
// Act
|
|
438
|
+
stop();
|
|
439
|
+
streams[0]!.handlers.onClose("killed");
|
|
440
|
+
clock.advance(SHARE_RECONNECT_MAX_MS * 2);
|
|
441
|
+
await tick();
|
|
442
|
+
|
|
443
|
+
// Assert
|
|
444
|
+
expect(streams[0]!.stopped).toBe(true);
|
|
445
|
+
expect(streams).toHaveLength(1);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("a restart replaces the stream instead of doubling it", async () => {
|
|
449
|
+
// Arrange
|
|
450
|
+
const clock = new ManualClock();
|
|
451
|
+
const { controller } = await connectedController(
|
|
452
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
453
|
+
clock,
|
|
454
|
+
liveTransport,
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
controller.startShareSync();
|
|
458
|
+
await tick();
|
|
459
|
+
|
|
460
|
+
// Act
|
|
461
|
+
controller.startShareSync();
|
|
462
|
+
await tick();
|
|
463
|
+
streams[0]!.handlers.onClose("stale");
|
|
464
|
+
clock.advance(SHARE_RECONNECT_MAX_MS * 2);
|
|
465
|
+
await tick();
|
|
466
|
+
|
|
467
|
+
// Assert - the first stream was stopped and its close re-armed nothing
|
|
468
|
+
expect(streams[0]!.stopped).toBe(true);
|
|
469
|
+
expect(streams).toHaveLength(2);
|
|
470
|
+
expect(streams[1]!.stopped).toBe(false);
|
|
471
|
+
});
|
|
472
|
+
});
|