revspec 0.6.0 → 0.7.1
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/README.md +60 -68
- package/bin/revspec.ts +4 -38
- package/package.json +15 -1
- package/skills/revspec/SKILL.md +38 -31
- package/src/cli/reply.ts +1 -1
- package/src/cli/watch.ts +122 -58
- package/src/protocol/live-events.ts +6 -16
- package/src/state/review-state.ts +37 -24
- package/src/tui/app.ts +145 -108
- package/src/tui/comment-input.ts +9 -13
- package/src/tui/confirm.ts +4 -6
- package/src/tui/help.ts +13 -16
- package/src/tui/spinner.ts +81 -0
- package/src/tui/status-bar.ts +9 -6
- package/src/tui/thread-list.ts +62 -22
- package/src/tui/ui/keymap.ts +55 -0
- package/.github/workflows/ci.yml +0 -18
- package/CLAUDE.md +0 -29
- package/bun.lock +0 -216
- package/docs/superpowers/plans/2026-03-14-live-ai-integration.md +0 -1877
- package/docs/superpowers/plans/2026-03-14-spectral-v1-implementation.md +0 -2139
- package/docs/superpowers/plans/2026-03-15-ui-refactor.md +0 -1025
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.md +0 -518
- package/docs/superpowers/specs/2026-03-14-live-ai-integration-design.review.json +0 -65
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.md +0 -331
- package/docs/superpowers/specs/2026-03-14-spec-review-tool-design.review.json +0 -141
- package/docs/superpowers/specs/claude-code-integration-notes.md +0 -26
- package/scripts/install-skill.sh +0 -20
- package/scripts/release.sh +0 -52
- package/test/e2e/__snapshots__/snapshot.test.ts.snap +0 -31
- package/test/e2e/fixtures/spec.md +0 -36
- package/test/e2e/harness.ts +0 -80
- package/test/e2e/snapshot.test.ts +0 -182
- package/test/integration/cli-reply.test.ts +0 -140
- package/test/integration/cli-watch.test.ts +0 -216
- package/test/integration/cli.test.ts +0 -160
- package/test/integration/e2e-live.test.ts +0 -171
- package/test/integration/live-interaction.test.ts +0 -398
- package/test/integration/opentui-smoke.test.ts +0 -12
- package/test/unit/protocol/live-events.test.ts +0 -509
- package/test/unit/protocol/live-merge.test.ts +0 -167
- package/test/unit/protocol/merge.test.ts +0 -100
- package/test/unit/protocol/read.test.ts +0 -92
- package/test/unit/protocol/types.test.ts +0 -95
- package/test/unit/protocol/write.test.ts +0 -72
- package/test/unit/state/review-state.test.ts +0 -399
- package/test/unit/tui/pager.test.ts +0 -159
- package/test/unit/tui/ui/keybinds.test.ts +0 -71
- package/tsconfig.json +0 -14
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
-
import { join, resolve } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
import { appendEvent, readEventsFromOffset } from "../../src/protocol/live-events";
|
|
6
|
-
|
|
7
|
-
const CLI = resolve(import.meta.dir, "../../bin/revspec.ts");
|
|
8
|
-
|
|
9
|
-
interface SpawnResult {
|
|
10
|
-
exitCode: number;
|
|
11
|
-
stdout: string;
|
|
12
|
-
stderr: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
async function runCli(
|
|
16
|
-
args: string[],
|
|
17
|
-
env: Record<string, string> = {}
|
|
18
|
-
): Promise<SpawnResult> {
|
|
19
|
-
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
20
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1", ...env },
|
|
21
|
-
stdout: "pipe",
|
|
22
|
-
stderr: "pipe",
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
const [stdout, stderr] = await Promise.all([
|
|
26
|
-
new Response(proc.stdout).text(),
|
|
27
|
-
new Response(proc.stderr).text(),
|
|
28
|
-
]);
|
|
29
|
-
|
|
30
|
-
const exitCode = await proc.exited;
|
|
31
|
-
return { exitCode, stdout, stderr };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function setupTempDir(): string {
|
|
35
|
-
return mkdtempSync(join(tmpdir(), "revspec-watch-test-"));
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function createSpecWithJsonl(
|
|
39
|
-
dir: string,
|
|
40
|
-
specContent: string = "# My Spec\n\nLine two\n\nLine four\n"
|
|
41
|
-
): { specPath: string; jsonlPath: string } {
|
|
42
|
-
const specPath = join(dir, "spec.md");
|
|
43
|
-
writeFileSync(specPath, specContent);
|
|
44
|
-
|
|
45
|
-
const jsonlPath = join(dir, "spec.review.live.jsonl");
|
|
46
|
-
|
|
47
|
-
return { specPath, jsonlPath };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
describe("revspec watch", () => {
|
|
51
|
-
let tmpDir: string;
|
|
52
|
-
|
|
53
|
-
afterEach(() => {
|
|
54
|
-
if (tmpDir) {
|
|
55
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("returns new comments with context", async () => {
|
|
60
|
-
tmpDir = setupTempDir();
|
|
61
|
-
const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
|
|
62
|
-
|
|
63
|
-
const threadId = "thread-001";
|
|
64
|
-
appendEvent(jsonlPath, {
|
|
65
|
-
type: "comment",
|
|
66
|
-
threadId,
|
|
67
|
-
line: 3,
|
|
68
|
-
author: "reviewer",
|
|
69
|
-
text: "This needs clarification",
|
|
70
|
-
ts: Date.now(),
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const result = await runCli(["watch", specPath]);
|
|
74
|
-
|
|
75
|
-
expect(result.exitCode).toBe(0);
|
|
76
|
-
expect(result.stdout).toContain(threadId);
|
|
77
|
-
expect(result.stdout).toContain("line 3");
|
|
78
|
-
expect(result.stdout).toContain("This needs clarification");
|
|
79
|
-
expect(result.stdout).toContain("revspec reply");
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("returns approval message when approve event present", async () => {
|
|
83
|
-
tmpDir = setupTempDir();
|
|
84
|
-
const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
|
|
85
|
-
|
|
86
|
-
appendEvent(jsonlPath, {
|
|
87
|
-
type: "approve",
|
|
88
|
-
author: "reviewer",
|
|
89
|
-
ts: Date.now(),
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
const result = await runCli(["watch", specPath]);
|
|
93
|
-
|
|
94
|
-
expect(result.exitCode).toBe(0);
|
|
95
|
-
expect(result.stdout).toContain("Review approved");
|
|
96
|
-
expect(result.stdout).toContain("spec.review.json");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("includes thread history for replies", async () => {
|
|
100
|
-
tmpDir = setupTempDir();
|
|
101
|
-
const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
|
|
102
|
-
|
|
103
|
-
const threadId = "thread-002";
|
|
104
|
-
const ts = Date.now();
|
|
105
|
-
|
|
106
|
-
appendEvent(jsonlPath, {
|
|
107
|
-
type: "comment",
|
|
108
|
-
threadId,
|
|
109
|
-
line: 1,
|
|
110
|
-
author: "reviewer",
|
|
111
|
-
text: "Initial comment",
|
|
112
|
-
ts,
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
appendEvent(jsonlPath, {
|
|
116
|
-
type: "reply",
|
|
117
|
-
threadId,
|
|
118
|
-
author: "owner",
|
|
119
|
-
text: "Thanks, addressing it",
|
|
120
|
-
ts: ts + 1000,
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
appendEvent(jsonlPath, {
|
|
124
|
-
type: "reply",
|
|
125
|
-
threadId,
|
|
126
|
-
author: "reviewer",
|
|
127
|
-
text: "Great, looks good now",
|
|
128
|
-
ts: ts + 2000,
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
// Watch starting at offset 0 should see new comment + replies
|
|
132
|
-
const result = await runCli(["watch", specPath]);
|
|
133
|
-
|
|
134
|
-
expect(result.exitCode).toBe(0);
|
|
135
|
-
// Should show full thread history in replies section
|
|
136
|
-
expect(result.stdout).toContain(threadId);
|
|
137
|
-
expect(result.stdout).toContain("Initial comment");
|
|
138
|
-
expect(result.stdout).toContain("Thanks, addressing it");
|
|
139
|
-
expect(result.stdout).toContain("Great, looks good now");
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
it("does not break watch loop for resolve-only events", async () => {
|
|
143
|
-
tmpDir = setupTempDir();
|
|
144
|
-
const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
|
|
145
|
-
|
|
146
|
-
const threadId = "thread-003";
|
|
147
|
-
const ts = Date.now();
|
|
148
|
-
|
|
149
|
-
// Write comment then resolve
|
|
150
|
-
appendEvent(jsonlPath, {
|
|
151
|
-
type: "comment",
|
|
152
|
-
threadId,
|
|
153
|
-
line: 2,
|
|
154
|
-
author: "reviewer",
|
|
155
|
-
text: "Fix this please",
|
|
156
|
-
ts,
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
// First watch picks up the comment
|
|
160
|
-
const result1 = await runCli(["watch", specPath]);
|
|
161
|
-
expect(result1.exitCode).toBe(0);
|
|
162
|
-
expect(result1.stdout).toContain("Fix this please");
|
|
163
|
-
|
|
164
|
-
// Append resolve — watch should return empty (no actionable events)
|
|
165
|
-
appendEvent(jsonlPath, {
|
|
166
|
-
type: "resolve",
|
|
167
|
-
threadId,
|
|
168
|
-
author: "reviewer",
|
|
169
|
-
ts: ts + 1000,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const result2 = await runCli(["watch", specPath]);
|
|
173
|
-
// No output — resolve is not actionable
|
|
174
|
-
expect(result2.stdout.trim()).toBe("");
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it("exits 1 for missing spec file", async () => {
|
|
178
|
-
tmpDir = setupTempDir();
|
|
179
|
-
const missingPath = join(tmpDir, "nonexistent.md");
|
|
180
|
-
|
|
181
|
-
const result = await runCli(["watch", missingPath]);
|
|
182
|
-
|
|
183
|
-
expect(result.exitCode).toBe(1);
|
|
184
|
-
expect(result.stderr).toContain("not found");
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it("exits 3 when another watch is running (lock file with live PID)", async () => {
|
|
188
|
-
tmpDir = setupTempDir();
|
|
189
|
-
const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
|
|
190
|
-
const lockPath = join(tmpDir, "spec.review.live.lock");
|
|
191
|
-
writeFileSync(lockPath, String(process.pid)); // current process is alive
|
|
192
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "x", ts: 1 });
|
|
193
|
-
|
|
194
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
195
|
-
stdout: "pipe", stderr: "pipe",
|
|
196
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
197
|
-
});
|
|
198
|
-
await proc.exited;
|
|
199
|
-
expect(proc.exitCode).toBe(3);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("proceeds when lock file has dead PID", async () => {
|
|
203
|
-
tmpDir = setupTempDir();
|
|
204
|
-
const { specPath, jsonlPath } = createSpecWithJsonl(tmpDir);
|
|
205
|
-
const lockPath = join(tmpDir, "spec.review.live.lock");
|
|
206
|
-
writeFileSync(lockPath, "999999"); // almost certainly dead
|
|
207
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 1, author: "reviewer", text: "x", ts: 1 });
|
|
208
|
-
|
|
209
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
210
|
-
stdout: "pipe", stderr: "pipe",
|
|
211
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
212
|
-
});
|
|
213
|
-
await proc.exited;
|
|
214
|
-
expect(proc.exitCode).toBe(0);
|
|
215
|
-
});
|
|
216
|
-
});
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
3
|
-
import { join, resolve } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
import { appendEvent } from "../../src/protocol/live-events";
|
|
6
|
-
import { writeReviewFile } from "../../src/protocol/write";
|
|
7
|
-
|
|
8
|
-
const CLI = resolve(import.meta.dir, "../../bin/revspec.ts");
|
|
9
|
-
|
|
10
|
-
interface SpawnResult {
|
|
11
|
-
exitCode: number;
|
|
12
|
-
stdout: string;
|
|
13
|
-
stderr: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
async function runCli(
|
|
17
|
-
args: string[],
|
|
18
|
-
env: Record<string, string> = {}
|
|
19
|
-
): Promise<SpawnResult> {
|
|
20
|
-
const proc = Bun.spawn(["bun", "run", CLI, ...args], {
|
|
21
|
-
env: { ...process.env, REVSPEC_SKIP_TUI: "1", ...env },
|
|
22
|
-
stdout: "pipe",
|
|
23
|
-
stderr: "pipe",
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
const [stdout, stderr] = await Promise.all([
|
|
27
|
-
new Response(proc.stdout).text(),
|
|
28
|
-
new Response(proc.stderr).text(),
|
|
29
|
-
]);
|
|
30
|
-
|
|
31
|
-
const exitCode = await proc.exited;
|
|
32
|
-
return { exitCode, stdout, stderr };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe("CLI entry point", () => {
|
|
36
|
-
let tmpDir: string;
|
|
37
|
-
|
|
38
|
-
// Create a fresh temp dir before each test
|
|
39
|
-
function setup(): string {
|
|
40
|
-
tmpDir = mkdtempSync(join(tmpdir(), "revspec-test-"));
|
|
41
|
-
return tmpDir;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
afterEach(() => {
|
|
45
|
-
if (tmpDir) {
|
|
46
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("exits 1 for missing spec file", async () => {
|
|
51
|
-
setup();
|
|
52
|
-
const result = await runCli([join(tmpDir, "nonexistent.md")]);
|
|
53
|
-
expect(result.exitCode).toBe(1);
|
|
54
|
-
expect(result.stderr).toContain("not found");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("exits 0 with no output when no review file exists", async () => {
|
|
58
|
-
const dir = setup();
|
|
59
|
-
const specFile = join(dir, "spec.md");
|
|
60
|
-
writeFileSync(specFile, "# My Spec\n");
|
|
61
|
-
|
|
62
|
-
const result = await runCli([specFile]);
|
|
63
|
-
expect(result.exitCode).toBe(0);
|
|
64
|
-
expect(result.stdout.trim()).toBe("");
|
|
65
|
-
expect(result.stderr.trim()).toBe("");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("outputs APPROVED when JSONL has approve event and review file exists", async () => {
|
|
69
|
-
const dir = setup();
|
|
70
|
-
const specFile = join(dir, "spec.md");
|
|
71
|
-
writeFileSync(specFile, "# My Spec\n");
|
|
72
|
-
|
|
73
|
-
// Write a review file (TUI writes this on approve)
|
|
74
|
-
const reviewPath = join(dir, "spec.review.json");
|
|
75
|
-
writeReviewFile(reviewPath, { file: specFile, threads: [] });
|
|
76
|
-
|
|
77
|
-
// Simulate TUI writing an approve event to JSONL
|
|
78
|
-
const jsonlPath = join(dir, "spec.review.live.jsonl");
|
|
79
|
-
appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
|
|
80
|
-
|
|
81
|
-
const result = await runCli([specFile]);
|
|
82
|
-
expect(result.exitCode).toBe(0);
|
|
83
|
-
expect(result.stdout).toContain("APPROVED:");
|
|
84
|
-
expect(result.stdout).toContain("spec.review.json");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("outputs review path when review file has open threads", async () => {
|
|
88
|
-
const dir = setup();
|
|
89
|
-
const specFile = join(dir, "spec.md");
|
|
90
|
-
writeFileSync(specFile, "# My Spec\n");
|
|
91
|
-
|
|
92
|
-
const reviewPath = join(dir, "spec.review.json");
|
|
93
|
-
writeReviewFile(reviewPath, {
|
|
94
|
-
file: specFile,
|
|
95
|
-
threads: [
|
|
96
|
-
{
|
|
97
|
-
id: "t1",
|
|
98
|
-
line: 5,
|
|
99
|
-
status: "open",
|
|
100
|
-
messages: [{ author: "reviewer", text: "This needs clarification" }],
|
|
101
|
-
},
|
|
102
|
-
],
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
const result = await runCli([specFile]);
|
|
106
|
-
expect(result.exitCode).toBe(0);
|
|
107
|
-
expect(result.stdout).toContain("spec.review.json");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("prints nothing when human adds no comments (no prior review)", async () => {
|
|
111
|
-
const dir = setup();
|
|
112
|
-
const specFile = join(dir, "spec.md");
|
|
113
|
-
writeFileSync(specFile, "# My Spec\n");
|
|
114
|
-
|
|
115
|
-
// No JSONL, no review file — TUI is skipped, nothing happened
|
|
116
|
-
const result = await runCli([specFile]);
|
|
117
|
-
expect(result.exitCode).toBe(0);
|
|
118
|
-
expect(result.stdout.trim()).toBe("");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("prints nothing when review exists but all threads resolved", async () => {
|
|
122
|
-
const dir = setup();
|
|
123
|
-
const specFile = join(dir, "spec.md");
|
|
124
|
-
writeFileSync(specFile, "# My Spec\n");
|
|
125
|
-
|
|
126
|
-
const reviewPath = join(dir, "spec.review.json");
|
|
127
|
-
writeReviewFile(reviewPath, {
|
|
128
|
-
file: specFile,
|
|
129
|
-
threads: [
|
|
130
|
-
{
|
|
131
|
-
id: "t1",
|
|
132
|
-
line: 5,
|
|
133
|
-
status: "resolved",
|
|
134
|
-
messages: [
|
|
135
|
-
{ author: "reviewer", text: "Was unclear" },
|
|
136
|
-
{ author: "owner", text: "Fixed" },
|
|
137
|
-
],
|
|
138
|
-
},
|
|
139
|
-
],
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
const result = await runCli([specFile]);
|
|
143
|
-
expect(result.exitCode).toBe(0);
|
|
144
|
-
expect(result.stdout.trim()).toBe("");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("does not output APPROVED if JSONL has approve but no review file", async () => {
|
|
148
|
-
const dir = setup();
|
|
149
|
-
const specFile = join(dir, "spec.md");
|
|
150
|
-
writeFileSync(specFile, "# My Spec\n");
|
|
151
|
-
|
|
152
|
-
// JSONL has approve but no review file written
|
|
153
|
-
const jsonlPath = join(dir, "spec.review.live.jsonl");
|
|
154
|
-
appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: Date.now() });
|
|
155
|
-
|
|
156
|
-
const result = await runCli([specFile]);
|
|
157
|
-
expect(result.exitCode).toBe(0);
|
|
158
|
-
expect(result.stdout).not.toContain("APPROVED:");
|
|
159
|
-
});
|
|
160
|
-
});
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
2
|
-
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs"
|
|
3
|
-
import { join } from "path"
|
|
4
|
-
import { tmpdir } from "os"
|
|
5
|
-
import { appendEvent, readEventsFromOffset } from "../../src/protocol/live-events"
|
|
6
|
-
import { mergeJsonlIntoReview } from "../../src/protocol/live-merge"
|
|
7
|
-
import { writeReviewFile } from "../../src/protocol/write"
|
|
8
|
-
|
|
9
|
-
const CLI = join(import.meta.dir, "..", "..", "bin", "revspec.ts")
|
|
10
|
-
|
|
11
|
-
describe("E2E: live review loop", () => {
|
|
12
|
-
let dir: string
|
|
13
|
-
let specPath: string
|
|
14
|
-
let jsonlPath: string
|
|
15
|
-
let reviewPath: string
|
|
16
|
-
|
|
17
|
-
beforeEach(() => {
|
|
18
|
-
dir = mkdtempSync(join(tmpdir(), "revspec-e2e-"))
|
|
19
|
-
specPath = join(dir, "spec.md")
|
|
20
|
-
jsonlPath = join(dir, "spec.review.live.jsonl")
|
|
21
|
-
reviewPath = join(dir, "spec.review.json")
|
|
22
|
-
writeFileSync(specPath, "# My Spec\n\nLine 3 is important.\n\nLine 5 also matters.\n")
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
afterEach(() => rmSync(dir, { recursive: true }))
|
|
26
|
-
|
|
27
|
-
it("simulates full loop: comment → watch → reply → watch → resolve → approve", async () => {
|
|
28
|
-
// Step 1: Reviewer adds comments (simulating TUI)
|
|
29
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "this is unclear", ts: 1000 })
|
|
30
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t2", line: 5, author: "reviewer", text: "needs more detail", ts: 1001 })
|
|
31
|
-
|
|
32
|
-
// Step 2: AI runs watch, gets comments
|
|
33
|
-
const watch1 = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
34
|
-
stdout: "pipe", stderr: "pipe",
|
|
35
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
36
|
-
})
|
|
37
|
-
const output1 = await new Response(watch1.stdout).text()
|
|
38
|
-
await watch1.exited
|
|
39
|
-
expect(watch1.exitCode).toBe(0)
|
|
40
|
-
expect(output1).toContain("t1")
|
|
41
|
-
expect(output1).toContain("t2")
|
|
42
|
-
expect(output1).toContain("this is unclear")
|
|
43
|
-
expect(output1).toContain("needs more detail")
|
|
44
|
-
|
|
45
|
-
// Step 3: AI replies
|
|
46
|
-
const reply1 = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t1", "I'll restructure this section"], {
|
|
47
|
-
stdout: "pipe", stderr: "pipe",
|
|
48
|
-
})
|
|
49
|
-
await reply1.exited
|
|
50
|
-
expect(reply1.exitCode).toBe(0)
|
|
51
|
-
|
|
52
|
-
const reply2 = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t2", "Adding more context now"], {
|
|
53
|
-
stdout: "pipe", stderr: "pipe",
|
|
54
|
-
})
|
|
55
|
-
await reply2.exited
|
|
56
|
-
expect(reply2.exitCode).toBe(0)
|
|
57
|
-
|
|
58
|
-
// Verify AI replies are in JSONL
|
|
59
|
-
const { events } = readEventsFromOffset(jsonlPath, 0)
|
|
60
|
-
expect(events).toHaveLength(4) // 2 comments + 2 replies
|
|
61
|
-
expect(events[2].author).toBe("owner")
|
|
62
|
-
expect(events[3].author).toBe("owner")
|
|
63
|
-
|
|
64
|
-
// Step 4: Reviewer resolves and approves (simulating TUI)
|
|
65
|
-
appendEvent(jsonlPath, { type: "resolve", threadId: "t1", author: "reviewer", ts: 2000 })
|
|
66
|
-
appendEvent(jsonlPath, { type: "resolve", threadId: "t2", author: "reviewer", ts: 2001 })
|
|
67
|
-
appendEvent(jsonlPath, { type: "approve", author: "reviewer", ts: 2002 })
|
|
68
|
-
|
|
69
|
-
// Step 5: AI runs watch, gets approval
|
|
70
|
-
const watch2 = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
71
|
-
stdout: "pipe", stderr: "pipe",
|
|
72
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
73
|
-
})
|
|
74
|
-
const output2 = await new Response(watch2.stdout).text()
|
|
75
|
-
await watch2.exited
|
|
76
|
-
expect(watch2.exitCode).toBe(0)
|
|
77
|
-
expect(output2).toContain("Review approved")
|
|
78
|
-
|
|
79
|
-
// Step 6: Merge JSONL → JSON (simulating TUI exit)
|
|
80
|
-
const review = mergeJsonlIntoReview(jsonlPath, null, specPath)
|
|
81
|
-
writeReviewFile(reviewPath, review)
|
|
82
|
-
expect(review.threads).toHaveLength(2)
|
|
83
|
-
expect(review.threads[0].status).toBe("resolved")
|
|
84
|
-
expect(review.threads[1].status).toBe("resolved")
|
|
85
|
-
expect(review.threads[0].messages).toHaveLength(2)
|
|
86
|
-
expect(review.threads[1].messages).toHaveLength(2)
|
|
87
|
-
expect(review.threads[0].messages[0].ts).toBe(1000) // timestamps preserved
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it("handles delete event in the loop", async () => {
|
|
91
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "wrong comment", ts: 1000 })
|
|
92
|
-
appendEvent(jsonlPath, { type: "delete", threadId: "t1", author: "reviewer", ts: 1001 })
|
|
93
|
-
|
|
94
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
95
|
-
stdout: "pipe", stderr: "pipe",
|
|
96
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
97
|
-
})
|
|
98
|
-
const output = await new Response(proc.stdout).text()
|
|
99
|
-
await proc.exited
|
|
100
|
-
|
|
101
|
-
// Watch should show the events
|
|
102
|
-
expect(proc.exitCode).toBe(0)
|
|
103
|
-
|
|
104
|
-
// Merge should exclude empty thread
|
|
105
|
-
const review = mergeJsonlIntoReview(jsonlPath, null, specPath)
|
|
106
|
-
expect(review.threads).toHaveLength(0)
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it("merges with existing review from prior round", async () => {
|
|
110
|
-
// Prior round left a resolved thread
|
|
111
|
-
const priorReview = {
|
|
112
|
-
file: specPath,
|
|
113
|
-
threads: [{
|
|
114
|
-
id: "t1", line: 3, status: "resolved" as const,
|
|
115
|
-
messages: [
|
|
116
|
-
{ author: "reviewer" as const, text: "old comment" },
|
|
117
|
-
{ author: "owner" as const, text: "fixed" },
|
|
118
|
-
]
|
|
119
|
-
}],
|
|
120
|
-
}
|
|
121
|
-
writeReviewFile(reviewPath, priorReview)
|
|
122
|
-
|
|
123
|
-
// New round
|
|
124
|
-
appendEvent(jsonlPath, { type: "round", author: "reviewer", round: 2, ts: 3000 })
|
|
125
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t2", line: 5, author: "reviewer", text: "new issue", ts: 3001 })
|
|
126
|
-
|
|
127
|
-
const review = mergeJsonlIntoReview(jsonlPath, priorReview, specPath)
|
|
128
|
-
expect(review.threads).toHaveLength(2)
|
|
129
|
-
expect(review.threads[0].id).toBe("t1")
|
|
130
|
-
expect(review.threads[1].id).toBe("t2")
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
it("watch output includes spec context lines", async () => {
|
|
134
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "unclear", ts: 1000 })
|
|
135
|
-
|
|
136
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
137
|
-
stdout: "pipe", stderr: "pipe",
|
|
138
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
139
|
-
})
|
|
140
|
-
const output = await new Response(proc.stdout).text()
|
|
141
|
-
await proc.exited
|
|
142
|
-
|
|
143
|
-
// Should include context lines around line 3
|
|
144
|
-
expect(output).toContain("Line 3 is important")
|
|
145
|
-
expect(output).toContain(">") // cursor marker on the anchored line
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
it("reply then watch shows thread history", async () => {
|
|
149
|
-
appendEvent(jsonlPath, { type: "comment", threadId: "t1", line: 3, author: "reviewer", text: "unclear", ts: 1000 })
|
|
150
|
-
|
|
151
|
-
// AI replies
|
|
152
|
-
const reply = Bun.spawn(["bun", "run", CLI, "reply", specPath, "t1", "I'll clarify"], {
|
|
153
|
-
stdout: "pipe", stderr: "pipe",
|
|
154
|
-
})
|
|
155
|
-
await reply.exited
|
|
156
|
-
|
|
157
|
-
// Reviewer replies back
|
|
158
|
-
appendEvent(jsonlPath, { type: "reply", threadId: "t1", author: "reviewer", text: "not quite", ts: 2000 })
|
|
159
|
-
|
|
160
|
-
const proc = Bun.spawn(["bun", "run", CLI, "watch", specPath], {
|
|
161
|
-
stdout: "pipe", stderr: "pipe",
|
|
162
|
-
env: { ...process.env, REVSPEC_WATCH_NO_BLOCK: "1" },
|
|
163
|
-
})
|
|
164
|
-
const output = await new Response(proc.stdout).text()
|
|
165
|
-
await proc.exited
|
|
166
|
-
|
|
167
|
-
expect(output).toContain("Replies")
|
|
168
|
-
expect(output).toContain("unclear")
|
|
169
|
-
expect(output).toContain("not quite")
|
|
170
|
-
})
|
|
171
|
-
})
|