@aaroncql/pim-agent 0.0.1 → 0.1.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.
Files changed (60) hide show
  1. package/README.md +19 -8
  2. package/bin/pim.ts +55 -3
  3. package/package.json +20 -5
  4. package/src/extensions/_init/index.ts +3 -2
  5. package/src/extensions/bash/capture.test.ts +0 -126
  6. package/src/extensions/bash/format.test.ts +0 -240
  7. package/src/extensions/bash/run.test.ts +0 -262
  8. package/src/extensions/command-picker/ranker.test.ts +0 -46
  9. package/src/extensions/edit/edit.test.ts +0 -285
  10. package/src/extensions/file-picker/catalog.test.ts +0 -263
  11. package/src/extensions/file-picker/index.test.ts +0 -168
  12. package/src/extensions/file-picker/ranker.test.ts +0 -94
  13. package/src/extensions/footer/git.test.ts +0 -76
  14. package/src/extensions/footer/index.test.ts +0 -161
  15. package/src/extensions/footer/segments.test.ts +0 -164
  16. package/src/extensions/glob/glob.test.ts +0 -171
  17. package/src/extensions/glob/index.test.ts +0 -68
  18. package/src/extensions/glob/render.test.ts +0 -126
  19. package/src/extensions/grep/grep.test.ts +0 -387
  20. package/src/extensions/grep/index.test.ts +0 -68
  21. package/src/extensions/grep/render.test.ts +0 -269
  22. package/src/extensions/read/read.test.ts +0 -177
  23. package/src/extensions/read/render.test.ts +0 -61
  24. package/src/extensions/subagent/index.test.ts +0 -44
  25. package/src/extensions/subagent/render.test.ts +0 -292
  26. package/src/extensions/subagent/subagent.test.ts +0 -315
  27. package/src/extensions/system-prompt/prompt.test.ts +0 -64
  28. package/src/extensions/todo/index.test.ts +0 -244
  29. package/src/extensions/todo/render.test.ts +0 -180
  30. package/src/extensions/todo/todo.test.ts +0 -222
  31. package/src/extensions/tps/index.test.ts +0 -254
  32. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +0 -119
  33. package/src/extensions/web-fetch/fetch.test.ts +0 -244
  34. package/src/extensions/web-fetch/render.test.ts +0 -56
  35. package/src/extensions/web-search/ExaMcpClient.test.ts +0 -143
  36. package/src/extensions/web-search/render.test.ts +0 -21
  37. package/src/extensions/web-search/search.test.ts +0 -53
  38. package/src/extensions/working-indicator/index.test.ts +0 -21
  39. package/src/extensions/write/render.test.ts +0 -64
  40. package/src/extensions/write/write.test.ts +0 -108
  41. package/src/shared/DiffLines.test.ts +0 -193
  42. package/src/shared/DiffRenderer.test.ts +0 -206
  43. package/src/shared/EditMatcher.test.ts +0 -123
  44. package/src/shared/FileScanner.test.ts +0 -158
  45. package/src/shared/FuzzyMatcher.test.ts +0 -114
  46. package/src/shared/GitignoreFilter.test.ts +0 -64
  47. package/src/shared/Lines.test.ts +0 -25
  48. package/src/shared/McpClient.test.ts +0 -235
  49. package/src/shared/OutputBudget.test.ts +0 -99
  50. package/src/shared/Paths.test.ts +0 -51
  51. package/src/shared/PimSettings.test.ts +0 -90
  52. package/src/shared/Renderer.test.ts +0 -190
  53. package/src/shared/SpillCache.test.ts +0 -94
  54. package/src/shared/Tools.test.ts +0 -392
  55. package/src/telegram/Config.test.ts +0 -275
  56. package/src/telegram/Markdown.test.ts +0 -143
  57. package/src/telegram/Renderer.test.ts +0 -216
  58. package/src/telegram/SessionRegistry.test.ts +0 -89
  59. package/src/telegram/TaskScheduler.test.ts +0 -278
  60. package/src/telegram/TaskTool.test.ts +0 -179
