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.
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/dist/agent-yes.js +2 -0
- package/dist/amp-yes.js +2 -0
- package/dist/auggie-yes.js +2 -0
- package/dist/claude-yes.js +2 -0
- package/dist/cli.js +31474 -0
- package/dist/cli.js.map +483 -0
- package/dist/codex-yes.js +2 -0
- package/dist/copilot-yes.js +2 -0
- package/dist/cursor-yes.js +2 -0
- package/dist/gemini-yes.js +2 -0
- package/dist/grok-yes.js +2 -0
- package/dist/index.js +25148 -0
- package/dist/index.js.map +435 -0
- package/dist/qwen-yes.js +2 -0
- package/package.json +145 -0
- package/ts/ReadyManager.spec.ts +72 -0
- package/ts/ReadyManager.ts +16 -0
- package/ts/SUPPORTED_CLIS.ts +5 -0
- package/ts/catcher.spec.ts +259 -0
- package/ts/catcher.ts +35 -0
- package/ts/cli-idle.spec.ts +20 -0
- package/ts/cli.ts +30 -0
- package/ts/defineConfig.ts +12 -0
- package/ts/idleWaiter.spec.ts +55 -0
- package/ts/idleWaiter.ts +31 -0
- package/ts/index.ts +783 -0
- package/ts/logger.ts +17 -0
- package/ts/parseCliArgs.spec.ts +231 -0
- package/ts/parseCliArgs.ts +182 -0
- package/ts/postbuild.ts +29 -0
- package/ts/pty-fix.ts +155 -0
- package/ts/pty.ts +18 -0
- package/ts/removeControlCharacters.spec.ts +73 -0
- package/ts/removeControlCharacters.ts +8 -0
- package/ts/runningLock.spec.ts +485 -0
- package/ts/runningLock.ts +362 -0
- package/ts/session-integration.spec.ts +93 -0
- package/ts/sleep.ts +3 -0
- package/ts/utils.spec.ts +169 -0
- package/ts/utils.ts +23 -0
|
@@ -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
|
+
}
|