cc-plan-viewer 0.1.0 → 0.2.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.
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import request from 'supertest';
6
+ import { createApp } from './app.js';
7
+ // Suppress lifecycle idle timer output
8
+ vi.mock('./lifecycle.js', () => ({
9
+ resetIdleTimer: vi.fn(),
10
+ }));
11
+ describe('server app', () => {
12
+ let testDir;
13
+ let app;
14
+ let server;
15
+ let wss;
16
+ beforeEach(() => {
17
+ // Create a temp directory for plan files
18
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-plan-viewer-test-'));
19
+ const result = createApp([testDir]);
20
+ app = result.app;
21
+ server = result.server;
22
+ wss = result.wss;
23
+ });
24
+ afterEach(() => {
25
+ // Close server and clean up
26
+ server.close();
27
+ wss.close();
28
+ // Clean up temp directory
29
+ fs.rmSync(testDir, { recursive: true, force: true });
30
+ });
31
+ describe('GET /health', () => {
32
+ it('returns status ok with plan dirs', async () => {
33
+ const res = await request(app).get('/health');
34
+ expect(res.status).toBe(200);
35
+ expect(res.body.status).toBe('ok');
36
+ expect(res.body.plansDirs).toContain(testDir);
37
+ });
38
+ });
39
+ describe('GET /api/plans', () => {
40
+ it('returns empty array when no plans exist', async () => {
41
+ const res = await request(app).get('/api/plans');
42
+ expect(res.status).toBe(200);
43
+ expect(res.body).toEqual([]);
44
+ });
45
+ it('returns list of plan files', async () => {
46
+ fs.writeFileSync(path.join(testDir, 'plan-a.md'), '# Plan A');
47
+ fs.writeFileSync(path.join(testDir, 'plan-b.md'), '# Plan B');
48
+ const res = await request(app).get('/api/plans');
49
+ expect(res.status).toBe(200);
50
+ expect(res.body).toHaveLength(2);
51
+ expect(res.body.map((p) => p.filename).sort()).toEqual(['plan-a.md', 'plan-b.md']);
52
+ });
53
+ it('sorts by modification time descending', async () => {
54
+ fs.writeFileSync(path.join(testDir, 'old.md'), '# Old');
55
+ // Touch the file with a future time to ensure ordering
56
+ const futureTime = new Date(Date.now() + 10000);
57
+ fs.writeFileSync(path.join(testDir, 'new.md'), '# New');
58
+ fs.utimesSync(path.join(testDir, 'new.md'), futureTime, futureTime);
59
+ const res = await request(app).get('/api/plans');
60
+ expect(res.body[0].filename).toBe('new.md');
61
+ });
62
+ it('excludes .review.json files', async () => {
63
+ fs.writeFileSync(path.join(testDir, 'plan.md'), '# Plan');
64
+ fs.writeFileSync(path.join(testDir, 'plan.review.json'), '{}');
65
+ const res = await request(app).get('/api/plans');
66
+ expect(res.body).toHaveLength(1);
67
+ expect(res.body[0].filename).toBe('plan.md');
68
+ });
69
+ });
70
+ describe('GET /api/plans/:filename', () => {
71
+ it('returns parsed plan data', async () => {
72
+ fs.writeFileSync(path.join(testDir, 'test.md'), '# My Plan\n\nSome content');
73
+ const res = await request(app).get('/api/plans/test.md');
74
+ expect(res.status).toBe(200);
75
+ expect(res.body.filename).toBe('test.md');
76
+ expect(res.body.parsed.title).toBe('My Plan');
77
+ expect(res.body.parsed.rawMarkdown).toContain('Some content');
78
+ });
79
+ it('returns 404 for missing plan', async () => {
80
+ const res = await request(app).get('/api/plans/missing.md');
81
+ expect(res.status).toBe(404);
82
+ });
83
+ it('returns 400 for non-.md filename', async () => {
84
+ const res = await request(app).get('/api/plans/bad.txt');
85
+ expect(res.status).toBe(400);
86
+ });
87
+ it('returns 400 for path traversal', async () => {
88
+ const res = await request(app).get('/api/plans/..%2Fetc%2Fpasswd.md');
89
+ expect(res.status).toBe(400);
90
+ });
91
+ });
92
+ describe('POST /api/plan-updated', () => {
93
+ it('returns ok', async () => {
94
+ const res = await request(app)
95
+ .post('/api/plan-updated')
96
+ .send({ filePath: '/some/path/plan.md' });
97
+ expect(res.status).toBe(200);
98
+ expect(res.body.ok).toBe(true);
99
+ });
100
+ });
101
+ describe('POST /api/reviews/:filename', () => {
102
+ it('saves review and returns it', async () => {
103
+ fs.writeFileSync(path.join(testDir, 'plan.md'), '# Plan');
104
+ const res = await request(app)
105
+ .post('/api/reviews/plan.md')
106
+ .send({
107
+ action: 'approve',
108
+ overallComment: 'LGTM',
109
+ inlineComments: [],
110
+ });
111
+ expect(res.status).toBe(200);
112
+ expect(res.body.ok).toBe(true);
113
+ expect(res.body.review.action).toBe('approve');
114
+ expect(res.body.review.overallComment).toBe('LGTM');
115
+ // Verify file was written
116
+ const reviewPath = path.join(testDir, 'plan.review.json');
117
+ expect(fs.existsSync(reviewPath)).toBe(true);
118
+ });
119
+ it('returns 404 for missing plan', async () => {
120
+ const res = await request(app)
121
+ .post('/api/reviews/missing.md')
122
+ .send({ action: 'approve' });
123
+ expect(res.status).toBe(404);
124
+ });
125
+ it('returns 400 for path traversal', async () => {
126
+ const res = await request(app)
127
+ .post('/api/reviews/..%2Fhack.md')
128
+ .send({ action: 'approve' });
129
+ expect(res.status).toBe(400);
130
+ });
131
+ });
132
+ describe('GET /api/reviews/:filename', () => {
133
+ it('returns saved review', async () => {
134
+ fs.writeFileSync(path.join(testDir, 'plan.md'), '# Plan');
135
+ // First save a review
136
+ await request(app)
137
+ .post('/api/reviews/plan.md')
138
+ .send({ action: 'feedback', overallComment: 'Nice' });
139
+ const res = await request(app).get('/api/reviews/plan.md');
140
+ expect(res.status).toBe(200);
141
+ expect(res.body.action).toBe('feedback');
142
+ expect(res.body.overallComment).toBe('Nice');
143
+ });
144
+ it('returns 404 when no review exists', async () => {
145
+ fs.writeFileSync(path.join(testDir, 'plan.md'), '# Plan');
146
+ const res = await request(app).get('/api/reviews/plan.md');
147
+ expect(res.status).toBe(404);
148
+ });
149
+ it('returns 404 for missing plan file', async () => {
150
+ const res = await request(app).get('/api/reviews/missing.md');
151
+ expect(res.status).toBe(404);
152
+ });
153
+ });
154
+ });
@@ -1,178 +1,36 @@
1
- import express from 'express';
2
- import { createServer } from 'node:http';
3
- import { WebSocketServer, WebSocket } from 'ws';
4
1
  import fs from 'node:fs';
