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.
- package/README.md +47 -9
- package/dist/cli-bundle.mjs +28293 -0
- package/dist/client/assets/index-DjikHpVb.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/bin/cc-plan-viewer.js +222 -75
- package/dist/server/server/app.js +171 -0
- package/dist/server/server/app.test.js +154 -0
- package/dist/server/server/index.js +18 -157
- package/dist/server/server/lifecycle.test.js +101 -0
- package/dist/server/server/planParser.test.js +104 -0
- package/dist/server/server/planWatcher.test.js +100 -0
- package/dist/server/server/reviewStore.test.js +88 -0
- package/dist/server-bundle.mjs +27988 -0
- package/hooks/plan-viewer-hook.cjs +19 -16
- package/package.json +30 -9
- package/dist/client/assets/index-CFUAZEOC.css +0 -1
- /package/dist/client/assets/{index-B9Ku0w1F.js → index-BRrHI1zG.js} +0 -0
|
@@ -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 {
|
|
8
|
-
import {
|
|
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
|
|
13
|
-
function
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
|
30
|
+
console.log(`[cc-plan-viewer] Plans directories: ${plansDirs.join(', ')}`);
|
|
173
31
|
});
|
|
174
|
-
// Watch
|
|
175
|
-
|
|
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
|
+
});
|