@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61
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 +37 -33
- package/src/App.appearance.test.tsx +51 -0
- package/src/App.autoclose.test.tsx +73 -47
- package/src/App.diff.test.tsx +169 -22
- package/src/App.export.test.tsx +49 -37
- package/src/App.plan-review.test.tsx +714 -0
- package/src/App.prototype.test.tsx +194 -0
- package/src/App.readonly.test.tsx +69 -35
- package/src/App.rendering.test.tsx +59 -31
- package/src/App.review-panel.test.tsx +143 -0
- package/src/App.submit-confirm.test.tsx +229 -0
- package/src/App.test.tsx +139 -68
- package/src/App.toast.test.tsx +77 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +270 -0
- package/src/app-view-model.ts +307 -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/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +250 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -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 +129 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffSheet.scroll.test.tsx +135 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +65 -0
- package/src/components/DiffSheet.tsx +374 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +64 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +59 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +109 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +75 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +422 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +80 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +450 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +103 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +318 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +147 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -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-sheet-layout.test.ts +128 -0
- package/src/components/diff-sheet-layout.ts +184 -0
- package/src/components/plan-sheet-run-style.ts +90 -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/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/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 +138 -0
- package/src/components/stories-app.tsx +124 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +75 -0
- package/src/components/syntax-highlight.ts +102 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +193 -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 +448 -31
- package/src/config.ts +339 -57
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +253 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +176 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +135 -9
- package/src/ghostty-terminal.test.ts +84 -0
- package/src/ghostty-terminal.ts +176 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +17 -21
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +499 -0
- package/src/intent-dispatch.ts +585 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +452 -0
- package/src/keymap.test.ts +635 -0
- package/src/keymap.ts +404 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +289 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +237 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +16 -5
- package/src/serve.test.ts +25 -9
- package/src/serve.ts +19 -8
- package/src/session-controller.curate.test.ts +260 -0
- package/src/session-controller.share.test.ts +320 -0
- package/src/session-controller.ts +993 -0
- package/src/share.ts +120 -0
- package/src/test-support.test.ts +60 -0
- package/src/test-support.ts +197 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +187 -0
- package/src/theme.ts +64 -17
- package/src/use-settings-dialog.tsx +275 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +22 -8
- package/src/view-diff.ts +39 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +554 -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 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** Prototype review end to end: a click on the rendered page selects a DOM element, the marker actions bar opens, a typed comment saves through the controller, and the annotation shows in the rail. The headless browser is replaced with a fake so no Chrome or kitty output is needed. Char-frame assertions over the real App and a real in-process daemon. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import {
|
|
13
|
+
setPrototypeRendererFactory,
|
|
14
|
+
type PrototypeElement,
|
|
15
|
+
type PrototypeRenderer,
|
|
16
|
+
} from "./prototype-browser";
|
|
17
|
+
import {
|
|
18
|
+
isolateUserConfig,
|
|
19
|
+
press,
|
|
20
|
+
settle,
|
|
21
|
+
typeText,
|
|
22
|
+
waitForState,
|
|
23
|
+
waitForText,
|
|
24
|
+
waitForTextGone,
|
|
25
|
+
} from "./test-support";
|
|
26
|
+
|
|
27
|
+
const HTML = "<main><div class='card'><h2>Pro</h2><p>$24/mo</p></div></main>";
|
|
28
|
+
|
|
29
|
+
const FAKE_ELEMENT: PrototypeElement = {
|
|
30
|
+
selector: "main > div.card",
|
|
31
|
+
quote: "Pro $24/mo",
|
|
32
|
+
// mid-page, so the popover anchors well away from the click point below
|
|
33
|
+
box: { x: 440, y: 320, width: 320, height: 220 },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let rendered = false;
|
|
37
|
+
let screenshotCount = 0;
|
|
38
|
+
let scrollDeltas: number[] = [];
|
|
39
|
+
const fakeRenderer: PrototypeRenderer = {
|
|
40
|
+
viewport: { width: 1280, height: 800 },
|
|
41
|
+
screenshot: async () => {
|
|
42
|
+
rendered = true;
|
|
43
|
+
screenshotCount += 1;
|
|
44
|
+
|
|
45
|
+
return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
46
|
+
},
|
|
47
|
+
elementAt: async () => FAKE_ELEMENT,
|
|
48
|
+
scrollBy: async (deltaY) => {
|
|
49
|
+
scrollDeltas.push(deltaY);
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
close: async () => undefined,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
let home: string;
|
|
57
|
+
let server: DaemonServer;
|
|
58
|
+
let session: ReviewSession;
|
|
59
|
+
let restoreUserConfig: () => void;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
|
|
63
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
64
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
65
|
+
server.start();
|
|
66
|
+
session = server.core.sessionCreate({
|
|
67
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
68
|
+
artifact: {
|
|
69
|
+
type: "prototype",
|
|
70
|
+
content: HTML,
|
|
71
|
+
meta: { title: "Pricing cards", prototypePath: "/repo/cards.html" },
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
rendered = false;
|
|
75
|
+
screenshotCount = 0;
|
|
76
|
+
scrollDeltas = [];
|
|
77
|
+
setPrototypeRendererFactory(async () => fakeRenderer);
|
|
78
|
+
});
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
setPrototypeRendererFactory(null);
|
|
81
|
+
restoreUserConfig();
|
|
82
|
+
server.stop();
|
|
83
|
+
rmSync(home, { recursive: true, force: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
async function renderApp() {
|
|
87
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
88
|
+
width: 120,
|
|
89
|
+
height: 32,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await waitForText(setup, "cueloop");
|
|
93
|
+
|
|
94
|
+
return setup;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
98
|
+
|
|
99
|
+
async function clickText(setup: Setup, needle: string): Promise<void> {
|
|
100
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
101
|
+
const row = lines.findIndex((line) => line.includes(needle));
|
|
102
|
+
|
|
103
|
+
await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
|
|
104
|
+
await setup.renderOnce();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("prototype review", () => {
|
|
108
|
+
test("click an element, comment on it, and it lands in the rail", async () => {
|
|
109
|
+
// Arrange - wait until the fake browser produced its first screenshot, so
|
|
110
|
+
// the sheet is interactive before the click
|
|
111
|
+
const setup = await renderApp();
|
|
112
|
+
|
|
113
|
+
await waitForState(setup, () => rendered);
|
|
114
|
+
await settle(setup);
|
|
115
|
+
// selecting an element must not re-screenshot the page (the popover-click
|
|
116
|
+
// lag regression): the highlight is the popover, not a fresh Chromium capture
|
|
117
|
+
const screenshotsAfterLoad = screenshotCount;
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
await setup.mockMouse.click(6, 6);
|
|
121
|
+
await waitForText(setup, "comment");
|
|
122
|
+
expect(screenshotCount).toBe(screenshotsAfterLoad);
|
|
123
|
+
await clickText(setup, "comment");
|
|
124
|
+
await waitForText(setup, "write a note");
|
|
125
|
+
// the keymap-suppression flag must propagate before typing so keys reach the
|
|
126
|
+
// textarea, not the global keymap
|
|
127
|
+
await settle(setup);
|
|
128
|
+
await typeText(setup, "tighten the padding");
|
|
129
|
+
await press(setup, "enter");
|
|
130
|
+
|
|
131
|
+
// Assert - the annotation is stored against the element's selector
|
|
132
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
133
|
+
const stored = server.core.sessionGet(session.id).annotations[0]!;
|
|
134
|
+
|
|
135
|
+
expect(stored.kind).toBe("comment");
|
|
136
|
+
expect(stored.body).toBe("tighten the padding");
|
|
137
|
+
expect(stored.anchor.selector).toBe("main > div.card");
|
|
138
|
+
|
|
139
|
+
// and it renders in the rail
|
|
140
|
+
await settle(setup);
|
|
141
|
+
const frame = setup.captureCharFrame();
|
|
142
|
+
|
|
143
|
+
expect(frame).toContain("tighten the padding");
|
|
144
|
+
expect(frame).toContain("COMMENT");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("escape cancels the compose, matching the plan composer", async () => {
|
|
148
|
+
// Arrange
|
|
149
|
+
const setup = await renderApp();
|
|
150
|
+
|
|
151
|
+
await waitForState(setup, () => rendered);
|
|
152
|
+
await settle(setup);
|
|
153
|
+
await setup.mockMouse.click(6, 6);
|
|
154
|
+
await waitForText(setup, "comment");
|
|
155
|
+
await clickText(setup, "comment");
|
|
156
|
+
await waitForText(setup, "write a note");
|
|
157
|
+
await settle(setup);
|
|
158
|
+
|
|
159
|
+
// Act
|
|
160
|
+
await press(setup, "escape");
|
|
161
|
+
|
|
162
|
+
// Assert - the composer closes and nothing was saved
|
|
163
|
+
await waitForTextGone(setup, "write a note");
|
|
164
|
+
expect(server.core.sessionGet(session.id).annotations).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("wheel over the preview scrolls the page down, then up, and clears a selection", async () => {
|
|
168
|
+
// Arrange
|
|
169
|
+
const setup = await renderApp();
|
|
170
|
+
|
|
171
|
+
await waitForState(setup, () => rendered);
|
|
172
|
+
await settle(setup);
|
|
173
|
+
|
|
174
|
+
// open a selection, then scroll: the scroll drops it (it would drift once
|
|
175
|
+
// the page moves under it)
|
|
176
|
+
await setup.mockMouse.click(6, 6);
|
|
177
|
+
await waitForText(setup, "comment");
|
|
178
|
+
// scrolling moves the page, so it DOES re-screenshot (unlike a click)
|
|
179
|
+
const screenshotsBeforeScroll = screenshotCount;
|
|
180
|
+
|
|
181
|
+
await setup.mockMouse.scroll(6, 6, "down");
|
|
182
|
+
await waitForState(setup, () => scrollDeltas.length === 1);
|
|
183
|
+
await waitForTextGone(setup, "comment");
|
|
184
|
+
await waitForState(setup, () => screenshotCount > screenshotsBeforeScroll);
|
|
185
|
+
|
|
186
|
+
// Act - scroll back up
|
|
187
|
+
await setup.mockMouse.scroll(6, 6, "up");
|
|
188
|
+
await waitForState(setup, () => scrollDeltas.length === 2);
|
|
189
|
+
|
|
190
|
+
// Assert - down was a positive delta, up was negative
|
|
191
|
+
expect(scrollDeltas[0]!).toBeGreaterThan(0);
|
|
192
|
+
expect(scrollDeltas[1]!).toBeLessThan(0);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Observer mode (tier 2): the App with readOnly renders and navigates like the
|
|
3
|
-
* controller's, but every mutating verb answers "observer - read-only" and
|
|
4
|
-
* leaves daemon state untouched.
|
|
5
|
-
*/
|
|
1
|
+
/** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating verb answers "observer - read-only" and leaves daemon state untouched. */
|
|
6
2
|
|
|
7
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -13,6 +9,8 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
13
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
10
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
15
11
|
import { App } from "./App";
|
|
12
|
+
import { DARK } from "./theme";
|
|
13
|
+
import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
|
|
16
14
|
|
|
17
15
|
const PLAN = `# Migration Plan
|
|
18
16
|
|
|
@@ -27,19 +25,26 @@ The daemon persists sessions to disk atomically.
|
|
|
27
25
|
`;
|
|
28
26
|
|
|
29
27
|
let home: string;
|
|
28
|
+
let restoreUserConfig: () => void;
|
|
30
29
|
let server: DaemonServer;
|
|
31
30
|
let session: ReviewSession;
|
|
32
31
|
|
|
33
32
|
beforeEach(() => {
|
|
34
33
|
home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
|
|
34
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
35
35
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
36
|
server.start();
|
|
37
37
|
session = server.core.sessionCreate({
|
|
38
38
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
-
artifact: {
|
|
39
|
+
artifact: {
|
|
40
|
+
type: "plan",
|
|
41
|
+
content: PLAN,
|
|
42
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
43
|
+
},
|
|
40
44
|
});
|
|
41
45
|
});
|
|
42
46
|
afterEach(() => {
|
|
47
|
+
restoreUserConfig();
|
|
43
48
|
server.stop();
|
|
44
49
|
rmSync(home, { recursive: true, force: true });
|
|
45
50
|
});
|
|
@@ -49,43 +54,39 @@ async function renderObserver() {
|
|
|
49
54
|
width: 120,
|
|
50
55
|
height: 32,
|
|
51
56
|
});
|
|
52
|
-
for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
|
|
53
|
-
await Bun.sleep(25);
|
|
54
|
-
await setup.renderOnce();
|
|
55
|
-
}
|
|
56
|
-
await setup.renderOnce();
|
|
57
|
-
return setup;
|
|
58
|
-
}
|
|
59
57
|
|
|
60
|
-
|
|
58
|
+
await waitForText(setup, "cueloop");
|
|
61
59
|
|
|
62
|
-
|
|
63
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
64
|
-
else await setup.mockInput.typeText(k);
|
|
65
|
-
await Bun.sleep(15);
|
|
66
|
-
await setup.renderOnce();
|
|
60
|
+
return setup;
|
|
67
61
|
}
|
|
68
62
|
|
|
69
63
|
/** Session state that any mutating verb would change. */
|
|
70
64
|
function snapshot() {
|
|
71
|
-
const
|
|
72
|
-
|
|
65
|
+
const stored = server.core.sessionGet(session.id);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
annotations: stored.annotations.length,
|
|
69
|
+
workingCopy: stored.workingCopy,
|
|
70
|
+
status: stored.status,
|
|
71
|
+
};
|
|
73
72
|
}
|
|
74
73
|
|
|
75
74
|
describe("observer rendering", () => {
|
|
76
|
-
test("shows the observer badge and
|
|
75
|
+
test("shows the observer badge and the bottom menu", async () => {
|
|
76
|
+
// Arrange
|
|
77
77
|
const setup = await renderObserver();
|
|
78
|
+
|
|
79
|
+
// Assert
|
|
78
80
|
const frame = setup.captureCharFrame();
|
|
81
|
+
|
|
79
82
|
expect(frame).toContain("· observer");
|
|
80
|
-
expect(frame).toContain("
|
|
81
|
-
expect(frame).not.toContain("⏎ submit");
|
|
83
|
+
expect(frame).toContain("menu");
|
|
82
84
|
});
|
|
83
85
|
});
|
|
84
86
|
|
|
85
87
|
describe("observer verbs are blocked", () => {
|
|
86
88
|
for (const [key, verb] of [
|
|
87
89
|
["c", "comment"],
|
|
88
|
-
["s", "suggest"],
|
|
89
90
|
["x", "cut"],
|
|
90
91
|
["e", "edit"],
|
|
91
92
|
["enter", "submit"],
|
|
@@ -93,47 +94,58 @@ describe("observer verbs are blocked", () => {
|
|
|
93
94
|
test(`${verb} (${key}) answers observer - read-only and mutates nothing`, async () => {
|
|
94
95
|
const setup = await renderObserver();
|
|
95
96
|
const before = snapshot();
|
|
97
|
+
|
|
96
98
|
await press(setup, "j");
|
|
97
99
|
await press(setup, "j");
|
|
98
100
|
await press(setup, key);
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
const frame = setup.captureCharFrame();
|
|
102
|
-
expect(frame).toContain("observer - read-only");
|
|
101
|
+
const frame = await waitForText(setup, "observer - read-only");
|
|
102
|
+
|
|
103
103
|
// no overlay opened: compose/submit bars never appear
|
|
104
|
-
expect(frame).not.toContain(
|
|
105
|
-
expect(frame).not.toContain("SUGGEST REPLACEMENT");
|
|
104
|
+
expect(frame).not.toContain('comment on "');
|
|
106
105
|
expect(frame).not.toContain("verdict ←/→");
|
|
107
106
|
expect(snapshot()).toEqual(before);
|
|
108
107
|
});
|
|
109
108
|
}
|
|
110
109
|
|
|
111
|
-
test("span mode c
|
|
110
|
+
test("span mode c is blocked too", async () => {
|
|
111
|
+
// Arrange
|
|
112
112
|
const setup = await renderObserver();
|
|
113
|
+
|
|
114
|
+
// Act
|
|
113
115
|
await press(setup, "j");
|
|
114
116
|
await press(setup, "j");
|
|
115
117
|
await press(setup, "v");
|
|
116
118
|
await press(setup, "c");
|
|
117
119
|
await setup.renderOnce();
|
|
120
|
+
|
|
121
|
+
// Assert
|
|
118
122
|
const frame = setup.captureCharFrame();
|
|
123
|
+
|
|
119
124
|
expect(frame).toContain("observer - read-only");
|
|
120
|
-
expect(frame).not.toContain(
|
|
125
|
+
expect(frame).not.toContain('comment on "');
|
|
121
126
|
expect(snapshot().annotations).toBe(0);
|
|
122
127
|
});
|
|
123
128
|
});
|
|
124
129
|
|
|
125
130
|
describe("observer navigation still works", () => {
|
|
126
131
|
test("j/k moves the cursor between blocks", async () => {
|
|
132
|
+
// Arrange
|
|
127
133
|
const setup = await renderObserver();
|
|
134
|
+
|
|
135
|
+
// Act
|
|
128
136
|
await press(setup, "j");
|
|
129
137
|
await press(setup, "j");
|
|
130
138
|
await setup.renderOnce();
|
|
139
|
+
|
|
140
|
+
// Assert
|
|
131
141
|
const lines = setup.captureCharFrame().split("\n");
|
|
132
|
-
const cursorLine = lines.find((
|
|
142
|
+
const cursorLine = lines.find((line) => line.includes("▎"))!;
|
|
143
|
+
|
|
133
144
|
expect(cursorLine).toContain("persists sessions");
|
|
134
145
|
});
|
|
135
146
|
|
|
136
147
|
test("n focuses annotations made by the controller", async () => {
|
|
148
|
+
// Arrange
|
|
137
149
|
server.core.sessionAnnotate(session.id, {
|
|
138
150
|
id: "a_obs1",
|
|
139
151
|
kind: "comment",
|
|
@@ -141,8 +153,30 @@ describe("observer navigation still works", () => {
|
|
|
141
153
|
body: "From the controller.",
|
|
142
154
|
});
|
|
143
155
|
const setup = await renderObserver();
|
|
156
|
+
|
|
157
|
+
// Act
|
|
144
158
|
await press(setup, "n");
|
|
145
159
|
await setup.renderOnce();
|
|
146
|
-
|
|
160
|
+
|
|
161
|
+
// Assert - focus shows as the card's blue border (transparent fill); a
|
|
162
|
+
// comment card is the only blue-bordered box in the frame
|
|
163
|
+
await waitForText(setup, "COMMENT · me");
|
|
164
|
+
await waitForState(setup, () => hasBorderColor(setup, DARK.blue));
|
|
147
165
|
});
|
|
148
166
|
});
|
|
167
|
+
|
|
168
|
+
/** Whether any border character (box-drawing) is painted in the given foreground hex. */
|
|
169
|
+
function hasBorderColor(setup: Awaited<ReturnType<typeof renderObserver>>, hex: string): boolean {
|
|
170
|
+
for (const line of setup.captureSpans().lines) {
|
|
171
|
+
for (const span of line.spans) {
|
|
172
|
+
if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
|
|
173
|
+
const [red, green, blue] = span.fg.toInts();
|
|
174
|
+
const rendered =
|
|
175
|
+
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
176
|
+
|
|
177
|
+
if (rendered === hex) return true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Plan rendering
|
|
1
|
+
/** Plan rendering: code blocks verbatim + contained, spacing never collapses. */
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -9,6 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
9
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
|
+
import { isolateUserConfig, waitForText } from "./test-support";
|
|
12
13
|
|
|
13
14
|
const PLAN = `# Render Plan
|
|
14
15
|
|
|
@@ -29,11 +30,13 @@ Deploys block below the threshold.
|
|
|
29
30
|
`;
|
|
30
31
|
|
|
31
32
|
let home: string;
|
|
33
|
+
let restoreUserConfig: () => void;
|
|
32
34
|
let server: DaemonServer;
|
|
33
35
|
let session: ReviewSession;
|
|
34
36
|
|
|
35
37
|
beforeEach(() => {
|
|
36
38
|
home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
|
|
39
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
37
40
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
38
41
|
server.start();
|
|
39
42
|
session = server.core.sessionCreate({
|
|
@@ -42,67 +45,92 @@ beforeEach(() => {
|
|
|
42
45
|
});
|
|
43
46
|
});
|
|
44
47
|
afterEach(() => {
|
|
48
|
+
restoreUserConfig();
|
|
45
49
|
server.stop();
|
|
46
50
|
rmSync(home, { recursive: true, force: true });
|
|
47
51
|
});
|
|
48
52
|
|
|
49
53
|
async function frame(): Promise<string> {
|
|
50
|
-
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
await
|
|
58
|
-
|
|
54
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
55
|
+
width: 120,
|
|
56
|
+
height: 36,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await waitForText(setup, "Render Plan");
|
|
60
|
+
// async highlights settle within the visual-idle wait
|
|
61
|
+
await setup.waitForVisualIdle();
|
|
62
|
+
|
|
59
63
|
return setup.captureCharFrame();
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
describe("code blocks", () => {
|
|
63
67
|
test("lines render verbatim: indentation preserved, no word-wrap", async () => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
// Arrange
|
|
69
|
+
const rendered = await frame();
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
72
|
+
expect(rendered).toContain(" const blocked = full < 0.6;");
|
|
73
|
+
expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
|
|
67
74
|
});
|
|
68
75
|
|
|
69
76
|
test("the block carries its language tag", async () => {
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
// Arrange
|
|
78
|
+
const rendered = await frame();
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
81
|
+
expect(rendered).toContain("tsx");
|
|
72
82
|
});
|
|
73
83
|
});
|
|
74
84
|
|
|
75
85
|
describe("block spacing", () => {
|
|
86
|
+
// the review panel's full-height divider paints a `│` on every row, so read
|
|
87
|
+
// the plan column (everything left of the divider) before asserting spacing
|
|
88
|
+
// the plan body is framed: content sits between the left border and the next │
|
|
89
|
+
const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
|
|
90
|
+
|
|
76
91
|
test("a code block never glues to the list item above it", async () => {
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
// Arrange
|
|
93
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
94
|
+
const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
|
|
95
|
+
const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
|
|
96
|
+
|
|
97
|
+
// Assert
|
|
98
|
+
expect(listItemLineIndex).toBeGreaterThan(-1);
|
|
99
|
+
expect(codeHeader).toBeGreaterThan(listItemLineIndex);
|
|
100
|
+
expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
|
|
83
101
|
});
|
|
84
102
|
|
|
85
103
|
test("a heading never sits directly on the previous block", async () => {
|
|
86
|
-
|
|
87
|
-
const
|
|
104
|
+
// Arrange
|
|
105
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
106
|
+
const gate = lines.findIndex((line) => line.trim() === "Gate");
|
|
107
|
+
|
|
108
|
+
// Assert
|
|
88
109
|
expect(gate).toBeGreaterThan(0);
|
|
89
110
|
expect(lines[gate - 1]).toBe("");
|
|
90
111
|
});
|
|
91
112
|
|
|
92
113
|
test("consecutive list items stay tight", async () => {
|
|
114
|
+
// Arrange
|
|
93
115
|
const listPlan = "# T\n\n- one\n- two\n";
|
|
94
116
|
const s2 = server.core.sessionCreate({
|
|
95
117
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
96
118
|
artifact: { type: "plan", content: listPlan, meta: {} },
|
|
97
119
|
});
|
|
98
|
-
const setup = await testRender(<App home={home} sessionId={s2.id} />, {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
const setup = await testRender(<App home={home} sessionId={s2.id} />, {
|
|
121
|
+
width: 120,
|
|
122
|
+
height: 30,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await waitForText(setup, "one");
|
|
126
|
+
|
|
127
|
+
// Assert
|
|
128
|
+
const lines = setup
|
|
129
|
+
.captureCharFrame()
|
|
130
|
+
.split("\n")
|
|
131
|
+
.map((line) => line.trimEnd());
|
|
132
|
+
const one = lines.findIndex((line) => line.includes("- one"));
|
|
133
|
+
|
|
106
134
|
expect(lines[one + 1]).toContain("- two");
|
|
107
135
|
});
|
|
108
136
|
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/** The review panel end to end: the b key cycles expanded -> compact -> hidden -> expanded on the live App, and the collapse mode persists to the user config so it survives a restart. Rendering details are locked by the ReviewPanel stories; this suite proves the App wiring and the persistence. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import { loadConfig } from "./config";
|
|
13
|
+
import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
|
|
14
|
+
|
|
15
|
+
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
16
|
+
|
|
17
|
+
let home: string;
|
|
18
|
+
let server: DaemonServer;
|
|
19
|
+
let session: ReviewSession;
|
|
20
|
+
let configPath: string;
|
|
21
|
+
let restoreUserConfig: () => void;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
|
|
25
|
+
configPath = join(home, "config.toml");
|
|
26
|
+
restoreUserConfig = isolateUserConfig(home, "config.toml");
|
|
27
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
28
|
+
server.start();
|
|
29
|
+
session = server.core.sessionCreate({
|
|
30
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
31
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
restoreUserConfig();
|
|
36
|
+
server.stop();
|
|
37
|
+
rmSync(home, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function renderApp() {
|
|
41
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
42
|
+
width: 120,
|
|
43
|
+
height: 30,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await waitForText(setup, "Review");
|
|
47
|
+
|
|
48
|
+
return setup;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("review panel", () => {
|
|
52
|
+
test("b cycles expanded -> compact -> hidden -> expanded", async () => {
|
|
53
|
+
// Arrange
|
|
54
|
+
const setup = await renderApp();
|
|
55
|
+
// the plan frame widens as the rail shrinks: expanded < compact < hidden.
|
|
56
|
+
// its width is the column of its top-right corner (the first ╮ on the row).
|
|
57
|
+
const planWidth = () =>
|
|
58
|
+
setup
|
|
59
|
+
.captureCharFrame()
|
|
60
|
+
.split("\n")
|
|
61
|
+
.find((line) => line.includes("╮"))
|
|
62
|
+
?.indexOf("╮") ?? 0;
|
|
63
|
+
|
|
64
|
+
// Assert: expanded shows the submit button and leaves room for the rail
|
|
65
|
+
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
66
|
+
const expandedWidth = planWidth();
|
|
67
|
+
|
|
68
|
+
// Act
|
|
69
|
+
await press(setup, "b");
|
|
70
|
+
|
|
71
|
+
// Assert: compact drops the submit button and widens the plan (rail is a strip)
|
|
72
|
+
await waitForTextGone(setup, "Submit review");
|
|
73
|
+
const compactWidth = planWidth();
|
|
74
|
+
|
|
75
|
+
expect(compactWidth).toBeGreaterThan(expandedWidth);
|
|
76
|
+
|
|
77
|
+
// Act
|
|
78
|
+
await press(setup, "b");
|
|
79
|
+
await settle(setup);
|
|
80
|
+
|
|
81
|
+
// Assert: hidden spans the plan full width, no rail column
|
|
82
|
+
const hiddenWidth = planWidth();
|
|
83
|
+
|
|
84
|
+
expect(hiddenWidth).toBeGreaterThan(compactWidth);
|
|
85
|
+
expect(hiddenWidth).toBeGreaterThan(115);
|
|
86
|
+
|
|
87
|
+
// Act
|
|
88
|
+
await press(setup, "b");
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
await waitForText(setup, "Submit review"); // back to expanded
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("the collapse mode persists to the user config", async () => {
|
|
95
|
+
// Arrange
|
|
96
|
+
const setup = await renderApp();
|
|
97
|
+
|
|
98
|
+
// Act
|
|
99
|
+
await press(setup, "b"); // -> compact
|
|
100
|
+
|
|
101
|
+
// Assert
|
|
102
|
+
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("] widens the rail and persists the new width", async () => {
|
|
106
|
+
// Arrange
|
|
107
|
+
const setup = await renderApp();
|
|
108
|
+
const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
109
|
+
|
|
110
|
+
// Act
|
|
111
|
+
await press(setup, "]");
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
114
|
+
const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
115
|
+
|
|
116
|
+
expect(widened).toBeGreaterThan(startWidth);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
|
|
120
|
+
// Arrange
|
|
121
|
+
const setup = await renderApp();
|
|
122
|
+
|
|
123
|
+
await press(setup, "b"); // -> compact
|
|
124
|
+
await press(setup, "b"); // -> hidden
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
127
|
+
await waitForTextGone(setup, "Submit review"); // rail is gone
|
|
128
|
+
expect(setup.captureCharFrame()).not.toContain("Submit review");
|
|
129
|
+
|
|
130
|
+
// Act
|
|
131
|
+
await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
134
|
+
await waitForText(setup, "submit review");
|
|
135
|
+
const frame = setup.captureCharFrame();
|
|
136
|
+
|
|
137
|
+
expect(frame).toContain("submit review"); // the confirm card is visible again
|
|
138
|
+
expect(frame).toContain("[Approve]");
|
|
139
|
+
|
|
140
|
+
// the reveal is live-only: the saved panel preference stays as the user left it
|
|
141
|
+
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
|
|
142
|
+
});
|
|
143
|
+
});
|