cc-plan-viewer 0.1.0 → 0.2.2

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,171 @@
1
+ import express from 'express';
2
+ import { createServer } from 'node:http';
3
+ import { WebSocketServer, WebSocket } from 'ws';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { parsePlan } from './planParser.js';
7
+ import { saveReview, getReview } from './reviewStore.js';
8
+ import { resetIdleTimer } from './lifecycle.js';
9
+ export function createApp(plansDirs) {
10
+ const plansDir = plansDirs[0] || '';
11
+ const app = express();
12
+ const server = createServer(app);
13
+ const wss = new WebSocketServer({ server });
14
+ app.use(express.json());
15
+ // Reset idle timer on every request
16
+ app.use((_req, _res, next) => {
17
+ resetIdleTimer();
18
+ next();
19
+ });
20
+ // Health check
21
+ app.get('/health', (_req, res) => {
22
+ res.json({ status: 'ok', plansDirs });
23
+ });
24
+ // List all plans across all ~/.claude*/plans/ directories
25
+ app.get('/api/plans', (_req, res) => {
26
+ try {
27
+ const allFiles = [];
28
+ for (const dir of plansDirs) {
29
+ if (!fs.existsSync(dir))
30
+ continue;
31
+ const files = fs.readdirSync(dir)
32
+ .filter(f => f.endsWith('.md') && !f.endsWith('.review.json'));
33
+ for (const f of files) {
34
+ const filePath = path.join(dir, f);
35
+ const stat = fs.statSync(filePath);
36
+ const review = getReview(filePath);
37
+ allFiles.push({
38
+ filename: f,
39
+ modified: stat.mtime.toISOString(),
40
+ size: stat.size,
41
+ hasReview: !!review,
42
+ reviewAction: review?.action ?? null,
43
+ dir,
44
+ });
45
+ }
46
+ }
47
+ allFiles.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
48
+ res.json(allFiles);
49
+ }
50
+ catch (err) {
51
+ res.status(500).json({ error: 'Failed to list plans' });
52
+ }
53
+ });
54
+ // Find a plan file across all plan directories
55
+ function findPlanFile(filename) {
56
+ for (const dir of plansDirs) {
57
+ const filePath = path.join(dir, filename);
58
+ if (fs.existsSync(filePath))
59
+ return filePath;
60
+ }
61
+ return null;
62
+ }
63
+ // Get a specific plan
64
+ app.get('/api/plans/:filename', (req, res) => {
65
+ const filename = req.params.filename;
66
+ if (!filename.endsWith('.md') || filename.includes('..')) {
67
+ res.status(400).json({ error: 'Invalid filename' });
68
+ return;
69
+ }
70
+ const filePath = findPlanFile(filename);
71
+ if (!filePath) {
72
+ res.status(404).json({ error: 'Plan not found' });
73
+ return;
74
+ }
75
+ try {
76
+ const content = fs.readFileSync(filePath, 'utf8');
77
+ const parsed = parsePlan(content);
78
+ const review = getReview(filePath);
79
+ res.json({ filename, parsed, review });
80
+ }
81
+ catch {
82
+ res.status(404).json({ error: 'Plan not found' });
83
+ }
84
+ });
85
+ // Hook notifies of plan update
86
+ app.post('/api/plan-updated', (req, res) => {
87
+ const { filePath, planOptions } = req.body;
88
+ const filename = path.basename(filePath || '');
89
+ // Broadcast to all WebSocket clients
90
+ const message = JSON.stringify({
91
+ type: 'plan-updated',
92
+ filename,
93
+ planOptions: planOptions || null,
94
+ });
95
+ for (const client of wss.clients) {
96
+ if (client.readyState === WebSocket.OPEN) {
97
+ client.send(message);
98
+ }
99
+ }
100
+ res.json({ ok: true });
101
+ });
102
+ // Save a review
103
+ app.post('/api/reviews/:filename', (req, res) => {
104
+ const filename = req.params.filename;
105
+ if (!filename.endsWith('.md') || filename.includes('..')) {
106
+ res.status(400).json({ error: 'Invalid filename' });
107
+ return;
108
+ }
109
+ const filePath = findPlanFile(filename);
110
+ if (!filePath) {
111
+ res.status(404).json({ error: 'Plan not found' });
112
+ return;
113
+ }
114
+ const review = {
115
+ planFile: filename,
116
+ action: req.body.action || 'feedback',
117
+ submittedAt: new Date().toISOString(),
118
+ consumedAt: null,
119
+ overallComment: req.body.overallComment || '',
120
+ inlineComments: req.body.inlineComments || [],
121
+ };
122
+ saveReview(filePath, review);
123
+ // Notify clients
124
+ const message = JSON.stringify({
125
+ type: 'review-submitted',
126
+ filename,
127
+ action: review.action,
128
+ });
129
+ for (const client of wss.clients) {
130
+ if (client.readyState === WebSocket.OPEN) {
131
+ client.send(message);
132
+ }
133
+ }
134
+ res.json({ ok: true, review });
135
+ });
136
+ // Get a review
137
+ app.get('/api/reviews/:filename', (req, res) => {
138
+ const filename = req.params.filename;
139
+ const filePath = findPlanFile(filename);
140
+ if (!filePath) {
141
+ res.status(404).json({ error: 'No review found' });
142
+ return;
143
+ }
144
+ const review = getReview(filePath);
145
+ if (!review) {
146
+ res.status(404).json({ error: 'No review found' });
147
+ return;
148
+ }
149
+ res.json(review);
150
+ });
151
+ // Serve SPA static files
152
+ // Try multiple paths depending on how the server is running
153
+ const clientDistCandidates = [
154
+ path.join(import.meta.dirname, 'client'), // bundled: ~/.cc-plan-viewer/server-bundle.mjs → ~/.cc-plan-viewer/client/
155
+ path.join(import.meta.dirname, '..', '..', 'client'), // compiled: dist/server/server/ → dist/client/
156
+ path.join(import.meta.dirname, '..', 'dist', 'client'), // dev: server/ → dist/client/
157
+ ];
158
+ const clientDist = clientDistCandidates.find((d) => fs.existsSync(d));
159
+ if (clientDist) {
160
+ app.use(express.static(clientDist));
161
+ app.get('/{*path}', (_req, res) => {
162
+ res.sendFile(path.join(clientDist, 'index.html'));
163
+ });
164
+ }
165
+ // WebSocket connection
166
+ wss.on('connection', (ws) => {
167
+ resetIdleTimer();
168
+ ws.on('message', () => resetIdleTimer());
169
+ });
170
+ return { app, server, wss };
171
+ }
@@ -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
+ });