agent-yes 1.31.41

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