@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.23
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 +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +140 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +15 -0
- package/src/App.review-panel.test.tsx +29 -6
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +77 -183
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/PlanSheet.stories.tsx +1 -1
- package/src/components/PlanSheet.tsx +13 -6
- package/src/components/ReviewRail.tsx +16 -4
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +62 -0
- package/src/config.ts +1 -0
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +27 -5
- package/src/key-bindings.ts +11 -1
- package/src/keymap.test.ts +97 -5
- package/src/keymap.ts +25 -2
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +22 -3
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import React, { forwardRef, useImperativeHandle, useRef } from "react";
|
|
10
10
|
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
11
|
-
import type
|
|
11
|
+
import { isAddressed, type Annotation, type ReviewSession } from "@cueloop/schema";
|
|
12
12
|
import type { Theme } from "../theme";
|
|
13
13
|
import { useComponentTheme } from "./theme-context";
|
|
14
14
|
import { Tabs, Tab, TabList } from "./primitives/Tabs";
|
|
@@ -40,6 +40,8 @@ export interface ReviewRailProps {
|
|
|
40
40
|
onSelectCard: (id: string) => void;
|
|
41
41
|
onActivateCard: (id: string) => void;
|
|
42
42
|
onSubmitRequest: () => void;
|
|
43
|
+
/** Publish the plan as a share; omitted for non-owners (hides the button). */
|
|
44
|
+
onShareRequest?: () => void;
|
|
43
45
|
/** Rail column width; the app derives it from the persisted review layout. */
|
|
44
46
|
width?: number;
|
|
45
47
|
/**
|
|
@@ -68,6 +70,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
68
70
|
onSelectCard,
|
|
69
71
|
onActivateCard,
|
|
70
72
|
onSubmitRequest,
|
|
73
|
+
onShareRequest,
|
|
71
74
|
width = 34,
|
|
72
75
|
onCollapse,
|
|
73
76
|
theme,
|
|
@@ -76,6 +79,9 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
76
79
|
): React.ReactNode {
|
|
77
80
|
const tokens = useComponentTheme(theme);
|
|
78
81
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
82
|
+
// addressed annotations leave the card list; a dim count keeps them honest
|
|
83
|
+
const openAnnotations = session.annotations.filter((annotation) => !isAddressed(annotation));
|
|
84
|
+
const addressedCount = session.annotations.length - openAnnotations.length;
|
|
79
85
|
|
|
80
86
|
useImperativeHandle(handleRef, () => ({
|
|
81
87
|
revealCard: (annotationId: string): void => {
|
|
@@ -101,11 +107,12 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
101
107
|
) : (
|
|
102
108
|
<>
|
|
103
109
|
{session.workingCopy !== undefined ? <text fg={tokens.textDim}>± plan edits → one diff</text> : null}
|
|
104
|
-
{
|
|
105
|
-
|
|
110
|
+
{addressedCount > 0 ? <text fg={tokens.textDim}>✓ {addressedCount} addressed by revision</text> : null}
|
|
111
|
+
{openAnnotations.length === 0 ? (
|
|
112
|
+
<text fg={tokens.textDim}>{session.annotations.length === 0 ? "no annotations yet" : "all annotations addressed"}</text>
|
|
106
113
|
) : (
|
|
107
114
|
<scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
|
|
108
|
-
{
|
|
115
|
+
{openAnnotations.map((annotation) => (
|
|
109
116
|
<AnnotationCard
|
|
110
117
|
key={annotation.id}
|
|
111
118
|
id={`annotation-card-${annotation.id}`}
|
|
@@ -145,6 +152,11 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
|
|
|
145
152
|
{`Submit review (${pendingCount}) ⏎`}
|
|
146
153
|
</Button>
|
|
147
154
|
)}
|
|
155
|
+
{onShareRequest && session.status !== "resolved" ? (
|
|
156
|
+
<Button variant="plain" onPress={onShareRequest} theme={theme}>
|
|
157
|
+
{"Share plan ⇧S"}
|
|
158
|
+
</Button>
|
|
159
|
+
) : null}
|
|
148
160
|
</>
|
|
149
161
|
)}
|
|
150
162
|
{/* the collapse chevron: muted, left-bound, one column from the divider.
|
|
@@ -28,10 +28,15 @@ describe("stories catalog", () => {
|
|
|
28
28
|
|
|
29
29
|
for (const { moduleTitle, storyName, story } of stories) {
|
|
30
30
|
test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
|
|
31
|
+
// Arrange
|
|
31
32
|
const size = story.size ?? { width: 80, height: 24 };
|
|
33
|
+
|
|
34
|
+
// Act
|
|
32
35
|
const setup = await testRender(story.render(), size);
|
|
33
36
|
await setup.waitForVisualIdle();
|
|
34
37
|
const frame = setup.captureCharFrame();
|
|
38
|
+
|
|
39
|
+
// Assert
|
|
35
40
|
expect(frame).toMatchSnapshot(`${moduleTitle}/${storyName}`);
|
|
36
41
|
if (story.expectedColors?.length) {
|
|
37
42
|
const seenColors = new Set<string>();
|
package/src/config.test.ts
CHANGED
|
@@ -8,20 +8,28 @@ import { DARK } from "./theme";
|
|
|
8
8
|
|
|
9
9
|
describe("loadConfig", () => {
|
|
10
10
|
test("defaults when no file exists", () => {
|
|
11
|
+
// Act
|
|
11
12
|
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
13
|
+
|
|
14
|
+
// Assert
|
|
12
15
|
expect(config.keys["comment"]).toEqual(["c"]);
|
|
13
16
|
expect(config.theme.accent).toBe(DARK.accent);
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
test("user config rebinds actions and overrides theme tokens", () => {
|
|
20
|
+
// Arrange
|
|
17
21
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-"));
|
|
18
22
|
const path = join(dir, "config.toml");
|
|
19
23
|
writeFileSync(
|
|
20
24
|
path,
|
|
21
25
|
`[keys]\ncomment = "a"\nsubmit = ["return", "S"]\n\n[theme]\naccent = "#ff0000"\n`,
|
|
22
26
|
);
|
|
27
|
+
|
|
23
28
|
try {
|
|
29
|
+
// Act
|
|
24
30
|
const config = loadConfig({ userConfigPath: path });
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
25
33
|
expect(config.keys["comment"]).toEqual(["a"]);
|
|
26
34
|
expect(config.keys["submit"]).toEqual(["return", "S"]);
|
|
27
35
|
expect(config.keys["cut"]).toEqual(["x"]); // untouched defaults survive
|
|
@@ -33,14 +41,19 @@ describe("loadConfig", () => {
|
|
|
33
41
|
});
|
|
34
42
|
|
|
35
43
|
test("repo config layers over user config", () => {
|
|
44
|
+
// Arrange
|
|
36
45
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg2-"));
|
|
37
46
|
const user = join(dir, "user.toml");
|
|
38
47
|
const repoRoot = join(dir, "repo");
|
|
39
48
|
writeFileSync(user, `[keys]\ncomment = "a"\n`);
|
|
40
49
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
41
50
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[keys]\ncomment = "z"\n`);
|
|
51
|
+
|
|
42
52
|
try {
|
|
53
|
+
// Act
|
|
43
54
|
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
44
57
|
expect(config.keys["comment"]).toEqual(["z"]);
|
|
45
58
|
} finally {
|
|
46
59
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -48,11 +61,16 @@ describe("loadConfig", () => {
|
|
|
48
61
|
});
|
|
49
62
|
|
|
50
63
|
test("broken config never blocks a review", () => {
|
|
64
|
+
// Arrange
|
|
51
65
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg3-"));
|
|
52
66
|
const path = join(dir, "config.toml");
|
|
53
67
|
writeFileSync(path, "not [valid toml");
|
|
68
|
+
|
|
54
69
|
try {
|
|
70
|
+
// Act
|
|
55
71
|
const config = loadConfig({ userConfigPath: path });
|
|
72
|
+
|
|
73
|
+
// Assert
|
|
56
74
|
expect(config.keys["comment"]).toEqual(["c"]);
|
|
57
75
|
} finally {
|
|
58
76
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -60,11 +78,16 @@ describe("loadConfig", () => {
|
|
|
60
78
|
});
|
|
61
79
|
|
|
62
80
|
test("[ui] parses auto_close and the editor override", () => {
|
|
81
|
+
// Arrange
|
|
63
82
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg4-"));
|
|
64
83
|
const path = join(dir, "config.toml");
|
|
65
84
|
writeFileSync(path, `[ui]\nauto_close = 3\neditor = "code --wait"\n`);
|
|
85
|
+
|
|
66
86
|
try {
|
|
87
|
+
// Act
|
|
67
88
|
const config = loadConfig({ userConfigPath: path });
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
68
91
|
expect(config.ui.autoClose).toBe(3);
|
|
69
92
|
expect(config.ui.editor).toBe("code --wait");
|
|
70
93
|
} finally {
|
|
@@ -73,10 +96,13 @@ describe("loadConfig", () => {
|
|
|
73
96
|
});
|
|
74
97
|
|
|
75
98
|
test("[ui] editor defaults to undefined and ignores a blank value", () => {
|
|
99
|
+
// Arrange
|
|
76
100
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg5-"));
|
|
77
101
|
const path = join(dir, "config.toml");
|
|
78
102
|
writeFileSync(path, `[ui]\neditor = " "\n`);
|
|
103
|
+
|
|
79
104
|
try {
|
|
105
|
+
// Assert
|
|
80
106
|
expect(loadConfig({ userConfigPath: "/nonexistent/config.toml" }).ui.editor).toBeUndefined();
|
|
81
107
|
expect(loadConfig({ userConfigPath: path }).ui.editor).toBeUndefined();
|
|
82
108
|
} finally {
|
|
@@ -85,17 +111,25 @@ describe("loadConfig", () => {
|
|
|
85
111
|
});
|
|
86
112
|
|
|
87
113
|
test("[ui] review panel defaults to an expanded rail at the default width", () => {
|
|
114
|
+
// Act
|
|
88
115
|
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
116
|
+
|
|
117
|
+
// Assert
|
|
89
118
|
expect(config.ui.reviewState).toBe("expanded");
|
|
90
119
|
expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
|
|
91
120
|
});
|
|
92
121
|
|
|
93
122
|
test("[ui] parses review_width (clamped) and review_state", () => {
|
|
123
|
+
// Arrange
|
|
94
124
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg6-"));
|
|
95
125
|
const path = join(dir, "config.toml");
|
|
96
126
|
writeFileSync(path, `[ui]\nreview_width = 999\nreview_state = "compact"\n`);
|
|
127
|
+
|
|
97
128
|
try {
|
|
129
|
+
// Act
|
|
98
130
|
const config = loadConfig({ userConfigPath: path });
|
|
131
|
+
|
|
132
|
+
// Assert
|
|
99
133
|
expect(config.ui.reviewWidth).toBe(REVIEW_MAX_WIDTH); // out-of-range width clamps
|
|
100
134
|
expect(config.ui.reviewState).toBe("compact");
|
|
101
135
|
} finally {
|
|
@@ -104,11 +138,16 @@ describe("loadConfig", () => {
|
|
|
104
138
|
});
|
|
105
139
|
|
|
106
140
|
test("[ui] ignores an unknown review_state and a non-numeric width", () => {
|
|
141
|
+
// Arrange
|
|
107
142
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg7-"));
|
|
108
143
|
const path = join(dir, "config.toml");
|
|
109
144
|
writeFileSync(path, `[ui]\nreview_width = "wide"\nreview_state = "sideways"\n`);
|
|
145
|
+
|
|
110
146
|
try {
|
|
147
|
+
// Act
|
|
111
148
|
const config = loadConfig({ userConfigPath: path });
|
|
149
|
+
|
|
150
|
+
// Assert
|
|
112
151
|
expect(config.ui.reviewWidth).toBe(REVIEW_DEFAULT_WIDTH);
|
|
113
152
|
expect(config.ui.reviewState).toBe("expanded");
|
|
114
153
|
} finally {
|
|
@@ -117,16 +156,25 @@ describe("loadConfig", () => {
|
|
|
117
156
|
});
|
|
118
157
|
|
|
119
158
|
test("persistReviewWidth and persistReviewState round-trip through the config file", () => {
|
|
159
|
+
// Arrange
|
|
120
160
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg8-"));
|
|
121
161
|
const path = join(dir, "config.toml");
|
|
162
|
+
|
|
122
163
|
try {
|
|
164
|
+
// Act
|
|
123
165
|
persistReviewWidth(42, path);
|
|
124
166
|
persistReviewState("hidden", path);
|
|
167
|
+
|
|
168
|
+
// Assert
|
|
125
169
|
const config = loadConfig({ userConfigPath: path });
|
|
126
170
|
expect(config.ui.reviewWidth).toBe(42);
|
|
127
171
|
expect(config.ui.reviewState).toBe("hidden");
|
|
172
|
+
|
|
128
173
|
// a second write replaces the key in place rather than appending a duplicate
|
|
174
|
+
// Act
|
|
129
175
|
persistReviewWidth(30, path);
|
|
176
|
+
|
|
177
|
+
// Assert
|
|
130
178
|
expect(loadConfig({ userConfigPath: path }).ui.reviewWidth).toBe(30);
|
|
131
179
|
} finally {
|
|
132
180
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -136,7 +184,10 @@ describe("loadConfig", () => {
|
|
|
136
184
|
|
|
137
185
|
describe("integrations.obsidian config", () => {
|
|
138
186
|
test("defaults when the section is absent", () => {
|
|
187
|
+
// Act
|
|
139
188
|
const config = loadConfig({ userConfigPath: "/nonexistent/config.toml" });
|
|
189
|
+
|
|
190
|
+
// Assert
|
|
140
191
|
expect(config.integrations.obsidian.vault).toBeUndefined();
|
|
141
192
|
expect(config.integrations.obsidian.folder).toBe("cueloop");
|
|
142
193
|
expect(config.integrations.obsidian.filenameFormat).toBe("{YYYY}-{MM}-{DD} - {title}");
|
|
@@ -145,14 +196,19 @@ describe("integrations.obsidian config", () => {
|
|
|
145
196
|
});
|
|
146
197
|
|
|
147
198
|
test("user config sets the section; invalid enum values are ignored", () => {
|
|
199
|
+
// Arrange
|
|
148
200
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs-"));
|
|
149
201
|
const path = join(dir, "config.toml");
|
|
150
202
|
writeFileSync(
|
|
151
203
|
path,
|
|
152
204
|
`[integrations.obsidian]\nvault = "/notes/vault"\nfolder = "plans"\nexportOn = "approve"\nseparator = "comma"\n`,
|
|
153
205
|
);
|
|
206
|
+
|
|
154
207
|
try {
|
|
208
|
+
// Act
|
|
155
209
|
const config = loadConfig({ userConfigPath: path });
|
|
210
|
+
|
|
211
|
+
// Assert
|
|
156
212
|
expect(config.integrations.obsidian.vault).toBe("/notes/vault");
|
|
157
213
|
expect(config.integrations.obsidian.folder).toBe("plans");
|
|
158
214
|
expect(config.integrations.obsidian.exportOn).toBe("approve");
|
|
@@ -164,14 +220,19 @@ describe("integrations.obsidian config", () => {
|
|
|
164
220
|
});
|
|
165
221
|
|
|
166
222
|
test("repo config layers over user config for the section", () => {
|
|
223
|
+
// Arrange
|
|
167
224
|
const dir = mkdtempSync(join(tmpdir(), "cueloop-cfg-obs2-"));
|
|
168
225
|
const user = join(dir, "user.toml");
|
|
169
226
|
const repoRoot = join(dir, "repo");
|
|
170
227
|
writeFileSync(user, `[integrations.obsidian]\nvault = "/user/vault"\nexportOn = "resolve"\n`);
|
|
171
228
|
Bun.spawnSync(["mkdir", "-p", join(repoRoot, ".cueloop")]);
|
|
172
229
|
writeFileSync(join(repoRoot, ".cueloop", "config.toml"), `[integrations.obsidian]\nfolder = "repo-plans"\n`);
|
|
230
|
+
|
|
173
231
|
try {
|
|
232
|
+
// Act
|
|
174
233
|
const config = loadConfig({ userConfigPath: user, repoRoot });
|
|
234
|
+
|
|
235
|
+
// Assert
|
|
175
236
|
expect(config.integrations.obsidian.vault).toBe("/user/vault"); // user layer survives
|
|
176
237
|
expect(config.integrations.obsidian.exportOn).toBe("resolve");
|
|
177
238
|
expect(config.integrations.obsidian.folder).toBe("repo-plans"); // repo layer wins
|
|
@@ -183,6 +244,7 @@ describe("integrations.obsidian config", () => {
|
|
|
183
244
|
|
|
184
245
|
describe("actionFor", () => {
|
|
185
246
|
test("resolves keys and shifted keys to actions", () => {
|
|
247
|
+
// Assert
|
|
186
248
|
expect(actionFor(DEFAULT_KEYS, "j", false)).toBe("down");
|
|
187
249
|
expect(actionFor(DEFAULT_KEYS, "g", true)).toBe("bottom"); // G
|
|
188
250
|
expect(actionFor(DEFAULT_KEYS, "return", false)).toBe("submit");
|
package/src/config.ts
CHANGED
package/src/editor.test.ts
CHANGED
|
@@ -6,35 +6,42 @@ import { editInEditor, resolveEditor, resolveEditorCommand } from "./editor";
|
|
|
6
6
|
|
|
7
7
|
describe("resolveEditor", () => {
|
|
8
8
|
test("a clean environment falls back to nano so editing always works", () => {
|
|
9
|
+
// Assert
|
|
9
10
|
expect(resolveEditor(undefined, {})).toBe("nano");
|
|
10
11
|
});
|
|
11
12
|
|
|
12
13
|
test("the environment chain wins in order: CUELOOP_EDITOR, VISUAL, EDITOR", () => {
|
|
14
|
+
// Assert
|
|
13
15
|
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code", CUELOOP_EDITOR: "hx" })).toBe("hx");
|
|
14
16
|
expect(resolveEditor(undefined, { EDITOR: "vim", VISUAL: "code" })).toBe("code");
|
|
15
17
|
expect(resolveEditor(undefined, { EDITOR: "vim" })).toBe("vim");
|
|
16
18
|
});
|
|
17
19
|
|
|
18
20
|
test("the [ui] editor config overrides the environment", () => {
|
|
21
|
+
// Assert
|
|
19
22
|
expect(resolveEditor("code --wait", { EDITOR: "vim" })).toBe("code --wait");
|
|
20
23
|
});
|
|
21
24
|
});
|
|
22
25
|
|
|
23
26
|
describe("resolveEditorCommand", () => {
|
|
24
27
|
test("a known GUI editor gets its wait flag appended and is trusted to wait", () => {
|
|
28
|
+
// Assert
|
|
25
29
|
expect(resolveEditorCommand("code")).toEqual({ argv: ["code", "--wait"], waits: true });
|
|
26
30
|
expect(resolveEditorCommand("subl")).toEqual({ argv: ["subl", "--new-window", "--wait"], waits: true });
|
|
27
31
|
});
|
|
28
32
|
|
|
29
33
|
test("an existing wait flag is not duplicated", () => {
|
|
34
|
+
// Assert
|
|
30
35
|
expect(resolveEditorCommand("code --wait").argv).toEqual(["code", "--wait"]);
|
|
31
36
|
});
|
|
32
37
|
|
|
33
38
|
test("a terminal editor is left alone and trusted to hold the tty", () => {
|
|
39
|
+
// Assert
|
|
34
40
|
expect(resolveEditorCommand("vim")).toEqual({ argv: ["vim"], waits: true });
|
|
35
41
|
});
|
|
36
42
|
|
|
37
43
|
test("an unknown editor is neither flagged nor trusted to wait", () => {
|
|
44
|
+
// Assert
|
|
38
45
|
expect(resolveEditorCommand("my-editor")).toEqual({ argv: ["my-editor"], waits: false });
|
|
39
46
|
});
|
|
40
47
|
});
|
|
@@ -62,12 +69,18 @@ describe("editInEditor", () => {
|
|
|
62
69
|
};
|
|
63
70
|
|
|
64
71
|
test("an editor that changes the file reports the new content", () => {
|
|
72
|
+
// Act
|
|
65
73
|
const result = editInEditor("old", "plan.md", { editor: stubEditor("writer.sh", `printf 'new' > "$1"`) });
|
|
74
|
+
|
|
75
|
+
// Assert
|
|
66
76
|
expect(result).toEqual({ content: "new", changed: true });
|
|
67
77
|
});
|
|
68
78
|
|
|
69
79
|
test("a fast, unchanged return opens the gate; confirming re-reads the saved file", () => {
|
|
80
|
+
// Arrange
|
|
70
81
|
let askedLabel = "";
|
|
82
|
+
|
|
83
|
+
// Act
|
|
71
84
|
const result = editInEditor("keep", "plan.md", {
|
|
72
85
|
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
73
86
|
now: instant(),
|
|
@@ -77,21 +90,29 @@ describe("editInEditor", () => {
|
|
|
77
90
|
return true;
|
|
78
91
|
},
|
|
79
92
|
});
|
|
93
|
+
|
|
94
|
+
// Assert
|
|
80
95
|
expect(askedLabel).toContain("gui-like.sh");
|
|
81
96
|
expect(result).toEqual({ content: "saved in the GUI", changed: true });
|
|
82
97
|
});
|
|
83
98
|
|
|
84
99
|
test("declining the gate keeps the original content", () => {
|
|
100
|
+
// Act
|
|
85
101
|
const result = editInEditor("keep", "plan.md", {
|
|
86
102
|
editor: stubEditor("gui-like.sh", "exit 0"),
|
|
87
103
|
now: instant(),
|
|
88
104
|
confirmSaved: () => false,
|
|
89
105
|
});
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
90
108
|
expect(result).toEqual({ content: "keep", changed: false });
|
|
91
109
|
});
|
|
92
110
|
|
|
93
111
|
test("a terminal editor that makes no change never opens the gate", () => {
|
|
112
|
+
// Arrange
|
|
94
113
|
let asked = false;
|
|
114
|
+
|
|
115
|
+
// Act
|
|
95
116
|
const result = editInEditor("keep", "plan.md", {
|
|
96
117
|
editor: stubEditor("vi", "exit 0"),
|
|
97
118
|
now: instant(),
|
|
@@ -100,11 +121,14 @@ describe("editInEditor", () => {
|
|
|
100
121
|
return true;
|
|
101
122
|
},
|
|
102
123
|
});
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
103
126
|
expect(asked).toBeFalse();
|
|
104
127
|
expect(result).toEqual({ content: "keep", changed: false });
|
|
105
128
|
});
|
|
106
129
|
|
|
107
130
|
test("a nonzero editor exit throws", () => {
|
|
131
|
+
// Assert
|
|
108
132
|
expect(() => editInEditor("x", "plan.md", { editor: stubEditor("fail.sh", "exit 3") })).toThrow("editor exited 3");
|
|
109
133
|
});
|
|
110
134
|
});
|
package/src/herdr.test.ts
CHANGED
|
@@ -15,6 +15,7 @@ afterEach(() => {
|
|
|
15
15
|
|
|
16
16
|
describe("focusHerdrPane", () => {
|
|
17
17
|
test("resolves the pane's tab and focuses it", () => {
|
|
18
|
+
// Arrange
|
|
18
19
|
const log = join(dir, "calls.log");
|
|
19
20
|
const stub = join(dir, "herdr");
|
|
20
21
|
writeFileSync(
|
|
@@ -25,6 +26,8 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
25
26
|
`,
|
|
26
27
|
);
|
|
27
28
|
Bun.spawnSync(["chmod", "+x", stub]);
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
28
31
|
expect(focusHerdrPane(stub, "w1:p1")).toBe(true);
|
|
29
32
|
const calls = readFileSync(log, "utf8").trim().split("\n");
|
|
30
33
|
expect(calls[0]).toBe("pane get w1:p1");
|
|
@@ -32,6 +35,7 @@ if [ "$1" = "pane" ]; then echo '{"result":{"pane":{"tab_id":"w1:t3"}}}'; fi
|
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
test("a missing binary never throws", () => {
|
|
38
|
+
// Assert
|
|
35
39
|
expect(focusHerdrPane(join(dir, "nope"), "w1:p1")).toBe(false);
|
|
36
40
|
});
|
|
37
41
|
});
|
package/src/index.ts
CHANGED
package/src/integrations.test.ts
CHANGED
|
@@ -9,20 +9,32 @@ function integrations(overrides: Partial<typeof OBSIDIAN_DEFAULTS> = {}): Integr
|
|
|
9
9
|
|
|
10
10
|
describe("loadBundledExporters", () => {
|
|
11
11
|
test("composes the obsidian integration into one named exporter", async () => {
|
|
12
|
+
// Act
|
|
12
13
|
const exporters = await loadBundledExporters(integrations());
|
|
14
|
+
|
|
15
|
+
// Assert
|
|
13
16
|
expect(exporters.map((exporter) => exporter.name)).toEqual(["obsidian"]);
|
|
14
17
|
});
|
|
15
18
|
|
|
16
19
|
test("runsOn mirrors the configured export policy", async () => {
|
|
20
|
+
// Arrange
|
|
17
21
|
const onApprove = (await loadBundledExporters(integrations({ exportOn: "approve" })))[0]!;
|
|
22
|
+
|
|
23
|
+
// Assert
|
|
18
24
|
expect(onApprove.runsOn("approve")).toBeTrue();
|
|
19
25
|
expect(onApprove.runsOn("request_changes")).toBeFalse();
|
|
20
26
|
|
|
27
|
+
// Arrange
|
|
21
28
|
const onResolve = (await loadBundledExporters(integrations({ exportOn: "resolve" })))[0]!;
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
22
31
|
expect(onResolve.runsOn("approve")).toBeTrue();
|
|
23
32
|
expect(onResolve.runsOn("request_changes")).toBeTrue();
|
|
24
33
|
|
|
34
|
+
// Arrange
|
|
25
35
|
const manual = (await loadBundledExporters(integrations({ exportOn: "manual" })))[0]!;
|
|
36
|
+
|
|
37
|
+
// Assert
|
|
26
38
|
expect(manual.runsOn("approve")).toBeFalse();
|
|
27
39
|
expect(manual.runsOn("request_changes")).toBeFalse();
|
|
28
40
|
});
|