wdyt 0.1.11 → 0.1.15

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.
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Integration tests for wdyt with flowctl-compatible interface
3
+ *
4
+ * Tests the full pipeline that flowctl uses:
5
+ * 1. builder "summary" -> creates tab
6
+ * 2. select add "path" -> adds files
7
+ * 3. call chat_send {...} -> generates review context
8
+ *
9
+ * Also tests context enrichment features:
10
+ * - Context hints from changed files
11
+ * - Git diff context injection
12
+ * - Verdict parsing from response
13
+ * - Re-review preamble for continuing chats
14
+ * - Flow-Next spec loading
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from "bun:test";
18
+ import { join } from "path";
19
+ import { mkdirSync, rmSync } from "fs";
20
+ import { $ } from "bun";
21
+ import { ensureState, createTab, getTab, updateTab, updateWindowPaths } from "./state";
22
+ import { builderCommand } from "./commands/builder";
23
+ import { selectAddCommand, selectGetCommand } from "./commands/select";
24
+ import { chatSendCommand } from "./commands/chat";
25
+ import { generateContextHints, formatHints } from "./context/hints";
26
+ import { getGitDiffContext, formatDiffContextXml } from "./git/diff";
27
+ import { buildReReviewPreamble, clearReviewState } from "./context/rereview";
28
+ import { loadTaskSpec, getTaskSpecContext } from "./flow/specs";
29
+
30
+ // Test fixtures directory
31
+ const TEST_DIR = join(import.meta.dir, "..", ".test-integration");
32
+ const TEST_FLOW_DIR = join(TEST_DIR, ".flow");
33
+
34
+ // Sample test files
35
+ const SAMPLE_TS_FILE = `/**
36
+ * Sample TypeScript file for testing
37
+ */
38
+
39
+ export interface User {
40
+ id: string;
41
+ name: string;
42
+ email: string;
43
+ }
44
+
45
+ export function createUser(name: string, email: string): User {
46
+ return {
47
+ id: crypto.randomUUID(),
48
+ name,
49
+ email,
50
+ };
51
+ }
52
+
53
+ export function validateEmail(email: string): boolean {
54
+ return email.includes("@");
55
+ }
56
+
57
+ export class UserService {
58
+ private users: User[] = [];
59
+
60
+ addUser(user: User): void {
61
+ this.users.push(user);
62
+ }
63
+
64
+ getUser(id: string): User | undefined {
65
+ return this.users.find(u => u.id === id);
66
+ }
67
+ }
68
+ `;
69
+
70
+ const SAMPLE_SPEC = `# fn-99-int.1 Integration Test Task
71
+
72
+ ## Description
73
+ This is a test task for integration testing.
74
+
75
+ ### Requirements
76
+ - Test the full flowctl pipeline
77
+ - Verify context hints generation
78
+ - Check verdict parsing
79
+
80
+ ### Acceptance Criteria
81
+ - [ ] builder command creates tabs
82
+ - [ ] select add adds files
83
+ - [ ] chat_send generates context
84
+ `;
85
+
86
+ describe("Integration: flowctl-compatible pipeline", () => {
87
+ beforeEach(async () => {
88
+ // Set up test environment
89
+ process.env.XDG_DATA_HOME = TEST_DIR;
90
+
91
+ // Create test directories
92
+ mkdirSync(join(TEST_DIR, "src"), { recursive: true });
93
+ mkdirSync(join(TEST_FLOW_DIR, "tasks"), { recursive: true });
94
+ mkdirSync(join(TEST_FLOW_DIR, "specs"), { recursive: true });
95
+
96
+ // Write test files
97
+ await Bun.write(join(TEST_DIR, "src", "user.ts"), SAMPLE_TS_FILE);
98
+ await Bun.write(join(TEST_FLOW_DIR, "tasks", "fn-99-int.1.md"), SAMPLE_SPEC);
99
+
100
+ // Initialize state
101
+ await ensureState();
102
+
103
+ // Set window root path to TEST_DIR so relative paths resolve correctly
104
+ await updateWindowPaths(1, [TEST_DIR]);
105
+
106
+ // Clear re-review state between tests
107
+ clearReviewState();
108
+ });
109
+
110
+ afterEach(async () => {
111
+ try {
112
+ rmSync(TEST_DIR, { recursive: true, force: true });
113
+ } catch {
114
+ // Directory might not exist
115
+ }
116
+ delete process.env.XDG_DATA_HOME;
117
+ });
118
+
119
+ describe("Step 1: builder command (setup-review)", () => {
120
+ it("creates a new tab and returns Tab: <uuid>", async () => {
121
+ const result = await builderCommand(1, "test review");
122
+
123
+ expect(result.success).toBe(true);
124
+ expect(result.data).toBeDefined();
125
+ expect(result.data?.tabId).toBeDefined();
126
+ expect(result.data?.tabId.length).toBe(36); // UUID format
127
+ expect(result.output).toMatch(/^Tab: [a-f0-9-]{36}$/);
128
+ });
129
+
130
+ it("accepts --response-type flag (flowctl compatibility)", async () => {
131
+ const result = await builderCommand(1, "test", { "response-type": "markdown" });
132
+
133
+ expect(result.success).toBe(true);
134
+ expect(result.data?.tabId).toBeDefined();
135
+ });
136
+ });
137
+
138
+ describe("Step 2: select add command", () => {
139
+ it("adds files to tab selection", async () => {
140
+ // Create tab first
141
+ const tab = await createTab(1);
142
+
143
+ // Add a file (using relative path - resolved against TEST_DIR)
144
+ const result = await selectAddCommand(1, tab.id, "src/user.ts");
145
+
146
+ expect(result.success).toBe(true);
147
+ expect(result.data?.added).toBe(1);
148
+ expect(result.data?.total).toBe(1);
149
+
150
+ // Verify file was added
151
+ const getResult = await selectGetCommand(1, tab.id);
152
+ expect(getResult.success).toBe(true);
153
+ // getResult.data is { files: string[] }, need to check files array
154
+ expect(getResult.data?.files).toBeDefined();
155
+ expect(getResult.data?.files.length).toBe(1);
156
+ expect(getResult.data?.files[0]).toContain("src/user.ts");
157
+ });
158
+
159
+ it("handles multiple files", async () => {
160
+ const tab = await createTab(1);
161
+
162
+ // Write another test file
163
+ await Bun.write(join(TEST_DIR, "src", "other.ts"), "export const other = 1;");
164
+
165
+ // Add multiple files
166
+ await selectAddCommand(1, tab.id, "src/user.ts");
167
+ await selectAddCommand(1, tab.id, "src/other.ts");
168
+
169
+ const getResult = await selectGetCommand(1, tab.id);
170
+ expect(getResult.success).toBe(true);
171
+ expect(getResult.data?.files).toBeDefined();
172
+ expect(getResult.data?.files.length).toBe(2);
173
+
174
+ // Check that both files are in the list
175
+ const files = getResult.data!.files.join("\n");
176
+ expect(files).toContain("src/user.ts");
177
+ expect(files).toContain("src/other.ts");
178
+ });
179
+ });
180
+
181
+ describe("Step 3: chat_send command", () => {
182
+ // Save original PATH and restore after tests
183
+ let originalPath: string | undefined;
184
+
185
+ beforeEach(() => {
186
+ // Remove claude from PATH to ensure tests don't depend on Claude CLI
187
+ // This makes tests fast and deterministic
188
+ originalPath = process.env.PATH;
189
+ process.env.PATH = "/nonexistent";
190
+ });
191
+
192
+ afterEach(() => {
193
+ if (originalPath) {
194
+ process.env.PATH = originalPath;
195
+ }
196
+ });
197
+
198
+ it("generates context XML with prompt and files", async () => {
199
+ // Setup: create tab and add file
200
+ const tab = await createTab(1);
201
+ await updateTab(1, tab.id, {
202
+ prompt: "Review this code",
203
+ selectedFiles: [join(TEST_DIR, "src", "user.ts")],
204
+ });
205
+
206
+ // Send chat (without Claude CLI)
207
+ const result = await chatSendCommand(1, tab.id, JSON.stringify({
208
+ message: "Please review this code for issues",
209
+ mode: "review",
210
+ new_chat: true,
211
+ }));
212
+
213
+ // Verify response structure
214
+ expect(result.success).toBe(true);
215
+ expect(result.data).toBeDefined();
216
+ expect(result.data?.id).toBeDefined();
217
+ expect(result.data?.id.length).toBe(36); // UUID format
218
+ expect(result.data?.path).toBeDefined();
219
+
220
+ // Verify output format matches flowctl expectations
221
+ expect(result.output).toContain(`Chat: \`${result.data?.id}\``);
222
+
223
+ // Verify the XML file was created with correct content
224
+ const xmlFile = Bun.file(result.data!.path);
225
+ expect(await xmlFile.exists()).toBe(true);
226
+ const xmlContent = await xmlFile.text();
227
+ expect(xmlContent).toContain('<?xml version="1.0"');
228
+ expect(xmlContent).toContain("<prompt>");
229
+ expect(xmlContent).toContain("Please review this code for issues");
230
+ expect(xmlContent).toContain("<files>");
231
+ expect(xmlContent).toContain("user.ts");
232
+ });
233
+
234
+ it("includes selected_paths from payload", async () => {
235
+ const tab = await createTab(1);
236
+
237
+ // Send with explicit selected_paths
238
+ const result = await chatSendCommand(1, tab.id, JSON.stringify({
239
+ message: "Review these files",
240
+ mode: "review",
241
+ selected_paths: [join(TEST_DIR, "src", "user.ts")],
242
+ }));
243
+
244
+ expect(result.success).toBe(true);
245
+ expect(result.data?.id).toBeDefined();
246
+
247
+ // Verify the file was included
248
+ const xmlFile = Bun.file(result.data!.path);
249
+ const xmlContent = await xmlFile.text();
250
+ expect(xmlContent).toContain("user.ts");
251
+ });
252
+ });
253
+ });
254
+
255
+ describe("Integration: Context hints generation", () => {
256
+ it("generates hints from TypeScript symbols", async () => {
257
+ const fileContents = new Map([
258
+ ["src/user.ts", SAMPLE_TS_FILE],
259
+ ]);
260
+
261
+ const hints = await generateContextHints({
262
+ changedFiles: ["src/user.ts"],
263
+ fileContents,
264
+ cwd: process.cwd(),
265
+ maxHints: 10,
266
+ });
267
+
268
+ // Should return array (may be empty if no external refs)
269
+ expect(Array.isArray(hints)).toBe(true);
270
+
271
+ // Each hint should have required fields
272
+ for (const hint of hints) {
273
+ expect(hint).toHaveProperty("file");
274
+ expect(hint).toHaveProperty("line");
275
+ expect(hint).toHaveProperty("symbol");
276
+ expect(hint).toHaveProperty("refCount");
277
+ }
278
+ });
279
+
280
+ it("formats hints in flowctl-compatible format", () => {
281
+ const hints = [
282
+ { file: "src/auth.ts", line: 15, symbol: "validateUser", refCount: 3 },
283
+ { file: "src/types.ts", line: 42, symbol: "User", refCount: 5 },
284
+ ];
285
+
286
+ const formatted = formatHints(hints);
287
+
288
+ expect(formatted).toContain("Consider these related files:");
289
+ expect(formatted).toContain("src/auth.ts:15 - references validateUser");
290
+ expect(formatted).toContain("src/types.ts:42 - references User");
291
+ });
292
+ });
293
+
294
+ describe("Integration: Git diff context", () => {
295
+ it("gets git context from repository", async () => {
296
+ // Use the actual repo for this test
297
+ const context = await getGitDiffContext({
298
+ base: "HEAD~1",
299
+ head: "HEAD",
300
+ cwd: process.cwd(),
301
+ });
302
+
303
+ // Should have basic structure
304
+ expect(context).toHaveProperty("diffStat");
305
+ expect(context).toHaveProperty("commits");
306
+ expect(context).toHaveProperty("changedFiles");
307
+ expect(context).toHaveProperty("branch");
308
+ });
309
+
310
+ it("formats git context as XML", () => {
311
+ const context = {
312
+ diffStat: "3 files changed, 100 insertions(+), 20 deletions(-)",
313
+ commits: ["abc1234 feat: add user module", "def5678 fix: email validation"],
314
+ changedFiles: ["src/user.ts", "src/types.ts"],
315
+ branch: "feature/users",
316
+ };
317
+
318
+ const xml = formatDiffContextXml(context);
319
+
320
+ expect(xml).toContain("<diff_summary>");
321
+ expect(xml).toContain("3 files changed");
322
+ expect(xml).toContain("<commits>");
323
+ expect(xml).toContain("feat: add user module");
324
+ expect(xml).toContain("<changed_files>");
325
+ expect(xml).toContain("src/user.ts");
326
+ });
327
+ });
328
+
329
+ describe("Integration: Re-review preamble", () => {
330
+ it("builds re-review preamble with changed files", () => {
331
+ const changedFiles = [
332
+ "src/user.ts",
333
+ "src/auth.ts",
334
+ "src/types.ts",
335
+ ];
336
+
337
+ const preamble = buildReReviewPreamble(changedFiles, "implementation");
338
+
339
+ expect(preamble).toContain("IMPORTANT: Re-review After Fixes");
340
+ expect(preamble).toContain("This is a RE-REVIEW");
341
+ expect(preamble).toContain("src/user.ts");
342
+ expect(preamble).toContain("src/auth.ts");
343
+ expect(preamble).toContain("implementation review");
344
+ });
345
+
346
+ it("truncates file list at 30 files", () => {
347
+ const manyFiles = Array.from({ length: 50 }, (_, i) => `src/file${i}.ts`);
348
+
349
+ const preamble = buildReReviewPreamble(manyFiles, "review");
350
+
351
+ expect(preamble).toContain("and 20 more files");
352
+ });
353
+ });
354
+
355
+ describe("Integration: Flow-Next spec loading", () => {
356
+ beforeAll(() => {
357
+ mkdirSync(join(TEST_FLOW_DIR, "tasks"), { recursive: true });
358
+ Bun.write(join(TEST_FLOW_DIR, "tasks", "fn-99-int.1.md"), SAMPLE_SPEC);
359
+ });
360
+
361
+ afterAll(() => {
362
+ rmSync(TEST_DIR, { recursive: true, force: true });
363
+ });
364
+
365
+ it("loads task spec from .flow directory", async () => {
366
+ const result = await loadTaskSpec("fn-99-int.1", { flowDir: TEST_FLOW_DIR });
367
+
368
+ expect(result.found).toBe(true);
369
+ expect(result.taskId).toBe("fn-99-int.1");
370
+ expect(result.content).toContain("Integration Test Task");
371
+ });
372
+
373
+ it("formats spec as XML context", async () => {
374
+ const xml = await getTaskSpecContext("fn-99-int.1", { flowDir: TEST_FLOW_DIR });
375
+
376
+ expect(xml).toContain("<task_spec>");
377
+ expect(xml).toContain("# fn-99-int.1");
378
+ expect(xml).toContain("</task_spec>");
379
+ });
380
+
381
+ it("returns empty string for missing spec (graceful)", async () => {
382
+ const xml = await getTaskSpecContext("fn-88.99", { flowDir: TEST_FLOW_DIR });
383
+
384
+ expect(xml).toBe("");
385
+ });
386
+ });
387
+
388
+ describe("Integration: Verdict parsing", () => {
389
+ // Note: Actual verdict parsing happens in chat.ts parseVerdict()
390
+ // These tests verify the expected format
391
+
392
+ it("recognizes SHIP verdict format", () => {
393
+ const response = `
394
+ ## Review Summary
395
+ Code looks good!
396
+
397
+ <verdict>SHIP</verdict>
398
+ `;
399
+ const match = response.match(/<verdict>(SHIP|NEEDS_WORK|MAJOR_RETHINK)<\/verdict>/i);
400
+ expect(match).not.toBeNull();
401
+ expect(match![1].toUpperCase()).toBe("SHIP");
402
+ });
403
+
404
+ it("recognizes NEEDS_WORK verdict format", () => {
405
+ const response = `
406
+ ## Review Summary
407
+ Some issues found.
408
+
409
+ <verdict>NEEDS_WORK</verdict>
410
+ `;
411
+ const match = response.match(/<verdict>(SHIP|NEEDS_WORK|MAJOR_RETHINK)<\/verdict>/i);
412
+ expect(match).not.toBeNull();
413
+ expect(match![1].toUpperCase()).toBe("NEEDS_WORK");
414
+ });
415
+
416
+ it("recognizes MAJOR_RETHINK verdict format", () => {
417
+ const response = `
418
+ ## Review Summary
419
+ Significant problems.
420
+
421
+ <verdict>MAJOR_RETHINK</verdict>
422
+ `;
423
+ const match = response.match(/<verdict>(SHIP|NEEDS_WORK|MAJOR_RETHINK)<\/verdict>/i);
424
+ expect(match).not.toBeNull();
425
+ expect(match![1].toUpperCase()).toBe("MAJOR_RETHINK");
426
+ });
427
+
428
+ it("handles case-insensitive verdict", () => {
429
+ const response = `<verdict>ship</verdict>`;
430
+ const match = response.match(/<verdict>(SHIP|NEEDS_WORK|MAJOR_RETHINK)<\/verdict>/i);
431
+ expect(match).not.toBeNull();
432
+ expect(match![1].toUpperCase()).toBe("SHIP");
433
+ });
434
+ });
435
+
436
+ describe("Integration: Full pipeline simulation", () => {
437
+ let originalPath: string | undefined;
438
+
439
+ beforeEach(async () => {
440
+ process.env.XDG_DATA_HOME = TEST_DIR;
441
+ // Remove claude from PATH to ensure tests don't depend on Claude CLI
442
+ originalPath = process.env.PATH;
443
+ process.env.PATH = "/nonexistent";
444
+
445
+ mkdirSync(join(TEST_DIR, "src"), { recursive: true });
446
+ await Bun.write(join(TEST_DIR, "src", "user.ts"), SAMPLE_TS_FILE);
447
+ await ensureState();
448
+
449
+ // Set window root to TEST_DIR for relative path resolution
450
+ await updateWindowPaths(1, [TEST_DIR]);
451
+
452
+ clearReviewState();
453
+ });
454
+
455
+ afterEach(async () => {
456
+ if (originalPath) {
457
+ process.env.PATH = originalPath;
458
+ }
459
+ try {
460
+ rmSync(TEST_DIR, { recursive: true, force: true });
461
+ } catch {
462
+ // Ignore
463
+ }
464
+ delete process.env.XDG_DATA_HOME;
465
+ });
466
+
467
+ it("runs complete flowctl-like workflow", async () => {
468
+ // Step 1: builder (setup-review)
469
+ // This is what flowctl calls with: rp-cli -w $W -e 'builder "summary"'
470
+ const builderResult = await builderCommand(1, "integration test");
471
+ expect(builderResult.success).toBe(true);
472
+ expect(builderResult.output).toMatch(/^Tab: [a-f0-9-]{36}$/);
473
+ const tabId = builderResult.data!.tabId;
474
+
475
+ // Step 2: select add (add files to review)
476
+ // This is what flowctl calls with: rp-cli -w $W -t $T -e 'select add "path"'
477
+ const selectResult = await selectAddCommand(1, tabId, "src/user.ts");
478
+ expect(selectResult.success).toBe(true);
479
+ expect(selectResult.data?.added).toBe(1);
480
+
481
+ // Step 3: chat_send (trigger review)
482
+ // This is what flowctl calls with: rp-cli -w $W -t $T -e 'call chat_send {...}'
483
+ const chatResult = await chatSendCommand(1, tabId, JSON.stringify({
484
+ message: "Review this user module for security and correctness",
485
+ mode: "review",
486
+ new_chat: true,
487
+ }));
488
+
489
+ expect(chatResult.success).toBe(true);
490
+ expect(chatResult.data?.id).toBeDefined();
491
+ expect(chatResult.output).toContain("Chat:");
492
+
493
+ // Verify the XML context was generated correctly
494
+ const xmlFile = Bun.file(chatResult.data!.path);
495
+ expect(await xmlFile.exists()).toBe(true);
496
+ const xmlContent = await xmlFile.text();
497
+
498
+ // Verify the context contains expected elements
499
+ expect(xmlContent).toContain('<?xml version="1.0" encoding="UTF-8"?>');
500
+ expect(xmlContent).toContain("<context>");
501
+ expect(xmlContent).toContain("<prompt>");
502
+ expect(xmlContent).toContain("Review this user module");
503
+ expect(xmlContent).toContain("<files>");
504
+ expect(xmlContent).toContain("user.ts");
505
+ expect(xmlContent).toContain("</context>");
506
+ });
507
+
508
+ it("supports re-review workflow with preamble", async () => {
509
+ // First review
510
+ const tab = await createTab(1);
511
+ await updateTab(1, tab.id, {
512
+ selectedFiles: [join(TEST_DIR, "src", "user.ts")],
513
+ });
514
+
515
+ const firstReview = await chatSendCommand(1, tab.id, JSON.stringify({
516
+ message: "Initial review",
517
+ mode: "review",
518
+ new_chat: true,
519
+ }));
520
+ expect(firstReview.success).toBe(true);
521
+ const firstChatId = firstReview.data!.id;
522
+
523
+ // Second review (re-review) - continuing same chat
524
+ const reReview = await chatSendCommand(1, tab.id, JSON.stringify({
525
+ message: "Please re-review after fixes",
526
+ mode: "review",
527
+ chat_id: firstChatId, // Continue from first chat
528
+ new_chat: false,
529
+ }));
530
+
531
+ expect(reReview.success).toBe(true);
532
+ expect(reReview.data?.isReReview).toBe(true);
533
+
534
+ // Verify re-review preamble is in the context
535
+ const xmlContent = await Bun.file(reReview.data!.path).text();
536
+ expect(xmlContent).toContain("RE-REVIEW");
537
+ });
538
+ });
@@ -7,61 +7,35 @@
7
7
  */
