claude-yes 1.23.2 → 1.24.0

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,477 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync } from 'fs';
3
+ import { mkdir, readFile, rm, writeFile } from 'fs/promises';
4
+ import { homedir } from 'os';
5
+ import path from 'path';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import {
8
+ acquireLock,
9
+ cleanStaleLocks,
10
+ releaseLock,
11
+ shouldUseLock,
12
+ type Task,
13
+ updateCurrentTaskStatus,
14
+ } from './runningLock';
15
+
16
+ const LOCK_DIR = path.join(homedir(), '.claude-yes');
17
+ const LOCK_FILE = path.join(LOCK_DIR, 'running.lock.json');
18
+ const TEST_DIR = path.join(process.cwd(), '.cache', 'test-lock');
19
+
20
+ describe('runningLock', () => {
21
+ beforeEach(async () => {
22
+ // Clean up before each test
23
+ await cleanupLockFile();
24
+ await mkdir(TEST_DIR, { recursive: true });
25
+ });
26
+
27
+ afterEach(async () => {
28
+ // Clean up after each test
29
+ await cleanupLockFile();
30
+ await rm(TEST_DIR, { recursive: true, force: true });
31
+ });
32
+
33
+ describe('shouldUseLock', () => {
34
+ it('should return true for any directory', () => {
35
+ expect(shouldUseLock(process.cwd())).toBe(true);
36
+ expect(shouldUseLock('/tmp')).toBe(true);
37
+ expect(shouldUseLock(TEST_DIR)).toBe(true);
38
+ });
39
+ });
40
+
41
+ describe('acquireLock and releaseLock', () => {
42
+ it('should acquire and release lock successfully', async () => {
43
+ await acquireLock(TEST_DIR, 'Test task');
44
+
45
+ // Check lock file exists and contains task
46
+ const lockData = await readLockFile();
47
+ expect(lockData.tasks).toHaveLength(1);
48
+ expect(lockData.tasks[0].cwd).toBe(path.resolve(TEST_DIR));
49
+ expect(lockData.tasks[0].task).toBe('Test task');
50
+ expect(lockData.tasks[0].pid).toBe(process.pid);
51
+ expect(lockData.tasks[0].status).toBe('running');
52
+
53
+ // Release lock
54
+ await releaseLock();
55
+
56
+ // Check lock is released
57
+ const lockDataAfter = await readLockFile();
58
+ expect(lockDataAfter.tasks).toHaveLength(0);
59
+ });
60
+
61
+ it('should create lock directory if it does not exist', async () => {
62
+ // Remove lock directory
63
+ await rm(LOCK_DIR, { recursive: true, force: true });
64
+
65
+ await acquireLock(TEST_DIR, 'Test task');
66
+
67
+ // Check directory and file exist
68
+ expect(existsSync(LOCK_DIR)).toBe(true);
69
+ expect(existsSync(LOCK_FILE)).toBe(true);
70
+
71
+ await releaseLock();
72
+ });
73
+
74
+ it('should handle prompt longer than 100 characters', async () => {
75
+ const longPrompt = 'A'.repeat(150);
76
+
77
+ await acquireLock(TEST_DIR, longPrompt);
78
+
79
+ const lockData = await readLockFile();
80
+ expect(lockData.tasks[0].task).toHaveLength(100);
81
+ expect(lockData.tasks[0].task).toBe('A'.repeat(100));
82
+
83
+ await releaseLock();
84
+ });
85
+
86
+ it('should include timestamp fields', async () => {
87
+ const before = Date.now();
88
+ await acquireLock(TEST_DIR, 'Test task');
89
+ const after = Date.now();
90
+
91
+ const lockData = await readLockFile();
92
+ const task = lockData.tasks[0];
93
+
94
+ expect(task.startedAt).toBeGreaterThanOrEqual(before);
95
+ expect(task.startedAt).toBeLessThanOrEqual(after);
96
+ expect(task.lockedAt).toBeGreaterThanOrEqual(before);
97
+ expect(task.lockedAt).toBeLessThanOrEqual(after);
98
+
99
+ await releaseLock();
100
+ });
101
+ });
102
+
103
+ describe('git repository detection', () => {
104
+ it('should detect git root for repository', async () => {
105
+ // Use current directory which is a git repo
106
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
107
+ cwd: process.cwd(),
108
+ encoding: 'utf8',
109
+ }).trim();
110
+
111
+ await acquireLock(process.cwd(), 'Git repo task');
112
+
113
+ const lockData = await readLockFile();
114
+ expect(lockData.tasks[0].gitRoot).toBe(gitRoot);
115
+
116
+ await releaseLock();
117
+ });
118
+
119
+ it('should detect same git root for subdirectory', async () => {
120
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
121
+ cwd: process.cwd(),
122
+ encoding: 'utf8',
123
+ }).trim();
124
+ const subdir = path.join(process.cwd(), 'docs');
125
+
126
+ await acquireLock(subdir, 'Subdirectory task');
127
+
128
+ const lockData = await readLockFile();
129
+ expect(lockData.tasks[0].gitRoot).toBe(gitRoot);
130
+ expect(lockData.tasks[0].cwd).toBe(path.resolve(subdir));
131
+
132
+ await releaseLock();
133
+ });
134
+
135
+ it('should not have gitRoot for non-git directory', async () => {
136
+ // /tmp is typically not a git repository
137
+ await acquireLock('/tmp', 'Non-git task');
138
+
139
+ const lockData = await readLockFile();
140
+ expect(lockData.tasks[0].gitRoot).toBeUndefined();
141
+ expect(lockData.tasks[0].cwd).toBe('/tmp');
142
+
143
+ await releaseLock();
144
+ });
145
+ });
146
+
147
+ describe('updateCurrentTaskStatus', () => {
148
+ it('should update task status', async () => {
149
+ await acquireLock(TEST_DIR, 'Test task');
150
+
151
+ // Update to completed
152
+ await updateCurrentTaskStatus('completed');
153
+
154
+ let lockData = await readLockFile();
155
+ expect(lockData.tasks[0].status).toBe('completed');
156
+
157
+ // Update to failed
158
+ await updateCurrentTaskStatus('failed');
159
+
160
+ lockData = await readLockFile();
161
+ expect(lockData.tasks[0].status).toBe('failed');
162
+
163
+ await releaseLock();
164
+ });
165
+
166
+ it('should not throw when updating non-existent task', async () => {
167
+ // Should complete without throwing
168
+ await updateCurrentTaskStatus('completed');
169
+ // If we got here, no error was thrown
170
+ expect(true).toBe(true);
171
+ });
172
+ });
173
+
174
+ describe('cleanStaleLocks', () => {
175
+ it('should remove stale locks with invalid PIDs', async () => {
176
+ // Use a PID that definitely doesn't exist
177
+ const invalidPid = 9999999;
178
+
179
+ // Create a lock with a non-existent PID
180
+ const staleLock = {
181
+ tasks: [
182
+ {
183
+ cwd: TEST_DIR,
184
+ task: 'Stale task',
185
+ pid: invalidPid,
186
+ status: 'running' as const,
187
+ startedAt: Date.now() - 60000,
188
+ lockedAt: Date.now() - 60000,
189
+ },
190
+ ],
191
+ };
192
+
193
+ await mkdir(LOCK_DIR, { recursive: true });
194
+ await writeFile(LOCK_FILE, JSON.stringify(staleLock, null, 2));
195
+
196
+ // Verify the stale lock was written
197
+ let rawContent = await readFile(LOCK_FILE, 'utf8');
198
+ let rawData = JSON.parse(rawContent);
199
+ expect(rawData.tasks).toHaveLength(1);
200
+ expect(rawData.tasks[0].pid).toBe(invalidPid);
201
+
202
+ // Now acquire a lock - this will trigger cleanup of stale locks
203
+ await acquireLock(TEST_DIR, 'New task');
204
+
205
+ // The stale lock should be cleaned, and only our new task should remain
206
+ const lockData = await readLockFile();
207
+ expect(lockData.tasks).toHaveLength(1);
208
+ expect(lockData.tasks[0].pid).toBe(process.pid);
209
+ expect(lockData.tasks[0].task).toBe('New task');
210
+
211
+ await releaseLock();
212
+ });
213
+
214
+ it('should keep valid locks with running PIDs', async () => {
215
+ await acquireLock(TEST_DIR, 'Valid task');
216
+
217
+ // Clean stale locks (should not remove our lock)
218
+ await cleanStaleLocks();
219
+
220
+ const lockData = await readLockFile();
221
+ expect(lockData.tasks).toHaveLength(1);
222
+ expect(lockData.tasks[0].pid).toBe(process.pid);
223
+
224
+ await releaseLock();
225
+ });
226
+
227
+ it('should handle corrupted lock file', async () => {
228
+ // Write invalid JSON
229
+ await mkdir(LOCK_DIR, { recursive: true });
230
+ await writeFile(LOCK_FILE, 'invalid json{{{');
231
+
232
+ // Reading the lock file should handle corruption gracefully
233
+ const lockData = await readLockFile();
234
+
235
+ // Should return empty task list for corrupted file
236
+ expect(lockData.tasks).toHaveLength(0);
237
+ });
238
+
239
+ it('should handle missing lock file', async () => {
240
+ await rm(LOCK_FILE, { force: true });
241
+
242
+ // Reading non-existent lock file should return empty
243
+ const lockData = await readLockFile();
244
+ expect(lockData.tasks).toHaveLength(0);
245
+ });
246
+ });
247
+
248
+ describe('concurrent access', () => {
249
+ it('should handle multiple tasks from different processes', async () => {
250
+ // Simulate multiple processes by using different PIDs in the lock file
251
+ await acquireLock(TEST_DIR, 'Task 1');
252
+
253
+ // Manually add another task with our PID + 1 (simulating another process)
254
+ const lockData = await readLockFile();
255
+ lockData.tasks.push({
256
+ cwd: '/tmp',
257
+ task: 'Task 2',
258
+ pid: process.pid + 1,
259
+ status: 'running',
260
+ startedAt: Date.now(),
261
+ lockedAt: Date.now(),
262
+ });
263
+ await writeFile(LOCK_FILE, JSON.stringify(lockData, null, 2));
264
+
265
+ // Read and verify both tasks exist
266
+ const updatedLockData = await readLockFile();
267
+ expect(updatedLockData.tasks).toHaveLength(2);
268
+
269
+ await releaseLock();
270
+
271
+ // After release, only the "other process" task should remain
272
+ const finalLockData = await readLockFile();
273
+ expect(finalLockData.tasks).toHaveLength(1);
274
+ expect(finalLockData.tasks[0].pid).toBe(process.pid + 1);
275
+ });
276
+
277
+ it('should not duplicate tasks with same PID', async () => {
278
+ await acquireLock(TEST_DIR, 'Task 1');
279
+
280
+ // Try to acquire again with same PID
281
+ await acquireLock(TEST_DIR, 'Task 2');
282
+
283
+ // Should only have one task
284
+ const lockData = await readLockFile();
285
+ expect(lockData.tasks).toHaveLength(1);
286
+ expect(lockData.tasks[0].task).toBe('Task 2'); // Latest task
287
+
288
+ await releaseLock();
289
+ });
290
+ });
291
+
292
+ describe('lock file structure', () => {
293
+ it('should have all required fields', async () => {
294
+ await acquireLock(TEST_DIR, 'Complete task');
295
+
296
+ const lockData = await readLockFile();
297
+ const task = lockData.tasks[0];
298
+
299
+ expect(task).toHaveProperty('cwd');
300
+ expect(task).toHaveProperty('task');
301
+ expect(task).toHaveProperty('pid');
302
+ expect(task).toHaveProperty('status');
303
+ expect(task).toHaveProperty('startedAt');
304
+ expect(task).toHaveProperty('lockedAt');
305
+
306
+ expect(typeof task.cwd).toBe('string');
307
+ expect(typeof task.task).toBe('string');
308
+ expect(typeof task.pid).toBe('number');
309
+ expect(typeof task.status).toBe('string');
310
+ expect(typeof task.startedAt).toBe('number');
311
+ expect(typeof task.lockedAt).toBe('number');
312
+
313
+ await releaseLock();
314
+ });
315
+
316
+ it('should have valid status values', async () => {
317
+ const validStatuses: Task['status'][] = [
318
+ 'running',
319
+ 'queued',
320
+ 'completed',
321
+ 'failed',
322
+ ];
323
+
324
+ for (const status of validStatuses) {
325
+ await acquireLock(TEST_DIR, `Task with ${status}`);
326
+ await updateCurrentTaskStatus(status);
327
+
328
+ const lockData = await readLockFile();
329
+ expect(lockData.tasks[0].status).toBe(status);
330
+
331
+ await releaseLock();
332
+ }
333
+ });
334
+ });
335
+
336
+ describe('edge cases', () => {
337
+ it('should handle empty task description', async () => {
338
+ await acquireLock(TEST_DIR, '');
339
+
340
+ const lockData = await readLockFile();
341
+ expect(lockData.tasks[0].task).toBe('');
342
+
343
+ await releaseLock();
344
+ });
345
+
346
+ it('should handle special characters in task description', async () => {
347
+ const specialTask =
348
+ 'Task with "quotes" and \'apostrophes\' and \n newlines';
349
+
350
+ await acquireLock(TEST_DIR, specialTask);
351
+
352
+ const lockData = await readLockFile();
353
+ expect(lockData.tasks[0].task).toContain('quotes');
354
+
355
+ await releaseLock();
356
+ });
357
+
358
+ it('should resolve symlinks to real paths', async () => {
359
+ await acquireLock(TEST_DIR, 'Symlink test');
360
+
361
+ const lockData = await readLockFile();
362
+ // Should be an absolute path
363
+ expect(path.isAbsolute(lockData.tasks[0].cwd)).toBe(true);
364
+
365
+ await releaseLock();
366
+ });
367
+
368
+ it('should handle rapid acquire/release cycles', async () => {
369
+ for (let i = 0; i < 10; i++) {
370
+ await acquireLock(TEST_DIR, `Rapid task ${i}`);
371
+ await releaseLock();
372
+ }
373
+
374
+ // Final state should be clean
375
+ const lockData = await readLockFile();
376
+ expect(lockData.tasks).toHaveLength(0);
377
+ });
378
+ });
379
+
380
+ describe('queueing behavior', () => {
381
+ it('should detect when lock is held by same git repo', async () => {
382
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
383
+ cwd: process.cwd(),
384
+ encoding: 'utf8',
385
+ }).trim();
386
+
387
+ // Acquire lock at root
388
+ await acquireLock(gitRoot, 'Root task');
389
+
390
+ // Create a lock with different PID to simulate another process
391
+ const lockData = await readLockFile();
392
+ lockData.tasks.push({
393
+ cwd: path.join(gitRoot, 'subdirectory'),
394
+ gitRoot: gitRoot,
395
+ task: 'Subdirectory task',
396
+ pid: process.pid + 1,
397
+ status: 'running',
398
+ startedAt: Date.now(),
399
+ lockedAt: Date.now(),
400
+ });
401
+ await writeFile(LOCK_FILE, JSON.stringify(lockData, null, 2));
402
+
403
+ // Both tasks should be in the same git repo
404
+ const updatedLockData = await readLockFile();
405
+ const gitRoots = updatedLockData.tasks
406
+ .map((t) => t.gitRoot)
407
+ .filter((g) => g);
408
+ expect(new Set(gitRoots).size).toBe(1); // All same git root
409
+
410
+ await releaseLock();
411
+ });
412
+
413
+ it('should allow different directories without git repos', async () => {
414
+ // Create lock for /tmp
415
+ const lock = {
416
+ tasks: [
417
+ {
418
+ cwd: '/tmp',
419
+ task: 'Tmp task',
420
+ pid: process.pid + 1,
421
+ status: 'running' as const,
422
+ startedAt: Date.now(),
423
+ lockedAt: Date.now(),
424
+ },
425
+ ],
426
+ };
427
+ await writeFile(LOCK_FILE, JSON.stringify(lock, null, 2));
428
+
429
+ // Acquire lock for different directory
430
+ await acquireLock(TEST_DIR, 'Test task');
431
+
432
+ // Both should coexist
433
+ const lockData = await readLockFile();
434
+ expect(lockData.tasks).toHaveLength(2);
435
+
436
+ await releaseLock();
437
+ });
438
+ });
439
+
440
+ describe('disableLock option', () => {
441
+ it('should respect lock file operations when disableLock is false', async () => {
442
+ // Clean up first
443
+ await rm(LOCK_FILE, { force: true });
444
+
445
+ // When disableLock is not used (default behavior), locks work normally
446
+ await acquireLock(TEST_DIR, 'Test task');
447
+ expect(existsSync(LOCK_FILE)).toBe(true);
448
+
449
+ const lockData = await readLockFile();
450
+ expect(lockData.tasks).toHaveLength(1);
451
+
452
+ await releaseLock();
453
+
454
+ const lockDataAfter = await readLockFile();
455
+ expect(lockDataAfter.tasks).toHaveLength(0);
456
+ });
457
+ });
458
+ });
459
+
460
+ // Helper functions
461
+
462
+ async function cleanupLockFile() {
463
+ try {
464
+ await rm(LOCK_FILE, { force: true });
465
+ } catch {
466
+ // Ignore errors
467
+ }
468
+ }
469
+
470
+ async function readLockFile(): Promise<{ tasks: Task[] }> {
471
+ try {
472
+ const content = await readFile(LOCK_FILE, 'utf8');
473
+ return JSON.parse(content);
474
+ } catch {
475
+ return { tasks: [] };
476
+ }
477
+ }