@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.12
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 +10 -10
- package/src/App.autoclose.test.tsx +24 -35
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -21
- package/src/App.plan-review.test.tsx +21 -52
- package/src/App.readonly.test.tsx +3 -18
- package/src/App.rendering.test.tsx +5 -13
- package/src/App.submit-confirm.test.tsx +7 -25
- package/src/App.test.tsx +13 -52
- package/src/App.tsx +197 -910
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +136 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +50 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +22 -0
- package/src/components/ConfirmCard.tsx +130 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +42 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +309 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +158 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1189 -0
- package/src/components/format.ts +6 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +58 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +52 -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 +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/stories-app.tsx +94 -0
- package/src/components/stories.test.tsx +51 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/key-bindings.test.ts +103 -0
- package/src/key-bindings.ts +332 -0
- package/src/keymap.ts +8 -2
- package/src/run.ts +3 -1
- package/src/session-controller.ts +28 -7
- package/src/test-support.ts +87 -0
- package/src/theme.ts +3 -0
- package/src/view.test.ts +35 -13
- package/src/view.ts +41 -64
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
package/package.json
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.12",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
-
"@opentui/core": "^0.
|
|
15
|
-
"@opentui/
|
|
16
|
-
"@opentui/
|
|
10
|
+
"@cueloop/daemon": "0.1.0-alpha.12",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.12",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.12",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.12",
|
|
14
|
+
"@opentui/core": "^0.5.1",
|
|
15
|
+
"@opentui/keymap": "0.5.1",
|
|
16
|
+
"@opentui/react": "^0.5.1",
|
|
17
|
+
"@opentui/ssh": "0.5.1",
|
|
17
18
|
"@pierre/diffs": "^1.3.5",
|
|
18
|
-
"react": "^19.2.0"
|
|
19
|
-
"shiki": "^4.4.2"
|
|
19
|
+
"react": "^19.2.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/react": "^19.2.0",
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* Post-submit completion overlay: prompt, opt-in persistence, countdown,
|
|
3
|
+
* dismissal. Countdown seconds tick on an injected ManualClock, so the
|
|
4
|
+
* tests advance time instead of waiting it out.
|
|
5
|
+
*/
|
|
2
6
|
|
|
3
7
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
8
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -6,9 +10,11 @@ import { tmpdir } from "node:os";
|
|
|
6
10
|
import { join } from "node:path";
|
|
7
11
|
import React from "react";
|
|
8
12
|
import { testRender } from "@opentui/react/test-utils";
|
|
13
|
+
import { ManualClock } from "@opentui/core/testing";
|
|
9
14
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
15
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
16
|
import { App } from "./App";
|
|
17
|
+
import { press, settle, waitForText } from "./test-support";
|
|
12
18
|
|
|
13
19
|
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
14
20
|
|
|
@@ -16,6 +22,7 @@ let home: string;
|
|
|
16
22
|
let server: DaemonServer;
|
|
17
23
|
let session: ReviewSession;
|
|
18
24
|
let configPath: string;
|
|
25
|
+
let clock: ManualClock;
|
|
19
26
|
|
|
20
27
|
beforeEach(() => {
|
|
21
28
|
home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
|
|
@@ -27,6 +34,7 @@ beforeEach(() => {
|
|
|
27
34
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
28
35
|
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
29
36
|
});
|
|
37
|
+
clock = new ManualClock();
|
|
30
38
|
});
|
|
31
39
|
afterEach(() => {
|
|
32
40
|
delete process.env.CUELOOP_CONFIG;
|
|
@@ -35,34 +43,20 @@ afterEach(() => {
|
|
|
35
43
|
});
|
|
36
44
|
|
|
37
45
|
async function renderApp(onExit?: (code: number) => void) {
|
|
38
|
-
const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} />, {
|
|
46
|
+
const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} clock={clock} />, {
|
|
39
47
|
width: 120,
|
|
40
48
|
height: 30,
|
|
41
49
|
});
|
|
42
|
-
|
|
43
|
-
await Bun.sleep(25);
|
|
44
|
-
await setup.renderOnce();
|
|
45
|
-
}
|
|
50
|
+
await waitForText(setup, "cueloop");
|
|
46
51
|
return setup;
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
50
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
51
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
52
|
-
else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
|
|
53
|
-
else await setup.mockInput.typeText(k);
|
|
54
|
-
await Bun.sleep(15);
|
|
55
|
-
await setup.renderOnce();
|
|
56
|
-
}
|
|
57
55
|
|
|
58
56
|
async function submitApprove(setup: Setup): Promise<void> {
|
|
59
57
|
await press(setup, "enter"); // open submit (approve default: no pending items)
|
|
60
58
|
await press(setup, "enter"); // confirm
|
|
61
|
-
|
|
62
|
-
while (Date.now() < deadline && !setup.captureCharFrame().includes("review approved")) {
|
|
63
|
-
await Bun.sleep(25);
|
|
64
|
-
await setup.renderOnce();
|
|
65
|
-
}
|
|
59
|
+
await waitForText(setup, "review approved");
|
|
66
60
|
}
|
|
67
61
|
|
|
68
62
|
describe("completion overlay", () => {
|
|
@@ -81,24 +75,21 @@ describe("completion overlay", () => {
|
|
|
81
75
|
const setup = await renderApp();
|
|
82
76
|
await submitApprove(setup);
|
|
83
77
|
await press(setup, "a");
|
|
84
|
-
|
|
78
|
+
await waitForText(setup, "closing in 3s");
|
|
85
79
|
expect(existsSync(configPath)).toBe(true);
|
|
86
80
|
expect(readFileSync(configPath, "utf8")).toContain("auto_close = 3");
|
|
81
|
+
// each countdown second is one manual-clock tick, not wall time
|
|
82
|
+
clock.advance(1000);
|
|
83
|
+
await waitForText(setup, "closing in 2s");
|
|
87
84
|
});
|
|
88
85
|
|
|
89
86
|
test("esc dismisses to the resolved read-only view", async () => {
|
|
90
87
|
const setup = await renderApp();
|
|
91
88
|
await submitApprove(setup);
|
|
92
|
-
await press(setup, "escape");
|
|
93
89
|
// a bare ESC sits in the input parser's escape-sequence disambiguation
|
|
94
|
-
// window before it is delivered -
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
await Bun.sleep(50);
|
|
98
|
-
await setup.renderOnce();
|
|
99
|
-
}
|
|
100
|
-
const frame = setup.captureCharFrame();
|
|
101
|
-
expect(frame).toContain("resolved: approve");
|
|
90
|
+
// window before it is delivered - the frame wait absorbs it
|
|
91
|
+
await press(setup, "escape");
|
|
92
|
+
const frame = await waitForText(setup, "resolved: approve");
|
|
102
93
|
expect(frame).not.toContain("always close after submit");
|
|
103
94
|
});
|
|
104
95
|
|
|
@@ -107,12 +98,10 @@ describe("completion overlay", () => {
|
|
|
107
98
|
let exited = -1;
|
|
108
99
|
const setup = await renderApp((code) => (exited = code));
|
|
109
100
|
await submitApprove(setup);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
await setup.renderOnce();
|
|
115
|
-
}
|
|
101
|
+
await waitForText(setup, "closing in 1s");
|
|
102
|
+
expect(exited).toBe(-1);
|
|
103
|
+
clock.advance(1000);
|
|
104
|
+
await settle(setup);
|
|
116
105
|
expect(exited).toBe(0);
|
|
117
|
-
}
|
|
106
|
+
});
|
|
118
107
|
});
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -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 { press, waitForText } from "./test-support";
|
|
12
13
|
|
|
13
14
|
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
14
15
|
index 111..222 100644
|
|
@@ -41,22 +42,10 @@ afterEach(() => {
|
|
|
41
42
|
|
|
42
43
|
async function renderApp() {
|
|
43
44
|
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
|
|
44
|
-
|
|
45
|
-
await Bun.sleep(25);
|
|
46
|
-
await setup.renderOnce();
|
|
47
|
-
}
|
|
48
|
-
await setup.renderOnce();
|
|
45
|
+
await waitForText(setup, "cueloop");
|
|
49
46
|
return setup;
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
53
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
54
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
55
|
-
else await setup.mockInput.typeText(k);
|
|
56
|
-
await Bun.sleep(15);
|
|
57
|
-
await setup.renderOnce();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
49
|
describe("diff review", () => {
|
|
61
50
|
test("renders file header, hunks, and signed lines", async () => {
|
|
62
51
|
const setup = await renderApp();
|
|
@@ -75,17 +64,15 @@ describe("diff review", () => {
|
|
|
75
64
|
expect(setup.captureCharFrame()).toContain("COMMENT ON");
|
|
76
65
|
await setup.mockInput.typeText("Map needs an eviction story.");
|
|
77
66
|
await press(setup, "enter");
|
|
78
|
-
|
|
79
|
-
await setup.
|
|
67
|
+
// inline annotation card rendered under the line
|
|
68
|
+
await waitForText(setup, "◆ Map needs an eviction story.");
|
|
80
69
|
const stored = server.core.sessionGet(session.id);
|
|
81
70
|
expect(stored.annotations.length).toBe(1);
|
|
82
71
|
expect(stored.annotations[0]!.anchor.quote).toContain("new Map()");
|
|
83
|
-
// inline annotation card rendered under the line
|
|
84
|
-
expect(setup.captureCharFrame()).toContain("◆ Map needs an eviction story.");
|
|
85
72
|
|
|
86
73
|
await press(setup, "enter"); // submit
|
|
87
74
|
await press(setup, "enter"); // confirm request_changes
|
|
88
|
-
await
|
|
75
|
+
await waitForText(setup, "✎ feedback sent");
|
|
89
76
|
const resolved = server.core.sessionGet(session.id);
|
|
90
77
|
expect(resolved.verdict!.feedback).toContain("new Map()");
|
|
91
78
|
expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
|
package/src/App.export.test.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
13
13
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
14
|
import type { ReviewSession } from "@cueloop/schema";
|
|
15
15
|
import { App } from "./App";
|
|
16
|
+
import { press, waitForState, waitForText } from "./test-support";
|
|
16
17
|
|
|
17
18
|
const PLAN = `# Migration Plan
|
|
18
19
|
|
|
@@ -50,30 +51,16 @@ afterEach(() => {
|
|
|
50
51
|
describe("obsidian export on resolve", () => {
|
|
51
52
|
test("submitting a review writes the plan into the vault and shows the path", async () => {
|
|
52
53
|
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
|
|
53
|
-
|
|
54
|
-
await Bun.sleep(25);
|
|
55
|
-
await setup.renderOnce();
|
|
56
|
-
}
|
|
54
|
+
await waitForText(setup, "cueloop");
|
|
57
55
|
// open the submit overlay, keep the default verdict (approve), submit
|
|
58
|
-
setup
|
|
59
|
-
await Bun.sleep(15);
|
|
60
|
-
await setup.renderOnce();
|
|
56
|
+
await press(setup, "enter");
|
|
61
57
|
expect(setup.captureCharFrame()).toContain("verdict");
|
|
62
|
-
setup
|
|
63
|
-
//
|
|
64
|
-
//
|
|
58
|
+
await press(setup, "enter");
|
|
59
|
+
// the export is an async round-trip after resolve; the status line lands
|
|
60
|
+
// on a later render than the file write
|
|
65
61
|
const dir = join(vault, "cueloop");
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
await Bun.sleep(25);
|
|
69
|
-
await setup.renderOnce();
|
|
70
|
-
}
|
|
71
|
-
// the status line lands on a later render than the file write
|
|
72
|
-
const statusDeadline = Date.now() + 30_000;
|
|
73
|
-
while (Date.now() < statusDeadline && !setup.captureCharFrame().includes("exported to")) {
|
|
74
|
-
await Bun.sleep(25);
|
|
75
|
-
await setup.renderOnce();
|
|
76
|
-
}
|
|
62
|
+
await waitForState(setup, () => existsSync(dir));
|
|
63
|
+
await waitForText(setup, "exported to");
|
|
77
64
|
|
|
78
65
|
const notes = readdirSync(dir);
|
|
79
66
|
expect(notes.length).toBe(1);
|
|
@@ -16,6 +16,7 @@ import { DaemonServer } from "@cueloop/daemon";
|
|
|
16
16
|
import type { ReviewSession } from "@cueloop/schema";
|
|
17
17
|
import { App } from "./App";
|
|
18
18
|
import { DARK as T } from "./theme";
|
|
19
|
+
import { press, settle, typeText as type, waitForState, waitForText, waitForTextGone } from "./test-support";
|
|
19
20
|
|
|
20
21
|
const PLAN = `# Migration Plan
|
|
21
22
|
|
|
@@ -52,33 +53,12 @@ async function renderApp() {
|
|
|
52
53
|
width: 120,
|
|
53
54
|
height: 32,
|
|
54
55
|
});
|
|
55
|
-
|
|
56
|
-
await Bun.sleep(25);
|
|
57
|
-
await setup.renderOnce();
|
|
58
|
-
}
|
|
59
|
-
await setup.renderOnce();
|
|
56
|
+
await waitForText(setup, "cueloop");
|
|
60
57
|
return setup;
|
|
61
58
|
}
|
|
62
59
|
|
|
63
60
|
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
64
61
|
|
|
65
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
66
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
67
|
-
else if (k === "escape") {
|
|
68
|
-
setup.mockInput.pressKey("ESCAPE");
|
|
69
|
-
// a bare ESC byte is an ambiguous sequence prefix; the parser settles late
|
|
70
|
-
await Bun.sleep(120);
|
|
71
|
-
} else await type(setup, k);
|
|
72
|
-
await Bun.sleep(15);
|
|
73
|
-
await setup.renderOnce();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
async function type(setup: Setup, text: string): Promise<void> {
|
|
77
|
-
await setup.mockInput.typeText(text);
|
|
78
|
-
await Bun.sleep(15);
|
|
79
|
-
await setup.renderOnce();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
62
|
/**
|
|
83
63
|
* Background colors (hex) of every styled span containing the needle - the
|
|
84
64
|
* document highlight and the rail's quote excerpt can both match.
|
|
@@ -111,10 +91,8 @@ describe("selection feeds the annotation quote", () => {
|
|
|
111
91
|
expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
|
|
112
92
|
await type(setup, "Which daemon?");
|
|
113
93
|
await press(setup, "enter");
|
|
114
|
-
await
|
|
115
|
-
await setup.renderOnce();
|
|
94
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
116
95
|
const stored = server.core.sessionGet(session.id);
|
|
117
|
-
expect(stored.annotations.length).toBe(1);
|
|
118
96
|
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
119
97
|
});
|
|
120
98
|
|
|
@@ -126,12 +104,12 @@ describe("selection feeds the annotation quote", () => {
|
|
|
126
104
|
expect(row).toBeGreaterThan(-1);
|
|
127
105
|
// the drag's end cell is exclusive: release one past the last character
|
|
128
106
|
await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
|
|
129
|
-
await setup
|
|
107
|
+
await settle(setup);
|
|
130
108
|
await press(setup, "c");
|
|
131
109
|
expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
|
|
132
110
|
await type(setup, "sessions plural?");
|
|
133
111
|
await press(setup, "enter");
|
|
134
|
-
await
|
|
112
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
135
113
|
const stored = server.core.sessionGet(session.id);
|
|
136
114
|
expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
|
|
137
115
|
});
|
|
@@ -148,16 +126,16 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
148
126
|
expect(setup.captureCharFrame()).toContain("Save ⏎");
|
|
149
127
|
expect(setup.captureCharFrame()).toContain("Cancel esc");
|
|
150
128
|
expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
|
|
151
|
-
// cancel un-paints
|
|
129
|
+
// cancel un-paints (a bare ESC settles after the parser's escape window)
|
|
152
130
|
await press(setup, "escape");
|
|
153
|
-
|
|
131
|
+
await waitForTextGone(setup, 'comment on "');
|
|
154
132
|
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
|
|
155
133
|
// save converts the paint to the kind-colored annotation highlight
|
|
156
134
|
await press(setup, "c");
|
|
157
135
|
await type(setup, "Which daemon?");
|
|
158
136
|
await press(setup, "enter");
|
|
159
|
-
await
|
|
160
|
-
await setup
|
|
137
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
138
|
+
await settle(setup);
|
|
161
139
|
// the whole cursor block is the anchor here; check the highlight landed
|
|
162
140
|
const stored = server.core.sessionGet(session.id);
|
|
163
141
|
expect(stored.annotations.length).toBe(1);
|
|
@@ -172,18 +150,15 @@ describe("the document selects, the rail edits", () => {
|
|
|
172
150
|
await press(setup, "c");
|
|
173
151
|
await type(setup, "Needs a citation.");
|
|
174
152
|
await press(setup, "enter");
|
|
175
|
-
await
|
|
176
|
-
await setup.renderOnce();
|
|
177
|
-
expect(setup.captureCharFrame()).toContain("COMMENT · pending");
|
|
153
|
+
await waitForText(setup, "COMMENT · pending");
|
|
178
154
|
// the saved card is selected; e turns its body into an input in place
|
|
179
155
|
await press(setup, "e");
|
|
180
156
|
await type(setup, " And a link.");
|
|
181
157
|
await press(setup, "enter");
|
|
182
|
-
await
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
expect(stored.annotations[0]!.body).toBe("Needs a citation. And a link.");
|
|
158
|
+
await waitForState(
|
|
159
|
+
setup,
|
|
160
|
+
() => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
|
|
161
|
+
);
|
|
187
162
|
});
|
|
188
163
|
|
|
189
164
|
test("x deletes the selected card and un-paints the document highlight", async () => {
|
|
@@ -192,15 +167,12 @@ describe("the document selects, the rail edits", () => {
|
|
|
192
167
|
await press(setup, "c");
|
|
193
168
|
await type(setup, "Delete me.");
|
|
194
169
|
await press(setup, "enter");
|
|
195
|
-
await
|
|
196
|
-
await setup.renderOnce();
|
|
170
|
+
await waitForText(setup, "COMMENT · pending");
|
|
197
171
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
198
172
|
expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
|
|
199
173
|
await press(setup, "x");
|
|
200
|
-
await
|
|
201
|
-
await setup.renderOnce();
|
|
174
|
+
await waitForTextGone(setup, "COMMENT · pending");
|
|
202
175
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
203
|
-
expect(setup.captureCharFrame()).not.toContain("COMMENT · pending");
|
|
204
176
|
expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
|
|
205
177
|
});
|
|
206
178
|
});
|
|
@@ -239,17 +211,14 @@ describe("edit-exit reconciliation", () => {
|
|
|
239
211
|
await press(setup, "c");
|
|
240
212
|
await type(setup, "Anchor me to the doomed passage.");
|
|
241
213
|
await press(setup, "enter");
|
|
242
|
-
await
|
|
243
|
-
await setup.renderOnce();
|
|
244
|
-
expect(setup.captureCharFrame()).toContain("· pending");
|
|
214
|
+
await waitForText(setup, "· pending");
|
|
245
215
|
// deselect the card so e reaches the editor hand-off, then edit
|
|
246
216
|
await press(setup, "escape");
|
|
217
|
+
await waitForTextGone(setup, "▸ COMMENT");
|
|
247
218
|
await press(setup, "e");
|
|
248
|
-
await
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
expect(frame).toContain("1 annotation no longer match - the passage was removed.");
|
|
252
|
-
expect(frame).toContain("· ORPHANED");
|
|
219
|
+
await waitForText(setup, "1 annotation no longer match - the passage was removed.");
|
|
220
|
+
// the rail card flips to ORPHANED once the working-copy refresh lands
|
|
221
|
+
await waitForText(setup, "· ORPHANED");
|
|
253
222
|
// the annotation is NOT deleted: the feedback serializer handles orphans
|
|
254
223
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
255
224
|
} finally {
|
|
@@ -13,6 +13,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
13
13
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
14
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
15
15
|
import { App } from "./App";
|
|
16
|
+
import { press, waitForText } from "./test-support";
|
|
16
17
|
|
|
17
18
|
const PLAN = `# Migration Plan
|
|
18
19
|
|
|
@@ -49,23 +50,10 @@ async function renderObserver() {
|
|
|
49
50
|
width: 120,
|
|
50
51
|
height: 32,
|
|
51
52
|
});
|
|
52
|
-
|
|
53
|
-
await Bun.sleep(25);
|
|
54
|
-
await setup.renderOnce();
|
|
55
|
-
}
|
|
56
|
-
await setup.renderOnce();
|
|
53
|
+
await waitForText(setup, "cueloop");
|
|
57
54
|
return setup;
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
type Setup = Awaited<ReturnType<typeof renderObserver>>;
|
|
61
|
-
|
|
62
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
63
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
64
|
-
else await setup.mockInput.typeText(k);
|
|
65
|
-
await Bun.sleep(15);
|
|
66
|
-
await setup.renderOnce();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
57
|
/** Session state that any mutating verb would change. */
|
|
70
58
|
function snapshot() {
|
|
71
59
|
const s = server.core.sessionGet(session.id);
|
|
@@ -96,10 +84,7 @@ describe("observer verbs are blocked", () => {
|
|
|
96
84
|
await press(setup, "j");
|
|
97
85
|
await press(setup, "j");
|
|
98
86
|
await press(setup, key);
|
|
99
|
-
await
|
|
100
|
-
await setup.renderOnce();
|
|
101
|
-
const frame = setup.captureCharFrame();
|
|
102
|
-
expect(frame).toContain("observer - read-only");
|
|
87
|
+
const frame = await waitForText(setup, "observer - read-only");
|
|
103
88
|
// no overlay opened: compose/submit bars never appear
|
|
104
89
|
expect(frame).not.toContain("COMMENT ON");
|
|
105
90
|
expect(frame).not.toContain("SUGGEST REPLACEMENT");
|
|
@@ -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 { waitForText } from "./test-support";
|
|
12
13
|
|
|
13
14
|
const PLAN = `# Render Plan
|
|
14
15
|
|
|
@@ -48,14 +49,9 @@ afterEach(() => {
|
|
|
48
49
|
|
|
49
50
|
async function frame(): Promise<string> {
|
|
50
51
|
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
await setup.renderOnce();
|
|
55
|
-
}
|
|
56
|
-
// give the async highlighter a beat, then take the final frame
|
|
57
|
-
await Bun.sleep(150);
|
|
58
|
-
await setup.renderOnce();
|
|
52
|
+
await waitForText(setup, "Render Plan");
|
|
53
|
+
// async highlights settle within the visual-idle wait
|
|
54
|
+
await setup.waitForVisualIdle();
|
|
59
55
|
return setup.captureCharFrame();
|
|
60
56
|
}
|
|
61
57
|
|
|
@@ -96,11 +92,7 @@ describe("block spacing", () => {
|
|
|
96
92
|
artifact: { type: "plan", content: listPlan, meta: {} },
|
|
97
93
|
});
|
|
98
94
|
const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
|
|
99
|
-
|
|
100
|
-
while (Date.now() < deadline && !setup.captureCharFrame().includes("one")) {
|
|
101
|
-
await Bun.sleep(25);
|
|
102
|
-
await setup.renderOnce();
|
|
103
|
-
}
|
|
95
|
+
await waitForText(setup, "one");
|
|
104
96
|
const lines = setup.captureCharFrame().split("\n").map((l) => l.trimEnd());
|
|
105
97
|
const one = lines.findIndex((l) => l.includes("- one"));
|
|
106
98
|
expect(lines[one + 1]).toContain("- two");
|
|
@@ -14,6 +14,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
14
14
|
import { DaemonServer } from "@cueloop/daemon";
|
|
15
15
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
16
16
|
import { App } from "./App";
|
|
17
|
+
import { press, settle, waitForText, waitForTextGone } from "./test-support";
|
|
17
18
|
|
|
18
19
|
const PLAN = `# Migration Plan
|
|
19
20
|
|
|
@@ -50,29 +51,12 @@ async function renderApp(options: { readOnly?: boolean } = {}) {
|
|
|
50
51
|
width: 120,
|
|
51
52
|
height: 32,
|
|
52
53
|
});
|
|
53
|
-
|
|
54
|
-
await Bun.sleep(25);
|
|
55
|
-
await setup.renderOnce();
|
|
56
|
-
}
|
|
57
|
-
await setup.renderOnce();
|
|
54
|
+
await waitForText(setup, "cueloop");
|
|
58
55
|
return setup;
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
62
59
|
|
|
63
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
64
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
65
|
-
else if (k === "escape") {
|
|
66
|
-
setup.mockInput.pressKey("ESCAPE");
|
|
67
|
-
// a bare ESC byte is an ambiguous sequence prefix; the parser settles late
|
|
68
|
-
await Bun.sleep(120);
|
|
69
|
-
} else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
|
|
70
|
-
else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
|
|
71
|
-
else await setup.mockInput.typeText(k);
|
|
72
|
-
await Bun.sleep(15);
|
|
73
|
-
await setup.renderOnce();
|
|
74
|
-
}
|
|
75
|
-
|
|
76
60
|
/** Seed annotations directly through the daemon core (all on one block). */
|
|
77
61
|
function seedAnnotations(count: number): void {
|
|
78
62
|
const blocks = parseBlocks(PLAN);
|
|
@@ -121,8 +105,8 @@ describe("rail submit confirm", () => {
|
|
|
121
105
|
await press(setup, "enter");
|
|
122
106
|
expect(setup.captureCharFrame()).toContain("[Approve]");
|
|
123
107
|
await press(setup, "escape");
|
|
124
|
-
|
|
125
|
-
|
|
108
|
+
// a bare ESC settles after the parser's escape-sequence window
|
|
109
|
+
const frame = await waitForTextGone(setup, "[Approve]");
|
|
126
110
|
expect(frame).not.toContain("0 annotations");
|
|
127
111
|
expect(frame).toContain("Submit review (0)");
|
|
128
112
|
});
|
|
@@ -136,13 +120,11 @@ describe("rail submit confirm", () => {
|
|
|
136
120
|
expect(frame).toContain("[Changes]"); // pending items: request changes default
|
|
137
121
|
await setup.mockInput.typeText("Tighten the steps.");
|
|
138
122
|
await press(setup, "enter");
|
|
139
|
-
|
|
140
|
-
await setup
|
|
123
|
+
// the completion flow after submit is unchanged
|
|
124
|
+
await waitForText(setup, "✎ feedback sent");
|
|
141
125
|
const stored = server.core.sessionGet(session.id);
|
|
142
126
|
expect(stored.status).toBe("resolved");
|
|
143
127
|
expect(stored.verdict!.kind).toBe("request_changes");
|
|
144
|
-
// the completion flow after submit is unchanged
|
|
145
|
-
expect(setup.captureCharFrame()).toContain("✎ feedback sent");
|
|
146
128
|
});
|
|
147
129
|
|
|
148
130
|
test("the annotation stack stays scrollable while the card is open", async () => {
|
|
@@ -162,7 +144,7 @@ describe("rail submit confirm", () => {
|
|
|
162
144
|
// the stack still scrolls with the card open: wheel over the rail
|
|
163
145
|
// brings the last card back into view while the confirm card stays put
|
|
164
146
|
for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
|
|
165
|
-
await setup
|
|
147
|
+
await settle(setup);
|
|
166
148
|
const scrolledWithCard = setup.captureCharFrame();
|
|
167
149
|
expect(scrolledWithCard).toContain("note 12");
|
|
168
150
|
expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
|