@@ -1,262 +0,0 @@
1
- import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm, stat } from "node:fs/promises";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import { SpillCache } from "../../shared/SpillCache";
6
- import { killAllActiveBashGroups, runBashCommand } from "./run";
7
- import {
8
- DRAIN_GRACE_MS,
9
- KILL_GRACE_MS,
10
- STREAM_HEAD_BYTES,
11
- STREAM_TAIL_BYTES,
12
- } from "./schema";
13
-
14
- let previousPimHomeDir: string | undefined;
15
- let testPimHomeDir: string | undefined;
16
-
17
- beforeAll(async () => {
18
- previousPimHomeDir = process.env.PIM_HOME_DIR;
19
- testPimHomeDir = await mkdtemp(join(tmpdir(), "pim-bash-home-"));
20
- process.env.PIM_HOME_DIR = testPimHomeDir;
21
- });
22
-
23
- afterAll(async () => {
24
- if (previousPimHomeDir === undefined) {
25
- delete process.env.PIM_HOME_DIR;
26
- } else {
27
- process.env.PIM_HOME_DIR = previousPimHomeDir;
28
- }
29
- if (testPimHomeDir) {
30
- await rm(testPimHomeDir, { recursive: true, force: true });
31
- }
32
- });
33
-
34
- function shellQuote(value: string): string {
35
- return `'${value.replaceAll("'", `'"'"'`)}'`;
36
- }
37
-
38
- async function waitForFile(path: string, timeoutMs: number): Promise<void> {
39
- const deadline = Date.now() + timeoutMs;
40
- while (Date.now() < deadline) {
41
- if (await Bun.file(path).exists()) {
42
- return;
43
- }
44
- await Bun.sleep(10);
45
- }
46
- throw new Error(`Timed out waiting for ${path}`);
47
- }
48
-
49
- async function waitForNoProcess(
50
- marker: string,
51
- timeoutMs: number
52
- ): Promise<void> {
53
- const deadline = Date.now() + timeoutMs;
54
- while (true) {
55
- const probe = Bun.spawnSync({ cmd: ["pgrep", "-f", marker] });
56
- if (probe.exitCode !== 0) {
57
- return;
58
- }
59
- if (Date.now() >= deadline) {
60
- throw new Error(`Process still running for marker ${marker}`);
61
- }
62
- await Bun.sleep(25);
63
- }
64
- }
65
-
66
- describe("runBashCommand (integration)", () => {
67
- test("captures stdout from a successful command", async () => {
68
- const r = await runBashCommand(
69
- "echo hello",
70
- 5000,
71
- undefined,
72
- process.cwd()
73
- );
74
- expect(r.exitCode).toBe(0);
75
- expect(r.aborted).toBe(false);
76
- expect(r.timedOut).toBe(false);
77
- expect(r.stdout.text.trim()).toBe("hello");
78
- expect(r.stderr.totalBytes).toBe(0);
79
- });
80
-
81
- test("captures stderr and non-zero exit", async () => {
82
- const r = await runBashCommand(
83
- "echo oops 1>&2; exit 3",
84
- 5000,
85
- undefined,
86
- process.cwd()
87
- );
88
- expect(r.exitCode).toBe(3);
89
- expect(r.stderr.text.trim()).toBe("oops");
90
- });
91
-
92
- test("respects cwd", async () => {
93
- const r = await runBashCommand("pwd", 5000, undefined, "/tmp");
94
- expect(r.exitCode).toBe(0);
95
- expect(r.stdout.text.trim()).toBe("/tmp");
96
- });
97
-
98
- test("times out and reports timedOut", async () => {
99
- const r = await runBashCommand("sleep 5", 25, undefined, process.cwd());
100
- expect(r.timedOut).toBe(true);
101
- expect(r.exitCode === null || r.exitCode !== 0).toBe(true);
102
- });
103
-
104
- test("aborts when signal fires", async () => {
105
- const ctrl = new AbortController();
106
- const promise = runBashCommand("sleep 5", 5000, ctrl.signal, process.cwd());
107
- ctrl.abort();
108
- const r = await promise;
109
- expect(r.aborted).toBe(true);
110
- });
111
-
112
- test("returns promptly when a backgrounded child inherits the pipe", async () => {
113
- const startedAt = Date.now();
114
- const r = await runBashCommand(
115
- "nohup sleep 47 > /dev/null 2>&1 & disown; echo done",
116
- 5000,
117
- undefined,
118
- process.cwd()
119
- );
120
- const elapsed = Date.now() - startedAt;
121
- expect(r.exitCode).toBe(0);
122
- expect(r.timedOut).toBe(false);
123
- expect(r.stdout.text.trim()).toBe("done");
124
- expect(elapsed).toBeLessThan(2000);
125
- // clean up the orphaned sleep so it doesn't linger
126
- try {
127
- Bun.spawnSync({ cmd: ["pkill", "-f", "sleep 47"] });
128
- } catch {}
129
- });
130
-
131
- test("timeout kills the whole process group", async () => {
132
- const marker = `pim-test-timeout-${Date.now()}`;
133
- const startedAt = Date.now();
134
- const r = await runBashCommand(
135
- `bash -c ${shellQuote(`exec -a ${marker} sleep 60`)}`,
136
- 50,
137
- undefined,
138
- process.cwd()
139
- );
140
- const elapsed = Date.now() - startedAt;
141
- expect(r.timedOut).toBe(true);
142
- expect(r.exitCode === null || r.exitCode !== 0).toBe(true);
143
- expect(elapsed).toBeLessThan(KILL_GRACE_MS + 2000);
144
- await waitForNoProcess(marker, KILL_GRACE_MS + 500);
145
- });
146
-
147
- test("does not crash on timeout while drains still hold readers", async () => {
148
- // Regression: stream.cancel() on a locked stream rejects (Bun throws
149
- // synchronously). Drains run fire-and-forget, so when a quiet command
150
- // times out (no output → drain blocked on read), the finally hits
151
- // cancel before drain has released. Unhandled rejection would crash Bun.
152
- const rejections: unknown[] = [];
153
- const onRejection = (err: unknown) => rejections.push(err);
154
- process.on("unhandledRejection", onRejection);
155
- try {
156
- const r = await runBashCommand("sleep 5", 25, undefined, process.cwd());
157
- expect(r.timedOut).toBe(true);
158
- await Bun.sleep(25);
159
- expect(rejections).toEqual([]);
160
- } finally {
161
- process.off("unhandledRejection", onRejection);
162
- }
163
- });
164
-
165
- test("bounded drain returns even when a daemon escapes our process group", async () => {
166
- // A child that calls setsid itself leaves our pgid and survives killGroup.
167
- // If it keeps the pipe open, drain would block forever; the DRAIN_GRACE_MS
168
- // bound forces us to return anyway. The marker lets us clean up after.
169
- const marker = `pim-test-detached-${Date.now()}`;
170
- const startedAt = Date.now();
171
- const r = await runBashCommand(
172
- `setsid bash -c 'sleep 60; echo ${marker}' > /tmp/${marker}.out 2>&1 < /dev/null & disown; echo done`,
173
- 5000,
174
- undefined,
175
- process.cwd()
176
- );
177
- const elapsed = Date.now() - startedAt;
178
- expect(r.exitCode).toBe(0);
179
- expect(r.stdout.text.trim()).toBe("done");
180
- expect(elapsed).toBeLessThan(DRAIN_GRACE_MS + 2000);
181
- try {
182
- Bun.spawnSync({ cmd: ["pkill", "-f", marker] });
183
- Bun.spawnSync({ cmd: ["rm", "-f", `/tmp/${marker}.out`] });
184
- } catch {}
185
- });
186
-
187
- test("killAllActiveBashGroups sweeps in-flight subtrees", async () => {
188
- const id = Date.now();
189
- const marker = `/tmp/pim-test-active-${id}.marker`;
190
- const ready = `/tmp/pim-test-active-${id}.ready`;
191
- const processMarker = `pim-test-active-${id}`;
192
- const pending = runBashCommand(
193
- `touch ${shellQuote(ready)}; bash -c ${shellQuote(`exec -a ${processMarker} sleep 30`)} && touch ${shellQuote(marker)}`,
194
- 30_000,
195
- undefined,
196
- process.cwd()
197
- );
198
- try {
199
- await waitForFile(ready, 1000);
200
- killAllActiveBashGroups("SIGTERM");
201
- const result = await pending;
202
- expect(result.exitCode === null || result.exitCode !== 0).toBe(true);
203
- await waitForNoProcess(processMarker, KILL_GRACE_MS + 500);
204
- expect(await Bun.file(marker).exists()).toBe(false);
205
- } finally {
206
- Bun.spawnSync({ cmd: ["rm", "-f", marker, ready] });
207
- }
208
- });
209
-
210
- test("truncates very large stdout", async () => {
211
- const totalBytes = STREAM_HEAD_BYTES + STREAM_TAIL_BYTES + 1000;
212
- const r = await runBashCommand(
213
- `head -c ${totalBytes} /dev/zero | tr '\\0' 'A'`,
214
- 5000,
215
- undefined,
216
- process.cwd()
217
- );
218
- expect(r.exitCode).toBe(0);
219
- expect(r.stdout.totalBytes).toBe(totalBytes);
220
- expect(r.stdout.truncated).toBe(true);
221
- expect(r.stdout.text).toContain("bytes truncated");
222
- });
223
-
224
- test("spills full stdout to ~/.pim/cache when truncated", async () => {
225
- const totalBytes = STREAM_HEAD_BYTES + STREAM_TAIL_BYTES + 4096;
226
- const r = await runBashCommand(
227
- `head -c ${totalBytes} /dev/zero | tr '\\0' 'A'`,
228
- 5000,
229
- undefined,
230
- process.cwd()
231
- );
232
- try {
233
- expect(r.exitCode).toBe(0);
234
- expect(r.stdout.truncated).toBe(true);
235
- expect(r.stdout.path).toBeTruthy();
236
- expect(r.stdout.path!.startsWith(join(SpillCache.dir(), "bash-"))).toBe(
237
- true
238
- );
239
- expect(r.stdout.path!.endsWith(".out")).toBe(true);
240
- const cacheMode = (await stat(SpillCache.dir())).mode & 0o777;
241
- const spillMode = (await stat(r.stdout.path!)).mode & 0o777;
242
- expect(cacheMode).toBe(0o700);
243
- expect(spillMode).toBe(0o600);
244
- const spilled = await Bun.file(r.stdout.path!).text();
245
- expect(spilled.length).toBe(totalBytes);
246
- expect(spilled).toBe("A".repeat(totalBytes));
247
- } finally {
248
- if (r.stdout.path) {
249
- try {
250
- Bun.spawnSync({ cmd: ["rm", "-f", r.stdout.path] });
251
- } catch {}
252
- }
253
- }
254
- });
255
-
256
- test("omits spill path when stream is empty", async () => {
257
- const r = await runBashCommand("true", 5000, undefined, process.cwd());
258
- expect(r.exitCode).toBe(0);
259
- expect(r.stdout.path).toBeNull();
260
- expect(r.stderr.path).toBeNull();
261
- });
262
- });
@@ -1,46 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
- import { rank } from "./ranker";
4
-
5
- const item = (name: string, description?: string): AutocompleteItem => ({
6
- value: name,
7
- label: name,
8
- ...(description !== undefined && { description }),
9
- });
10
-
11
- test("empty query returns items alphabetically by label", () => {
12
- const items = rank("", [
13
- item("rename", "Rename the session."),
14
- item("clear", "Clear the session."),
15
- item("help", "Show help."),
16
- ]);
17
-
18
- expect(items.map((i) => i.value)).toEqual(["clear", "help", "rename"]);
19
- });
20
-
21
- test("query ranks fuzzy matches by score", () => {
22
- const items = rank("cl", [
23
- item("rename", "Rename the session."),
24
- item("clear", "Clear the session."),
25
- item("help", "Show help."),
26
- ]);
27
-
28
- expect(items[0]?.value).toBe("clear");
29
- });
30
-
31
- test("matches against description when label doesn't contain query", () => {
32
- const items = rank("rename", [
33
- item("noop", "fully unrelated"),
34
- item("x", "rename the session"),
35
- ]);
36
-
37
- expect(items[0]?.value).toBe("x");
38
- });
39
-
40
- test("limit caps the returned items", () => {
41
- const items = rank("", [item("c"), item("a"), item("b"), item("d")], {
42
- limit: 2,
43
- });
44
-
45
- expect(items.map((i) => i.value)).toEqual(["a", "b"]);
46
- });
@@ -1,285 +0,0 @@
1
- import {
2
- chmod,
3
- link,
4
- mkdtemp,
5
- readFile,
6
- rm,
7
- stat,
8
- symlink,
9
- writeFile,
10
- } from "node:fs/promises";
11
- import { tmpdir } from "node:os";
12
- import { join } from "node:path";
13
- import { afterAll, describe, expect, test } from "bun:test";
14
- import { editFile, formatEditSummary } from "./edit";
15
- import type { RawEdit } from "./schema";
16
-
17
- const tempRoots: string[] = [];
18
-
19
- const tempRoot = async (): Promise<string> => {
20
- const root = await mkdtemp(join(tmpdir(), "pim-edit-tool-"));
21
- tempRoots.push(root);
22
- return root;
23
- };
24
-
25
- afterAll(async () => {
26
- await Promise.all(
27
- tempRoots.map((root) => rm(root, { force: true, recursive: true }))
28
- );
29
- });
30
-
31
- describe("editFile", () => {
32
- test("applies exact single edit", async () => {
33
- const root = await tempRoot();
34
- const path = join(root, "notes.txt");
35
- await writeFile(path, "alpha\nbeta\ngamma", "utf8");
36
-
37
- const outcome = await editFile(path, [
38
- { oldString: "beta", newString: "delta" },
39
- ]);
40
-
41
- expect(await readFile(path, "utf8")).toBe("alpha\ndelta\ngamma");
42
- expect(outcome.editCount).toBe(1);
43
- expect(outcome.noops).toEqual([]);
44
- expect(outcome.resolvedEdits[0]?.strategy).toBe("simple");
45
- expect(formatEditSummary(path, outcome)).toBe(
46
- `1 edit made to ${path}: lines 2.`
47
- );
48
- });
49
-
50
- test("applies multi-edit batch against pre-batch content", async () => {
51
- const root = await tempRoot();
52
- const path = join(root, "notes.txt");
53
- await writeFile(path, "alpha\nbeta\ngamma\ndelta", "utf8");
54
-
55
- await editFile(path, [
56
- { oldString: "beta", newString: "BETA" },
57
- { oldString: "delta", newString: "DELTA" },
58
- ]);
59
-
60
- expect(await readFile(path, "utf8")).toBe("alpha\nBETA\ngamma\nDELTA");
61
- });
62
-
63
- test("rejects sequential transformations in one batch", async () => {
64
- const root = await tempRoot();
65
- const path = join(root, "notes.txt");
66
- await writeFile(path, "alpha\nbeta", "utf8");
67
-
68
- await expect(
69
- editFile(path, [
70
- { oldString: "beta", newString: "delta" },
71
- { oldString: "delta", newString: "omega" },
72
- ])
73
- ).rejects.toThrow(/oldString was not found/);
74
- expect(await readFile(path, "utf8")).toBe("alpha\nbeta");
75
- });
76
-
77
- test("rejects overlapping resolved ranges", async () => {
78
- const root = await tempRoot();
79
- const path = join(root, "notes.txt");
80
- await writeFile(path, "alpha\nbeta\ngamma", "utf8");
81
-
82
- await expect(
83
- editFile(path, [
84
- { oldString: "beta\ngamma", newString: "merged" },
85
- { oldString: "gamma", newString: "changed" },
86
- ])
87
- ).rejects.toThrow(/target overlapping byte ranges/);
88
- expect(await readFile(path, "utf8")).toBe("alpha\nbeta\ngamma");
89
- });
90
-
91
- test("replaceAll replaces all occurrences and lists every range", async () => {
92
- const root = await tempRoot();
93
- const path = join(root, "notes.txt");
94
- await writeFile(path, "foo\nbar\nfoo", "utf8");
95
-
96
- const outcome = await editFile(path, [
97
- { oldString: "foo", newString: "baz", replaceAll: true },
98
- ]);
99
-
100
- expect(await readFile(path, "utf8")).toBe("baz\nbar\nbaz");
101
- expect(outcome.ranges).toEqual(["1", "3"]);
102
- expect(formatEditSummary(path, outcome)).toBe(
103
- `1 edit made to ${path}: lines 1 and 3.`
104
- );
105
- });
106
-
107
- test("replaceAll overlap with another edit is rejected", async () => {
108
- const root = await tempRoot();
109
- const path = join(root, "notes.txt");
110
- await writeFile(path, "foo\nbar\nfoo", "utf8");
111
-
112
- await expect(
113
- editFile(path, [
114
- { oldString: "foo", newString: "baz", replaceAll: true },
115
- { oldString: "foo\nbar", newString: "merged" },
116
- ])
117
- ).rejects.toThrow(/target overlapping byte ranges/);
118
- });
119
-
120
- test("rejects duplicate edits", async () => {
121
- const root = await tempRoot();
122
- const path = join(root, "notes.txt");
123
- await writeFile(path, "alpha\nbeta", "utf8");
124
-
125
- const edit: RawEdit = { oldString: "beta", newString: "delta" };
126
-
127
- await expect(editFile(path, [edit, edit])).rejects.toThrow(
128
- /Edits 0 and 1 are identical/
129
- );
130
- });
131
-
132
- test("rejects all-noop fuzzy batches and tracks partial noops", async () => {
133
- const root = await tempRoot();
134
- const noopPath = join(root, "noop.txt");
135
- const partialPath = join(root, "partial.txt");
136
- await writeFile(noopPath, " beta", "utf8");
137
- await writeFile(partialPath, "alpha\n beta", "utf8");
138
-
139
- await expect(
140
- editFile(noopPath, [{ oldString: "beta ", newString: " beta" }])
141
- ).rejects.toThrow(/All edits were no-ops/);
142
-
143
- const outcome = await editFile(partialPath, [
144
- { oldString: "alpha", newString: "ALPHA" },
145
- { oldString: "beta ", newString: " beta" },
146
- ]);
147
-
148
- expect(await readFile(partialPath, "utf8")).toBe("ALPHA\n beta");
149
- expect(outcome.noops).toEqual([{ index: 1, range: "2" }]);
150
- });
151
-
152
- test("reports closest regions when oldString is not found", async () => {
153
- const root = await tempRoot();
154
- const path = join(root, "notes.txt");
155
- await writeFile(path, "alpha\nbeta\ngamma", "utf8");
156
-
157
- await expect(
158
- editFile(path, [{ oldString: "betx", newString: "delta" }])
159
- ).rejects.toThrow(/oldString was not found[\s\S]*lines 2/);
160
- });
161
-
162
- test("reports bare not found when nothing is close", async () => {
163
- const root = await tempRoot();
164
- const path = join(root, "notes.txt");
165
- await writeFile(path, "aaaa\nbbbb", "utf8");
166
-
167
- await expect(
168
- editFile(path, [{ oldString: "zzzz", newString: "delta" }])
169
- ).rejects.toThrow("oldString was not found in the file.");
170
- });
171
-
172
- test("rejects multiple matches without replaceAll", async () => {
173
- const root = await tempRoot();
174
- const path = join(root, "notes.txt");
175
- await writeFile(path, "foo\nbar\nfoo", "utf8");
176
-
177
- await expect(
178
- editFile(path, [{ oldString: "foo", newString: "baz" }])
179
- ).rejects.toThrow(/matched multiple regions/);
180
- });
181
-
182
- test("rejects escape drift after fuzzy match", async () => {
183
- const root = await tempRoot();
184
- const path = join(root, "notes.txt");
185
- await writeFile(path, " beta", "utf8");
186
-
187
- await expect(
188
- editFile(path, [{ oldString: "beta ", newString: "new\\nvalue" }])
189
- ).rejects.toThrow(/newString contains literal escape text/);
190
- });
191
-
192
- test("preserves UTF-8 BOM when editing", async () => {
193
- const root = await tempRoot();
194
- const path = join(root, "bom.txt");
195
- await writeFile(path, "\uFEFFalpha\nbeta", "utf8");
196
-
197
- await editFile(path, [{ oldString: "beta", newString: "delta" }]);
198
-
199
- const bytes = await Bun.file(path).bytes();
200
- expect(Array.from(bytes.slice(0, 3))).toEqual([0xef, 0xbb, 0xbf]);
201
- expect(await readFile(path, "utf8")).toBe("\uFEFFalpha\ndelta");
202
- });
203
-
204
- test("preserves CRLF line endings when editing", async () => {
205
- const root = await tempRoot();
206
- const path = join(root, "crlf.txt");
207
- await writeFile(path, "alpha\r\nbeta\r\n", "utf8");
208
-
209
- await editFile(path, [{ oldString: "beta", newString: "delta" }]);
210
-
211
- expect(await readFile(path, "utf8")).toBe("alpha\r\ndelta\r\n");
212
- });
213
-
214
- test("updates symlink targets and preserves hard-link inodes plus mode", async () => {
215
- const root = await tempRoot();
216
- const target = join(root, "target.txt");
217
- const linked = join(root, "linked.txt");
218
- const alias = join(root, "alias.txt");
219
-
220
- await writeFile(target, "alpha\nbeta", "utf8");
221
- await chmod(target, 0o640);
222
- await link(target, linked);
223
- await symlink(target, alias);
224
-
225
- const before = await stat(target);
226
-
227
- await editFile(alias, [{ oldString: "beta", newString: "delta" }]);
228
-
229
- const after = await stat(target);
230
- expect(await readFile(target, "utf8")).toBe("alpha\ndelta");
231
- expect(await readFile(linked, "utf8")).toBe("alpha\ndelta");
232
- expect(after.ino).toBe(before.ino);
233
- expect(Number(after.mode) & 0o777).toBe(0o640);
234
- });
235
-
236
- test("serializes concurrent edits on the same path", async () => {
237
- const root = await tempRoot();
238
- const path = join(root, "notes.txt");
239
- await writeFile(path, "0\n", "utf8");
240
-
241
- const concurrent = await Promise.all([
242
- editFile(path, [{ oldString: "0", newString: "0\n1" }]),
243
- editFile(path, [{ oldString: "0", newString: "0\n2" }]),
244
- editFile(path, [{ oldString: "0", newString: "0\n3" }]),
245
- ]);
246
-
247
- const final = await readFile(path, "utf8");
248
- expect(final).toContain("0");
249
- expect(concurrent).toHaveLength(3);
250
- });
251
-
252
- test("buildDiff splits distant edits into separate hunks", async () => {
253
- const root = await tempRoot();
254
- const path = join(root, "notes.txt");
255
- const lines = Array.from(
256
- { length: 200 },
257
- (_, index) => `line ${index + 1}`
258
- );
259
- await writeFile(path, lines.join("\n"), "utf8");
260
-
261
- const outcome = await editFile(path, [
262
- { oldString: "line 1\nline 2", newString: "changed 1\nline 2" },
263
- { oldString: "line 199\nline 200", newString: "line 199\nchanged 200" },
264
- ]);
265
-
266
- expect(outcome.diff?.hunks).toHaveLength(2);
267
- expect(formatEditSummary(path, outcome)).toBe(
268
- `2 edits made to ${path}: lines 1-2 and 199-200.`
269
- );
270
- });
271
-
272
- test("rejects directories and binary files", async () => {
273
- const root = await tempRoot();
274
- const binary = join(root, "bin.dat");
275
- await Bun.write(binary, new Uint8Array([0, 1, 2, 0, 4]));
276
-
277
- await expect(
278
- editFile(root, [{ oldString: "x", newString: "y" }])
279
- ).rejects.toThrow(/Path is a directory/);
280
-
281
- await expect(
282
- editFile(binary, [{ oldString: "x", newString: "y" }])
283
- ).rejects.toThrow(/binary file/);
284
- });
285
- });