5
2
  import path from 'node:path';
6
3
  import os from 'node:os';
7
- import { parsePlan } from './planParser.js';
8
- import { saveReview, getReview } from './reviewStore.js';
4
+ import { WebSocket } from 'ws';
5
+ import { createApp } from './app.js';
9
6
  import { writePidFile, writePortFile, cleanupFiles, resetIdleTimer } from './lifecycle.js';
10
7
  import { watchPlansDir } from './planWatcher.js';
11
8
  const PORT = parseInt(process.env.PORT || '3847', 10);
12
- // Auto-detect plans directory
13
- function findPlansDir() {
9
+ // Auto-detect all ~/.claude*/plans/ directories
10
+ function findAllPlansDirs() {
14
11
  const home = os.homedir();
15
- const candidates = [
16
- path.join(home, '.claude-personal', 'plans'),
17
- path.join(home, '.claude', 'plans'),
18
- ];
19
- for (const dir of candidates) {
20
- if (fs.existsSync(dir))
21
- return dir;
22
- }
23
- // Default to first candidate even if it doesn't exist yet
24
- return candidates[0];
25
- }
26
- const plansDir = findPlansDir();
27
- const app = express();
28
- const server = createServer(app);
29
- const wss = new WebSocketServer({ server });
30
- app.use(express.json());
31
- // Reset idle timer on every request
32
- app.use((_req, _res, next) => {
33
- resetIdleTimer();
34
- next();
35
- });
36
- // Health check
37
- app.get('/health', (_req, res) => {
38
- res.json({ status: 'ok', plansDir });
39
- });
40
- // List all plans
41
- app.get('/api/plans', (_req, res) => {
42
- try {
43
- if (!fs.existsSync(plansDir)) {
44
- res.json([]);
45
- return;
46
- }
47
- const files = fs.readdirSync(plansDir)
48
- .filter(f => f.endsWith('.md') && !f.endsWith('.review.json'))
49
- .map(f => {
50
- const filePath = path.join(plansDir, f);
51
- const stat = fs.statSync(filePath);
52
- const review = getReview(filePath);
53
- return {
54
- filename: f,
55
- modified: stat.mtime.toISOString(),
56
- size: stat.size,
57
- hasReview: !!review,
58
- reviewAction: review?.action ?? null,
59
- };
60
- })
61
- .sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
62
- res.json(files);
63
- }
64
- catch (err) {
65
- res.status(500).json({ error: 'Failed to list plans' });
66
- }
67
- });
68
- // Get a specific plan
69
- app.get('/api/plans/:filename', (req, res) => {
70
- const filename = req.params.filename;
71
- if (!filename.endsWith('.md') || filename.includes('..')) {
72
- res.status(400).json({ error: 'Invalid filename' });
73
- return;
74
- }
75
- const filePath = path.join(plansDir, filename);
76
12
  try {
77
- const content = fs.readFileSync(filePath, 'utf8');
78
- const parsed = parsePlan(content);
79
- const review = getReview(filePath);
80
- res.json({ filename, parsed, review });
13
+ return fs.readdirSync(home)
14
+ .filter(name => name.startsWith('.claude'))
15
+ .map(name => path.join(home, name, 'plans'))
16
+ .filter(dir => fs.existsSync(dir));
81
17
  }
82
18
  catch {
83
- res.status(404).json({ error: 'Plan not found' });
84
- }
85
- });
86
- // Hook notifies of plan update
87
- app.post('/api/plan-updated', (req, res) => {
88
- const { filePath, planOptions } = req.body;
89
- const filename = path.basename(filePath || '');
90
- // Broadcast to all WebSocket clients
91
- const message = JSON.stringify({
92
- type: 'plan-updated',
93
- filename,
94
- planOptions: planOptions || null,
95
- });
96
- for (const client of wss.clients) {
97
- if (client.readyState === WebSocket.OPEN) {
98
- client.send(message);
99
- }
100
- }
101
- res.json({ ok: true });
102
- });
103
- // Save a review
104
- app.post('/api/reviews/:filename', (req, res) => {
105
- const filename = req.params.filename;
106
- if (!filename.endsWith('.md') || filename.includes('..')) {
107
- res.status(400).json({ error: 'Invalid filename' });
108
- return;
109
- }
110
- const filePath = path.join(plansDir, filename);
111
- if (!fs.existsSync(filePath)) {
112
- res.status(404).json({ error: 'Plan not found' });
113
- return;
114
- }
115
- const review = {
116
- planFile: filename,
117
- action: req.body.action || 'feedback',
118
- submittedAt: new Date().toISOString(),
119
- consumedAt: null,
120
- overallComment: req.body.overallComment || '',
121
- inlineComments: req.body.inlineComments || [],
122
- };
123
- saveReview(filePath, review);
124
- // Notify clients
125
- const message = JSON.stringify({
126
- type: 'review-submitted',
127
- filename,
128
- action: review.action,
129
- });
130
- for (const client of wss.clients) {
131
- if (client.readyState === WebSocket.OPEN) {
132
- client.send(message);
133
- }
19
+ return [path.join(home, '.claude', 'plans')];
134
20
  }
135
- res.json({ ok: true, review });
136
- });
137
- // Get a review
138
- app.get('/api/reviews/:filename', (req, res) => {
139
- const filename = req.params.filename;
140
- const filePath = path.join(plansDir, filename);
141
- const review = getReview(filePath);
142
- if (!review) {
143
- res.status(404).json({ error: 'No review found' });
144
- return;
145
- }
146
- res.json(review);
147
- });
148
- // Serve SPA static files
149
- // Try multiple paths: dist/client relative to project root, or relative to compiled output
150
- const clientDistCandidates = [
151
- path.join(import.meta.dirname, '..', '..', 'client'), // prod: dist/server/server/ → dist/client/
152
- path.join(import.meta.dirname, '..', 'dist', 'client'), // dev: server/ → dist/client/
153
- ];
154
- const clientDist = clientDistCandidates.find((d) => fs.existsSync(d));
155
- if (clientDist) {
156
- app.use(express.static(clientDist));
157
- app.get('/{*path}', (_req, res) => {
158
- res.sendFile(path.join(clientDist, 'index.html'));
159
- });
160
21
  }
161
- // WebSocket connection
162
- wss.on('connection', (ws) => {
163
- resetIdleTimer();
164
- ws.on('message', () => resetIdleTimer());
165
- });
22
+ const plansDirs = findAllPlansDirs();
23
+ const { server, wss } = createApp(plansDirs);
166
24
  // Start
167
25
  server.listen(PORT, () => {
168
26
  writePidFile();
169
27
  writePortFile(PORT);
170
28
  resetIdleTimer();
171
29
  console.log(`[cc-plan-viewer] Server running at http://localhost:${PORT}`);
172
- console.log(`[cc-plan-viewer] Plans directory: ${plansDir}`);
30
+ console.log(`[cc-plan-viewer] Plans directories: ${plansDirs.join(', ')}`);
173
31
  });
174
- // Watch for plan file changes
175
- watchPlansDir(plansDir, (filename, content) => {
32
+ // Watch all plan directories for changes
33
+ const onPlanChange = (filename, _content) => {
176
34
  const message = JSON.stringify({
177
35
  type: 'plan-updated',
178
36
  filename,
@@ -182,7 +40,10 @@ watchPlansDir(plansDir, (filename, content) => {
182
40
  client.send(message);
183
41
  }
184
42
  }
185
- });
43
+ };
44
+ for (const dir of plansDirs) {
45
+ watchPlansDir(dir, onPlanChange);
46
+ }
186
47
  // Cleanup on exit
187
48
  process.on('SIGINT', () => { cleanupFiles(); process.exit(0); });
188
49
  process.on('SIGTERM', () => { cleanupFiles(); process.exit(0); });
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ const mockFs = vi.hoisted(() => ({
3
+ writeFileSync: vi.fn(),
4
+ readFileSync: vi.fn(),
5
+ unlinkSync: vi.fn(),
6
+ }));
7
+ vi.mock('node:fs', () => ({ default: mockFs, ...mockFs }));
8
+ vi.mock('node:os', () => ({
9
+ default: { tmpdir: () => '/tmp' },
10
+ tmpdir: () => '/tmp',
11
+ }));
12
+ const { writePidFile, writePortFile, cleanupFiles, readPortFile, isServerRunning, resetIdleTimer } = await import('./lifecycle.js');
13
+ describe('lifecycle', () => {
14
+ beforeEach(() => {
15
+ vi.resetAllMocks();
16
+ vi.useFakeTimers();
17
+ });
18
+ afterEach(() => {
19
+ vi.useRealTimers();
20
+ });
21
+ describe('writePidFile', () => {
22
+ it('writes process PID to correct path', () => {
23
+ writePidFile();
24
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/tmp/cc-plan-viewer.pid', String(process.pid), 'utf8');
25
+ });
26
+ });
27
+ describe('writePortFile', () => {
28
+ it('writes port number to correct path', () => {
29
+ writePortFile(3847);
30
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/tmp/cc-plan-viewer-port', '3847', 'utf8');
31
+ });
32
+ });
33
+ describe('cleanupFiles', () => {
34
+ it('unlinks both PID and port files', () => {
35
+ cleanupFiles();
36
+ expect(mockFs.unlinkSync).toHaveBeenCalledWith('/tmp/cc-plan-viewer.pid');
37
+ expect(mockFs.unlinkSync).toHaveBeenCalledWith('/tmp/cc-plan-viewer-port');
38
+ });
39
+ it('swallows errors when files do not exist', () => {
40
+ mockFs.unlinkSync.mockImplementation(() => {
41
+ throw new Error('ENOENT');
42
+ });
43
+ expect(() => cleanupFiles()).not.toThrow();
44
+ });
45
+ });
46
+ describe('readPortFile', () => {
47
+ it('returns port number from file', () => {
48
+ mockFs.readFileSync.mockReturnValue('3847');
49
+ expect(readPortFile()).toBe(3847);
50
+ });
51
+ it('returns null when file does not exist', () => {
52
+ mockFs.readFileSync.mockImplementation(() => {
53
+ throw new Error('ENOENT');
54
+ });
55
+ expect(readPortFile()).toBeNull();
56
+ });
57
+ it('returns null when file contains non-numeric content', () => {
58
+ mockFs.readFileSync.mockReturnValue('not-a-number');
59
+ expect(readPortFile()).toBeNull();
60
+ });
61
+ it('handles port with whitespace', () => {
62
+ mockFs.readFileSync.mockReturnValue(' 3847\n');
63
+ expect(readPortFile()).toBe(3847);
64
+ });
65
+ });
66
+ describe('isServerRunning', () => {
67
+ it('returns true when PID file exists and process is alive', () => {
68
+ mockFs.readFileSync.mockReturnValue('12345');
69
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
70
+ expect(isServerRunning()).toBe(true);
71
+ expect(killSpy).toHaveBeenCalledWith(12345, 0);
72
+ killSpy.mockRestore();
73
+ });
74
+ it('returns false when PID file does not exist', () => {
75
+ mockFs.readFileSync.mockImplementation(() => {
76
+ throw new Error('ENOENT');
77
+ });
78
+ expect(isServerRunning()).toBe(false);
79
+ });
80
+ it('returns false when process is not alive', () => {
81
+ mockFs.readFileSync.mockReturnValue('12345');
82
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => {
83
+ throw new Error('ESRCH');
84
+ });
85
+ expect(isServerRunning()).toBe(false);
86
+ killSpy.mockRestore();
87
+ });
88
+ it('returns false when PID is NaN', () => {
89
+ mockFs.readFileSync.mockReturnValue('garbage');
90
+ expect(isServerRunning()).toBe(false);
91
+ });
92
+ });
93
+ describe('resetIdleTimer', () => {
94
+ it('sets a timer', () => {
95
+ const spy = vi.spyOn(global, 'setTimeout');
96
+ resetIdleTimer();
97
+ expect(spy).toHaveBeenCalled();
98
+ spy.mockRestore();
99
+ });
100
+ });
101
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parsePlan } from './planParser.js';
3
+ describe('parsePlan', () => {
4
+ it('returns untitled plan for empty string', () => {
5
+ const result = parsePlan('');
6
+ expect(result.title).toBe('Untitled Plan');
7
+ expect(result.sections).toEqual([]);
8
+ expect(result.rawMarkdown).toBe('');
9
+ });
10
+ it('extracts title from first h1', () => {
11
+ const result = parsePlan('# My Plan\n\nSome content');
12
+ expect(result.title).toBe('My Plan');
13
+ });
14
+ it('uses Untitled Plan when no h1 exists', () => {
15
+ const result = parsePlan('## Section A\n\nContent');
16
+ expect(result.title).toBe('Untitled Plan');
17
+ });
18
+ it('only uses the first h1 as title', () => {
19
+ const result = parsePlan('# First Title\n\n# Second Title');
20
+ expect(result.title).toBe('First Title');
21
+ });
22
+ it('creates a single root section for one heading', () => {
23
+ const result = parsePlan('# Title\n\nContent here');
24
+ expect(result.sections).toHaveLength(1);
25
+ expect(result.sections[0].heading).toBe('Title');
26
+ expect(result.sections[0].level).toBe(1);
27
+ });
28
+ it('creates flat siblings for multiple h2 headings', () => {
29
+ const md = '## Section A\n\nContent A\n\n## Section B\n\nContent B';
30
+ const result = parsePlan(md);
31
+ expect(result.sections).toHaveLength(2);
32
+ expect(result.sections[0].heading).toBe('Section A');
33
+ expect(result.sections[1].heading).toBe('Section B');
34
+ });
35
+ it('nests h3 under preceding h2', () => {
36
+ const md = '## Parent\n\n### Child\n\nContent';
37
+ const result = parsePlan(md);
38
+ expect(result.sections).toHaveLength(1);
39
+ expect(result.sections[0].heading).toBe('Parent');
40
+ expect(result.sections[0].children).toHaveLength(1);
41
+ expect(result.sections[0].children[0].heading).toBe('Child');
42
+ });
43
+ it('builds correct h2/h3/h4 hierarchy', () => {
44
+ const md = '## A\n### B\n#### C\n### D\n## E';
45
+ const result = parsePlan(md);
46
+ expect(result.sections).toHaveLength(2); // A and E
47
+ expect(result.sections[0].children).toHaveLength(2); // B and D
48
+ expect(result.sections[0].children[0].children).toHaveLength(1); // C
49
+ });
50
+ it('calculates correct startLine for sections', () => {
51
+ const md = '# Title\n\nParagraph\n\n## Section';
52
+ const result = parsePlan(md);
53
+ expect(result.sections[0].startLine).toBe(0); // # Title is line 0
54
+ expect(result.sections[0].children[0].startLine).toBe(4); // ## Section is line 4
55
+ });
56
+ it('calculates correct endLine for sections', () => {
57
+ const md = '## A\nContent A\n## B\nContent B';
58
+ const result = parsePlan(md);
59
+ // A ends at line 2 (start of B)
60
+ expect(result.sections[0].endLine).toBe(2);
61
+ // B ends at the total number of lines
62
+ expect(result.sections[1].endLine).toBe(4);
63
+ });
64
+ it('extracts rawContent for each section', () => {
65
+ const md = '## Section\nLine 1\nLine 2';
66
+ const result = parsePlan(md);
67
+ expect(result.sections[0].rawContent).toBe('## Section\nLine 1\nLine 2');
68
+ });
69
+ it('generates slugified section IDs', () => {
70
+ const md = '## Hello World!';
71
+ const result = parsePlan(md);
72
+ expect(result.sections[0].id).toBe('hello-world');
73
+ });
74
+ it('generates fallback ID for empty heading text after slugify', () => {
75
+ // Heading with only special chars that slugify removes
76
+ const md = '## !!!';
77
+ const result = parsePlan(md);
78
+ expect(result.sections[0].id).toBe('section-0');
79
+ });
80
+ it('preserves rawMarkdown in output', () => {
81
+ const md = '# Title\n\nSome content';
82
+ const result = parsePlan(md);
83
+ expect(result.rawMarkdown).toBe(md);
84
+ });
85
+ it('handles headings with trailing spaces', () => {
86
+ const md = '## Trimmed ';
87
+ const result = parsePlan(md);
88
+ expect(result.sections[0].heading).toBe('Trimmed');
89
+ });
90
+ it('handles deeply nested sections (h2 through h6)', () => {
91
+ const md = '## L2\n### L3\n#### L4\n##### L5\n###### L6';
92
+ const result = parsePlan(md);
93
+ expect(result.sections).toHaveLength(1);
94
+ expect(result.sections[0].children[0].children[0].children[0].children[0].heading).toBe('L6');
95
+ });
96
+ it('sibling at same level after nested children pops back correctly', () => {
97
+ const md = '## A\n### A1\n### A2\n## B';
98
+ const result = parsePlan(md);
99
+ expect(result.sections).toHaveLength(2);
100
+ expect(result.sections[0].children).toHaveLength(2);
101
+ expect(result.sections[1].heading).toBe('B');
102
+ expect(result.sections[1].children).toHaveLength(0);
103
+ });
104
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ const mockFs = vi.hoisted(() => ({
3
+ existsSync: vi.fn(),
4
+ readFileSync: vi.fn(),
5
+ watch: vi.fn(),
6
+ }));
7
+ vi.mock('node:fs', () => ({ default: mockFs, ...mockFs }));
8
+ const { watchPlansDir } = await import('./planWatcher.js');
9
+ describe('watchPlansDir', () => {
10
+ let watchCallback;
11
+ beforeEach(() => {
12
+ vi.resetAllMocks();
13
+ vi.useFakeTimers();
14
+ mockFs.existsSync.mockReturnValue(true);
15
+ mockFs.watch.mockImplementation((_path, callback) => {
16
+ watchCallback = callback;
17
+ return { close: vi.fn() };
18
+ });
19
+ });
20
+ afterEach(() => {
21
+ vi.useRealTimers();
22
+ });
23
+ it('warns and returns early if directory does not exist', () => {
24
+ mockFs.existsSync.mockReturnValue(false);
25
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
26
+ const onUpdate = vi.fn();
27
+ watchPlansDir('/nonexistent', onUpdate);
28
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('not found'));
29
+ expect(mockFs.watch).not.toHaveBeenCalled();
30
+ consoleSpy.mockRestore();
31
+ });
32
+ it('calls fs.watch on the provided directory', () => {
33
+ vi.spyOn(console, 'log').mockImplementation(() => { });
34
+ watchPlansDir('/plans', vi.fn());
35
+ expect(mockFs.watch).toHaveBeenCalledWith('/plans', expect.any(Function));
36
+ });
37
+ it('ignores non-.md files', () => {
38
+ vi.spyOn(console, 'log').mockImplementation(() => { });
39
+ const onUpdate = vi.fn();
40
+ watchPlansDir('/plans', onUpdate);
41
+ watchCallback('change', 'file.txt');
42
+ vi.advanceTimersByTime(500);
43
+ expect(onUpdate).not.toHaveBeenCalled();
44
+ });
45
+ it('ignores null filename', () => {
46
+ vi.spyOn(console, 'log').mockImplementation(() => { });
47
+ const onUpdate = vi.fn();
48
+ watchPlansDir('/plans', onUpdate);
49
+ watchCallback('change', null);
50
+ vi.advanceTimersByTime(500);
51
+ expect(onUpdate).not.toHaveBeenCalled();
52
+ });
53
+ it('calls onUpdate with filename and content after debounce', () => {
54
+ vi.spyOn(console, 'log').mockImplementation(() => { });
55
+ const onUpdate = vi.fn();
56
+ mockFs.readFileSync.mockReturnValue('# Plan content');
57
+ watchPlansDir('/plans', onUpdate);
58
+ watchCallback('change', 'test.md');
59
+ vi.advanceTimersByTime(100);
60
+ expect(onUpdate).not.toHaveBeenCalled();
61
+ vi.advanceTimersByTime(200);
62
+ expect(onUpdate).toHaveBeenCalledWith('test.md', '# Plan content');
63
+ });
64
+ it('debounces rapid events for the same file', () => {
65
+ vi.spyOn(console, 'log').mockImplementation(() => { });
66
+ const onUpdate = vi.fn();
67
+ mockFs.readFileSync.mockReturnValue('content');
68
+ watchPlansDir('/plans', onUpdate);
69
+ watchCallback('change', 'test.md');
70
+ vi.advanceTimersByTime(100);
71
+ watchCallback('change', 'test.md');
72
+ vi.advanceTimersByTime(100);
73
+ watchCallback('change', 'test.md');
74
+ vi.advanceTimersByTime(300);
75
+ expect(onUpdate).toHaveBeenCalledTimes(1);
76
+ });
77
+ it('handles file deleted between event and read', () => {
78
+ vi.spyOn(console, 'log').mockImplementation(() => { });
79
+ const onUpdate = vi.fn();
80
+ mockFs.readFileSync.mockImplementation(() => {
81
+ throw new Error('ENOENT');
82
+ });
83
+ watchPlansDir('/plans', onUpdate);
84
+ watchCallback('change', 'deleted.md');
85
+ vi.advanceTimersByTime(300);
86
+ expect(onUpdate).not.toHaveBeenCalled();
87
+ });
88
+ it('handles different files independently', () => {
89
+ vi.spyOn(console, 'log').mockImplementation(() => { });
90
+ const onUpdate = vi.fn();
91
+ mockFs.readFileSync.mockReturnValue('content');
92
+ watchPlansDir('/plans', onUpdate);
93
+ watchCallback('change', 'a.md');
94
+ watchCallback('change', 'b.md');
95
+ vi.advanceTimersByTime(300);
96
+ expect(onUpdate).toHaveBeenCalledTimes(2);
97
+ expect(onUpdate).toHaveBeenCalledWith('a.md', 'content');
98
+ expect(onUpdate).toHaveBeenCalledWith('b.md', 'content');
99
+ });
100
+ });