8
8
 
9
9
  import { parseArgs } from "node:util";
10
+ import { parse as shellParse } from "shell-quote";
10
11
 
11
12
  /**
12
- * Tokenize a shell-like string, respecting quotes
13
- * "builder \"hello world\" --flag" -> ["builder", "hello world", "--flag"]
13
+ * Tokenize a shell-like string
14
+ *
15
+ * Uses shell-quote for most parsing, but preserves JSON objects literally
16
+ * since shell-quote would strip internal quotes from JSON.
14
17
  */
15
18
  export function tokenize(input: string): string[] {
16
- const tokens: string[] = [];
17
- let current = "";
18
- let inQuote: string | null = null;
19
- let escape = false;
20
-
21
- for (let i = 0; i < input.length; i++) {
22
- const char = input[i];
23
-
24
- if (escape) {
25
- current += char;
26
- escape = false;
27
- continue;
28
- }
29
-
30
- if (char === "\\") {
31
- escape = true;
32
- continue;
33
- }
19
+ // Check if input contains a JSON object (starts with {)
20
+ const jsonStart = input.indexOf("{");
34
21
 
35
- if (char === '"' || char === "'") {
36
- if (inQuote === char) {
37
- // End of quoted string
38
- inQuote = null;
39
- } else if (inQuote === null) {
40
- // Start of quoted string
41
- inQuote = char;
42
- } else {
43
- // Different quote inside a quoted string
44
- current += char;
45
- }
46
- continue;
47
- }
22
+ if (jsonStart !== -1) {
23
+ // Split into pre-JSON and JSON parts
24
+ const preJson = input.slice(0, jsonStart).trim();
25
+ const jsonPart = input.slice(jsonStart);
48
26
 
49
- if (char === " " && inQuote === null) {
50
- if (current) {
51
- tokens.push(current);
52
- current = "";
53
- }
54
- continue;
55
- }
56
-
57
- current += char;
58
- }
27
+ // Parse the pre-JSON part with shell-quote
28
+ const preTokens = preJson
29
+ ? shellParse(preJson).filter((t): t is string => typeof t === "string")
30
+ : [];
59
31
 
60
- if (current) {
61
- tokens.push(current);
32
+ // Keep the JSON part as-is
33
+ return [...preTokens, jsonPart];
62
34
  }
63
35
 
64
- return tokens;
36
+ // No JSON, use shell-quote for everything
37
+ const parsed = shellParse(input);
38
+ return parsed.filter((t): t is string => typeof t === "string");
65
39
  }
66
40
 
67
41
  /**