@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41
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.sh +56 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/shim.c +107 -0
- package/package.json +34 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +324 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +180 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +174 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +45 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type DiffFileContents, type ReviewSession } from "@cueloop/schema";
|
|
3
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
4
|
+
import { createReviewController } from "./session-controller";
|
|
5
|
+
|
|
6
|
+
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
7
|
+
index 111..222 100644
|
|
8
|
+
--- a/src/store.ts
|
|
9
|
+
+++ b/src/store.ts
|
|
10
|
+
@@ -1,4 +1,4 @@
|
|
11
|
+
export class Store {
|
|
12
|
+
- private items = [];
|
|
13
|
+
+ private items = new Map();
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
const FILES: DiffFileContents[] = [
|
|
18
|
+
{
|
|
19
|
+
path: "src/store.ts",
|
|
20
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
21
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
22
|
+
status: "modified",
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function diffSession(files?: DiffFileContents[]): ReviewSession {
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: SCHEMA_VERSION,
|
|
29
|
+
id: "ses_diff",
|
|
30
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
31
|
+
artifact: { type: "diff", content: PATCH, meta: {}, files },
|
|
32
|
+
revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
33
|
+
annotations: [],
|
|
34
|
+
verdict: null,
|
|
35
|
+
status: "pending",
|
|
36
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A fake client that records the working copy the controller writes. */
|
|
41
|
+
function fakeClient(session: ReviewSession, sink: { workingCopy?: string }): SessionClient {
|
|
42
|
+
return {
|
|
43
|
+
onEvent: () => () => {},
|
|
44
|
+
subscribe: async () => {},
|
|
45
|
+
sessionGet: async () => session,
|
|
46
|
+
sessionList: async () => [session],
|
|
47
|
+
sessionSetWorkingCopy: mock(async (_id: string, content: string | undefined) => {
|
|
48
|
+
sink.workingCopy = content;
|
|
49
|
+
return { ...session, workingCopy: content };
|
|
50
|
+
}),
|
|
51
|
+
close: () => {},
|
|
52
|
+
} as unknown as SessionClient;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
56
|
+
|
|
57
|
+
async function connected(session: ReviewSession) {
|
|
58
|
+
const sink: { workingCopy?: string } = {};
|
|
59
|
+
const client = fakeClient(session, sink);
|
|
60
|
+
const controller = createReviewController({
|
|
61
|
+
sessionId: session.id,
|
|
62
|
+
openClient: async () => client,
|
|
63
|
+
});
|
|
64
|
+
controller.connect();
|
|
65
|
+
await tick();
|
|
66
|
+
return { controller, client, sink };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
|
|
70
|
+
describe("diff hunk curation", () => {
|
|
71
|
+
test("rejecting the only change writes an empty curated working copy", async () => {
|
|
72
|
+
// Arrange
|
|
73
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
74
|
+
|
|
75
|
+
// Act - reject the change on the added line
|
|
76
|
+
controller.toggleRejectChange(4);
|
|
77
|
+
await tick();
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
80
|
+
expect(controller.getSnapshot().status).toContain("change rejected");
|
|
81
|
+
expect(sink.workingCopy).toBe("");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("both the deletion and addition rows of the change dim", async () => {
|
|
85
|
+
// Arrange
|
|
86
|
+
const { controller } = await connected(diffSession(FILES));
|
|
87
|
+
|
|
88
|
+
// Act
|
|
89
|
+
controller.toggleRejectChange(4);
|
|
90
|
+
await tick();
|
|
91
|
+
|
|
92
|
+
// Assert - the change spans the del row and the add row
|
|
93
|
+
expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("toggling the same change twice restores the full diff", async () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
99
|
+
|
|
100
|
+
// Act - reject then restore
|
|
101
|
+
controller.toggleRejectChange(4);
|
|
102
|
+
await tick();
|
|
103
|
+
controller.toggleRejectChange(4);
|
|
104
|
+
await tick();
|
|
105
|
+
|
|
106
|
+
// Assert - no decisions left, so the working copy reverts to undefined
|
|
107
|
+
expect(controller.getSnapshot().status).toContain("change restored");
|
|
108
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
109
|
+
expect(controller.rejectedRows().size).toBe(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("whole-hunk reject drops the hunk from the working copy", async () => {
|
|
113
|
+
// Arrange
|
|
114
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
115
|
+
|
|
116
|
+
// Act - reject the whole hunk from any of its rows
|
|
117
|
+
controller.toggleRejectHunk(3);
|
|
118
|
+
await tick();
|
|
119
|
+
|
|
120
|
+
// Assert - the single hunk is the whole diff, so nothing remains
|
|
121
|
+
expect(controller.getSnapshot().status).toContain("hunk rejected");
|
|
122
|
+
expect(sink.workingCopy).toBe("");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a rejected change becomes a removal card with a source, id, and preview", async () => {
|
|
126
|
+
// Arrange
|
|
127
|
+
const { controller } = await connected(diffSession(FILES));
|
|
128
|
+
|
|
129
|
+
// Act
|
|
130
|
+
controller.toggleRejectChange(4);
|
|
131
|
+
await tick();
|
|
132
|
+
|
|
133
|
+
// Assert - one item: diff source, revealing the change's row, both lines previewed
|
|
134
|
+
const items = controller.curationItems();
|
|
135
|
+
expect(items.length).toBe(1);
|
|
136
|
+
expect(items[0]!.source).toBe("diff");
|
|
137
|
+
expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
|
|
138
|
+
expect(items[0]!.revealIndex).toBe(3);
|
|
139
|
+
expect(items[0]!.preview).toEqual([
|
|
140
|
+
"- private items = [];",
|
|
141
|
+
"+ private items = new Map();",
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
|
|
146
|
+
// Arrange
|
|
147
|
+
const { controller } = await connected(diffSession(FILES));
|
|
148
|
+
|
|
149
|
+
// Act
|
|
150
|
+
controller.toggleRejectHunk(3);
|
|
151
|
+
await tick();
|
|
152
|
+
|
|
153
|
+
// Assert - the whole hunk, id diff:path#hunk#hunk
|
|
154
|
+
const items = controller.curationItems();
|
|
155
|
+
expect(items.length).toBe(1);
|
|
156
|
+
expect(items[0]!.source).toBe("diff");
|
|
157
|
+
expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("restoreCuration drops the diff removal and reverts the working copy", async () => {
|
|
161
|
+
// Arrange - one change rejected
|
|
162
|
+
const { controller, sink } = await connected(diffSession(FILES));
|
|
163
|
+
controller.toggleRejectChange(4);
|
|
164
|
+
await tick();
|
|
165
|
+
const [item] = controller.curationItems();
|
|
166
|
+
|
|
167
|
+
// Act
|
|
168
|
+
controller.restoreCuration(item!.id);
|
|
169
|
+
await tick();
|
|
170
|
+
|
|
171
|
+
// Assert - the list empties and the working copy reverts to the full diff
|
|
172
|
+
expect(controller.getSnapshot().status).toContain("removal restored");
|
|
173
|
+
expect(controller.curationItems().length).toBe(0);
|
|
174
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
175
|
+
expect(controller.rejectedRows().size).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("restoreCuration ignores an unknown id", async () => {
|
|
179
|
+
// Arrange - one change rejected
|
|
180
|
+
const { controller } = await connected(diffSession(FILES));
|
|
181
|
+
controller.toggleRejectChange(4);
|
|
182
|
+
await tick();
|
|
183
|
+
|
|
184
|
+
// Act
|
|
185
|
+
controller.restoreCuration("diff:nope#0#0");
|
|
186
|
+
await tick();
|
|
187
|
+
|
|
188
|
+
// Assert - the item stays
|
|
189
|
+
expect(controller.curationItems().length).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("curation is disabled without full file contents", async () => {
|
|
193
|
+
// Arrange - a legacy diff with no artifact.files
|
|
194
|
+
const { controller, sink } = await connected(diffSession(undefined));
|
|
195
|
+
|
|
196
|
+
// Act
|
|
197
|
+
controller.toggleRejectChange(4);
|
|
198
|
+
await tick();
|
|
199
|
+
|
|
200
|
+
// Assert
|
|
201
|
+
expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
|
|
202
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
|
|
207
|
+
// the working copy with the "Second paragraph." block cut out
|
|
208
|
+
const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
|
|
209
|
+
|
|
210
|
+
function planSession(workingCopy?: string): ReviewSession {
|
|
211
|
+
return {
|
|
212
|
+
schemaVersion: SCHEMA_VERSION,
|
|
213
|
+
id: "ses_plan",
|
|
214
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
215
|
+
artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
|
|
216
|
+
revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
217
|
+
annotations: [],
|
|
218
|
+
verdict: null,
|
|
219
|
+
status: "pending",
|
|
220
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
221
|
+
workingCopy,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
describe("plan cut removals", () => {
|
|
226
|
+
test("a cut block becomes a plan removal card previewing its content", async () => {
|
|
227
|
+
// Arrange - a plan whose working copy dropped the second paragraph
|
|
228
|
+
const { controller } = await connected(planSession(PLAN_CUT));
|
|
229
|
+
|
|
230
|
+
// Act
|
|
231
|
+
const items = controller.curationItems();
|
|
232
|
+
|
|
233
|
+
// Assert - one plan-source removal, keyed on the base line range, content previewed
|
|
234
|
+
expect(items.length).toBe(1);
|
|
235
|
+
expect(items[0]!.source).toBe("plan");
|
|
236
|
+
expect(items[0]!.id).toBe("plan:4-4");
|
|
237
|
+
expect(items[0]!.preview).toEqual(["Second paragraph."]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
|
|
241
|
+
// Arrange
|
|
242
|
+
const { controller, sink } = await connected(planSession(PLAN_CUT));
|
|
243
|
+
const [item] = controller.curationItems();
|
|
244
|
+
|
|
245
|
+
// Act
|
|
246
|
+
controller.restoreCuration(item!.id);
|
|
247
|
+
await tick();
|
|
248
|
+
|
|
249
|
+
// Assert - restoring the only cut round-trips to the submitted revision
|
|
250
|
+
expect(controller.getSnapshot().status).toContain("removal restored");
|
|
251
|
+
expect(sink.workingCopy).toBeUndefined();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import { ManualClock } from "@opentui/core/testing";
|
|
3
|
+
import { SCHEMA_VERSION, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
4
|
+
import type { SessionClient } from "@cueloop/daemon/client";
|
|
5
|
+
|
|
6
|
+
// Stub the ssh transport so no subprocess spawns; the controller under test
|
|
7
|
+
// imports these from "./share".
|
|
8
|
+
const publishShare = mock(async () => ({ line: "ssh p_abc123xy@cueloop.dev", copied: true }));
|
|
9
|
+
let remote: ReviewSession;
|
|
10
|
+
const pullShare = mock(async () => remote);
|
|
11
|
+
const pushShare = mock(
|
|
12
|
+
async (_shareId: string, _annotations: Array<Omit<Annotation, "createdAt">>) => {},
|
|
13
|
+
);
|
|
14
|
+
mock.module("./share", () => ({
|
|
15
|
+
publishShare,
|
|
16
|
+
pullShare,
|
|
17
|
+
pushShare,
|
|
18
|
+
shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
|
|
19
|
+
collaboratorAnnotations: (session: ReviewSession) =>
|
|
20
|
+
session.annotations.filter((annotation) => annotation.author),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { createReviewController, SHARE_POLL_MS } = await import("./session-controller");
|
|
24
|
+
|
|
25
|
+
function sessionFixture(overrides: Partial<ReviewSession> = {}): ReviewSession {
|
|
26
|
+
return {
|
|
27
|
+
schemaVersion: SCHEMA_VERSION,
|
|
28
|
+
id: "ses_1",
|
|
29
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
30
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
31
|
+
revisions: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
32
|
+
annotations: [],
|
|
33
|
+
verdict: null,
|
|
34
|
+
status: "pending",
|
|
35
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function annotation(id: string, author: string): Annotation {
|
|
41
|
+
return {
|
|
42
|
+
id,
|
|
43
|
+
kind: "comment",
|
|
44
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
45
|
+
body: "note",
|
|
46
|
+
author,
|
|
47
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fakeClient(session: ReviewSession): SessionClient {
|
|
52
|
+
return {
|
|
53
|
+
onEvent: () => () => {},
|
|
54
|
+
subscribe: async () => {},
|
|
55
|
+
sessionGet: async () => session,
|
|
56
|
+
sessionList: async () => [session],
|
|
57
|
+
sessionAnnotate: mock(async () => session),
|
|
58
|
+
sessionSetShareId: mock(
|
|
59
|
+
async (_id: string, shareId: string) => ((session.shareId = shareId), session),
|
|
60
|
+
),
|
|
61
|
+
sessionMergeShared: mock(async (_id: string, incoming: { annotations: Annotation[] }) => {
|
|
62
|
+
const known = new Set(session.annotations.map((existing) => existing.id));
|
|
63
|
+
for (const note of incoming.annotations)
|
|
64
|
+
if (!known.has(note.id)) session.annotations.push(note);
|
|
65
|
+
return session;
|
|
66
|
+
}),
|
|
67
|
+
close: () => {},
|
|
68
|
+
} as unknown as SessionClient;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
72
|
+
|
|
73
|
+
async function connectedController(
|
|
74
|
+
session: ReviewSession,
|
|
75
|
+
clock?: ManualClock,
|
|
76
|
+
): Promise<{ controller: ReturnType<typeof createReviewController>; client: SessionClient }> {
|
|
77
|
+
const client = fakeClient(session);
|
|
78
|
+
const controller = createReviewController({
|
|
79
|
+
sessionId: session.id,
|
|
80
|
+
openClient: async () => client,
|
|
81
|
+
clock,
|
|
82
|
+
});
|
|
83
|
+
controller.connect();
|
|
84
|
+
await tick();
|
|
85
|
+
return { controller, client };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("share", () => {
|
|
89
|
+
test("stamps the returned share id back on the session", async () => {
|
|
90
|
+
// Arrange
|
|
91
|
+
const { controller, client } = await connectedController(sessionFixture());
|
|
92
|
+
|
|
93
|
+
// Act
|
|
94
|
+
controller.share();
|
|
95
|
+
await tick();
|
|
96
|
+
|
|
97
|
+
// Assert
|
|
98
|
+
expect(client.sessionSetShareId).toHaveBeenCalledWith("ses_1", "p_abc123xy");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("surfaces the ssh line as a toast, not an inline status", async () => {
|
|
102
|
+
// Arrange
|
|
103
|
+
const { controller } = await connectedController(sessionFixture());
|
|
104
|
+
|
|
105
|
+
// Act
|
|
106
|
+
controller.share();
|
|
107
|
+
await tick();
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
expect(controller.getSnapshot().toast).toEqual({
|
|
111
|
+
body: "ssh p_abc123xy@cueloop.dev",
|
|
112
|
+
title: "share link copied",
|
|
113
|
+
});
|
|
114
|
+
expect(controller.getSnapshot().status).not.toContain("ssh p_abc123xy@cueloop.dev");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("pullShared", () => {
|
|
119
|
+
test("unions collaborator notes and identities in when the plan was shared", async () => {
|
|
120
|
+
// Arrange
|
|
121
|
+
remote = sessionFixture({
|
|
122
|
+
annotations: [annotation("a1", "SHA256:mate")],
|
|
123
|
+
participants: [{ id: "SHA256:mate", provider: "ssh", name: "Sam" }],
|
|
124
|
+
});
|
|
125
|
+
const { controller, client } = await connectedController(
|
|
126
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Act
|
|
130
|
+
controller.pullShared();
|
|
131
|
+
await tick();
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
134
|
+
expect(pullShare).toHaveBeenCalledWith("p_abc123xy");
|
|
135
|
+
expect(client.sessionMergeShared).toHaveBeenCalledWith("ses_1", {
|
|
136
|
+
annotations: remote.annotations,
|
|
137
|
+
participants: remote.participants,
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("does nothing for a plan that was never shared", async () => {
|
|
142
|
+
// Arrange
|
|
143
|
+
pullShare.mockClear();
|
|
144
|
+
const { controller, client } = await connectedController(sessionFixture());
|
|
145
|
+
|
|
146
|
+
// Act
|
|
147
|
+
controller.pullShared();
|
|
148
|
+
await tick();
|
|
149
|
+
|
|
150
|
+
// Assert
|
|
151
|
+
expect(pullShare).not.toHaveBeenCalled();
|
|
152
|
+
expect(client.sessionMergeShared).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("mirror on annotate", () => {
|
|
157
|
+
test("pushes an edited note up when the plan is shared", async () => {
|
|
158
|
+
// Arrange
|
|
159
|
+
pushShare.mockClear();
|
|
160
|
+
const { controller } = await connectedController(
|
|
161
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Act
|
|
165
|
+
controller.updateAnnotation("a1", "revised body");
|
|
166
|
+
await tick();
|
|
167
|
+
|
|
168
|
+
// Assert
|
|
169
|
+
expect(pushShare).toHaveBeenCalledTimes(1);
|
|
170
|
+
expect(pushShare.mock.calls[0]?.[0]).toBe("p_abc123xy");
|
|
171
|
+
expect(pushShare.mock.calls[0]?.[1]).toEqual([
|
|
172
|
+
{
|
|
173
|
+
id: "a1",
|
|
174
|
+
kind: "comment",
|
|
175
|
+
anchor: { quote: "Plan", prefix: "", suffix: "" },
|
|
176
|
+
body: "revised body",
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("does not push when the plan was never shared", async () => {
|
|
182
|
+
// Arrange
|
|
183
|
+
pushShare.mockClear();
|
|
184
|
+
const { controller } = await connectedController(
|
|
185
|
+
sessionFixture({ annotations: [annotation("a1", "SHA256:me")] }),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
// Act
|
|
189
|
+
controller.updateAnnotation("a1", "revised body");
|
|
190
|
+
await tick();
|
|
191
|
+
|
|
192
|
+
// Assert
|
|
193
|
+
expect(pushShare).not.toHaveBeenCalled();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("does not push when the local write is rejected", async () => {
|
|
197
|
+
// Arrange - the daemon write fails (e.g. a resolved session)
|
|
198
|
+
pushShare.mockClear();
|
|
199
|
+
const { controller, client } = await connectedController(
|
|
200
|
+
sessionFixture({ shareId: "p_abc123xy", annotations: [annotation("a1", "SHA256:me")] }),
|
|
201
|
+
);
|
|
202
|
+
(client.sessionAnnotate as ReturnType<typeof mock>).mockImplementationOnce(async () => {
|
|
203
|
+
throw new Error("session is resolved");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Act
|
|
207
|
+
controller.updateAnnotation("a1", "revised body");
|
|
208
|
+
await tick();
|
|
209
|
+
|
|
210
|
+
// Assert - a failed local write must never leak to the share
|
|
211
|
+
expect(pushShare).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("startSharePoll", () => {
|
|
216
|
+
test("pulls now and again each interval until stopped", async () => {
|
|
217
|
+
// Arrange
|
|
218
|
+
pullShare.mockClear();
|
|
219
|
+
remote = sessionFixture({ annotations: [] });
|
|
220
|
+
const clock = new ManualClock();
|
|
221
|
+
const { controller } = await connectedController(
|
|
222
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
223
|
+
clock,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Act - immediate pull
|
|
227
|
+
const stop = controller.startSharePoll();
|
|
228
|
+
await tick();
|
|
229
|
+
|
|
230
|
+
// Assert
|
|
231
|
+
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
232
|
+
|
|
233
|
+
// Act - one interval later, it pulls again
|
|
234
|
+
clock.advance(SHARE_POLL_MS);
|
|
235
|
+
await tick();
|
|
236
|
+
|
|
237
|
+
// Assert
|
|
238
|
+
expect(pullShare).toHaveBeenCalledTimes(2);
|
|
239
|
+
|
|
240
|
+
// Act - stop, then advance: no further pulls
|
|
241
|
+
stop();
|
|
242
|
+
clock.advance(SHARE_POLL_MS * 3);
|
|
243
|
+
await tick();
|
|
244
|
+
|
|
245
|
+
// Assert
|
|
246
|
+
expect(pullShare).toHaveBeenCalledTimes(2);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("a stop during an in-flight pull leaves no zombie timer", async () => {
|
|
250
|
+
// Arrange - the first pull hangs until released
|
|
251
|
+
pullShare.mockClear();
|
|
252
|
+
remote = sessionFixture({ annotations: [] });
|
|
253
|
+
let release: () => void = () => {};
|
|
254
|
+
pullShare.mockImplementationOnce(
|
|
255
|
+
() => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
|
|
256
|
+
);
|
|
257
|
+
const clock = new ManualClock();
|
|
258
|
+
const { controller } = await connectedController(
|
|
259
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
260
|
+
clock,
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
// Act - start (pull is in flight), leave, then let the pull settle
|
|
264
|
+
const stop = controller.startSharePoll();
|
|
265
|
+
await tick();
|
|
266
|
+
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
267
|
+
stop();
|
|
268
|
+
release();
|
|
269
|
+
await tick();
|
|
270
|
+
clock.advance(SHARE_POLL_MS * 3);
|
|
271
|
+
await tick();
|
|
272
|
+
|
|
273
|
+
// Assert - the settled pull's finally must not re-arm the timer
|
|
274
|
+
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a restart during an in-flight pull does not double the poll", async () => {
|
|
278
|
+
// Arrange - run A's first pull hangs; later pulls resolve immediately
|
|
279
|
+
pullShare.mockClear();
|
|
280
|
+
remote = sessionFixture({ annotations: [] });
|
|
281
|
+
let release: () => void = () => {};
|
|
282
|
+
pullShare.mockImplementationOnce(
|
|
283
|
+
() => new Promise<ReviewSession>((resolve) => (release = () => resolve(remote))),
|
|
284
|
+
);
|
|
285
|
+
const clock = new ManualClock();
|
|
286
|
+
const { controller } = await connectedController(
|
|
287
|
+
sessionFixture({ shareId: "p_abc123xy" }),
|
|
288
|
+
clock,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// Act - run A starts (pull hangs), then run B restarts while A is in flight
|
|
292
|
+
controller.startSharePoll();
|
|
293
|
+
await tick();
|
|
294
|
+
expect(pullShare).toHaveBeenCalledTimes(1);
|
|
295
|
+
const stopB = controller.startSharePoll();
|
|
296
|
+
await tick();
|
|
297
|
+
expect(pullShare).toHaveBeenCalledTimes(2);
|
|
298
|
+
|
|
299
|
+
// A's stale pull settles - it must not re-arm; then one interval fires only run B
|
|
300
|
+
release();
|
|
301
|
+
await tick();
|
|
302
|
+
clock.advance(SHARE_POLL_MS);
|
|
303
|
+
await tick();
|
|
304
|
+
|
|
305
|
+
// Assert - exactly one more pull (run B), not two
|
|
306
|
+
expect(pullShare).toHaveBeenCalledTimes(3);
|
|
307
|
+
stopB();
|
|
308
|
+
});
|
|
309
|
+
});
|