@herdctl/core 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 (284) hide show
  1. package/dist/config/__tests__/agent.test.js +61 -13
  2. package/dist/config/__tests__/agent.test.js.map +1 -1
  3. package/dist/config/__tests__/merge.test.js +10 -3
  4. package/dist/config/__tests__/merge.test.js.map +1 -1
  5. package/dist/config/__tests__/schema.test.js +350 -1
  6. package/dist/config/__tests__/schema.test.js.map +1 -1
  7. package/dist/config/index.d.ts +1 -1
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +3 -1
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/config/schema.d.ts +841 -27
  12. package/dist/config/schema.d.ts.map +1 -1
  13. package/dist/config/schema.js +129 -10
  14. package/dist/config/schema.js.map +1 -1
  15. package/dist/fleet-manager/__tests__/coverage.test.js +14 -331
  16. package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -1
  17. package/dist/fleet-manager/__tests__/errors.test.js +1 -49
  18. package/dist/fleet-manager/__tests__/errors.test.js.map +1 -1
  19. package/dist/fleet-manager/__tests__/integration.test.js +114 -0
  20. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
  21. package/dist/fleet-manager/__tests__/job-control.test.js +13 -14
  22. package/dist/fleet-manager/__tests__/job-control.test.js.map +1 -1
  23. package/dist/fleet-manager/__tests__/reload.test.js +12 -2
  24. package/dist/fleet-manager/__tests__/reload.test.js.map +1 -1
  25. package/dist/fleet-manager/__tests__/status-queries.test.js +6 -0
  26. package/dist/fleet-manager/__tests__/status-queries.test.js.map +1 -1
  27. package/dist/fleet-manager/__tests__/trigger.test.js +10 -2
  28. package/dist/fleet-manager/__tests__/trigger.test.js.map +1 -1
  29. package/dist/fleet-manager/config-reload.d.ts +164 -0
  30. package/dist/fleet-manager/config-reload.d.ts.map +1 -0
  31. package/dist/fleet-manager/config-reload.js +445 -0
  32. package/dist/fleet-manager/config-reload.js.map +1 -0
  33. package/dist/fleet-manager/context.d.ts +76 -0
  34. package/dist/fleet-manager/context.d.ts.map +1 -0
  35. package/dist/fleet-manager/context.js +11 -0
  36. package/dist/fleet-manager/context.js.map +1 -0
  37. package/dist/fleet-manager/errors.d.ts +0 -25
  38. package/dist/fleet-manager/errors.d.ts.map +1 -1
  39. package/dist/fleet-manager/errors.js +0 -38
  40. package/dist/fleet-manager/errors.js.map +1 -1
  41. package/dist/fleet-manager/event-emitters.d.ts +123 -0
  42. package/dist/fleet-manager/event-emitters.d.ts.map +1 -0
  43. package/dist/fleet-manager/event-emitters.js +136 -0
  44. package/dist/fleet-manager/event-emitters.js.map +1 -0
  45. package/dist/fleet-manager/event-types.d.ts +0 -15
  46. package/dist/fleet-manager/event-types.d.ts.map +1 -1
  47. package/dist/fleet-manager/fleet-manager.d.ts +40 -653
  48. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  49. package/dist/fleet-manager/fleet-manager.js +95 -1720
  50. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  51. package/dist/fleet-manager/index.d.ts +13 -2
  52. package/dist/fleet-manager/index.d.ts.map +1 -1
  53. package/dist/fleet-manager/index.js +19 -6
  54. package/dist/fleet-manager/index.js.map +1 -1
  55. package/dist/fleet-manager/job-control.d.ts +67 -0
  56. package/dist/fleet-manager/job-control.d.ts.map +1 -0
  57. package/dist/fleet-manager/job-control.js +333 -0
  58. package/dist/fleet-manager/job-control.js.map +1 -0
  59. package/dist/fleet-manager/log-streaming.d.ts +171 -0
  60. package/dist/fleet-manager/log-streaming.d.ts.map +1 -0
  61. package/dist/fleet-manager/log-streaming.js +503 -0
  62. package/dist/fleet-manager/log-streaming.js.map +1 -0
  63. package/dist/fleet-manager/schedule-executor.d.ts +63 -0
  64. package/dist/fleet-manager/schedule-executor.d.ts.map +1 -0
  65. package/dist/fleet-manager/schedule-executor.js +209 -0
  66. package/dist/fleet-manager/schedule-executor.js.map +1 -0
  67. package/dist/fleet-manager/schedule-management.d.ts +71 -0
  68. package/dist/fleet-manager/schedule-management.d.ts.map +1 -0
  69. package/dist/fleet-manager/schedule-management.js +171 -0
  70. package/dist/fleet-manager/schedule-management.js.map +1 -0
  71. package/dist/fleet-manager/status-queries.d.ts +105 -0
  72. package/dist/fleet-manager/status-queries.d.ts.map +1 -0
  73. package/dist/fleet-manager/status-queries.js +247 -0
  74. package/dist/fleet-manager/status-queries.js.map +1 -0
  75. package/dist/fleet-manager/types.d.ts +0 -39
  76. package/dist/fleet-manager/types.d.ts.map +1 -1
  77. package/dist/runner/__tests__/job-executor.test.js +206 -1
  78. package/dist/runner/__tests__/job-executor.test.js.map +1 -1
  79. package/dist/runner/job-executor.d.ts +9 -0
  80. package/dist/runner/job-executor.d.ts.map +1 -1
  81. package/dist/runner/job-executor.js +78 -4
  82. package/dist/runner/job-executor.js.map +1 -1
  83. package/dist/runner/message-processor.d.ts.map +1 -1
  84. package/dist/runner/message-processor.js +53 -0
  85. package/dist/runner/message-processor.js.map +1 -1
  86. package/dist/runner/types.d.ts +3 -1
  87. package/dist/runner/types.d.ts.map +1 -1
  88. package/dist/scheduler/__tests__/cron.test.d.ts +2 -0
  89. package/dist/scheduler/__tests__/cron.test.d.ts.map +1 -0
  90. package/dist/scheduler/__tests__/cron.test.js +867 -0
  91. package/dist/scheduler/__tests__/cron.test.js.map +1 -0
  92. package/dist/scheduler/__tests__/scheduler.test.js +164 -5
  93. package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
  94. package/dist/scheduler/cron.d.ts +126 -0
  95. package/dist/scheduler/cron.d.ts.map +1 -0
  96. package/dist/scheduler/cron.js +390 -0
  97. package/dist/scheduler/cron.js.map +1 -0
  98. package/dist/scheduler/errors.d.ts +81 -1
  99. package/dist/scheduler/errors.d.ts.map +1 -1
  100. package/dist/scheduler/errors.js +81 -6
  101. package/dist/scheduler/errors.js.map +1 -1
  102. package/dist/scheduler/index.d.ts +1 -0
  103. package/dist/scheduler/index.d.ts.map +1 -1
  104. package/dist/scheduler/index.js +2 -0
  105. package/dist/scheduler/index.js.map +1 -1
  106. package/dist/scheduler/schedule-runner.d.ts +2 -2
  107. package/dist/scheduler/schedule-runner.d.ts.map +1 -1
  108. package/dist/scheduler/schedule-runner.js +20 -8
  109. package/dist/scheduler/schedule-runner.js.map +1 -1
  110. package/dist/scheduler/scheduler.d.ts +4 -4
  111. package/dist/scheduler/scheduler.d.ts.map +1 -1
  112. package/dist/scheduler/scheduler.js +95 -20
  113. package/dist/scheduler/scheduler.js.map +1 -1
  114. package/dist/scheduler/types.d.ts +1 -1
  115. package/dist/scheduler/types.d.ts.map +1 -1
  116. package/dist/state/schemas/job-metadata.d.ts +2 -2
  117. package/package.json +33 -8
  118. package/.turbo/turbo-build.log +0 -4
  119. package/.turbo/turbo-test.log +0 -219
  120. package/.turbo/turbo-typecheck.log +0 -4
  121. package/coverage/base.css +0 -224
  122. package/coverage/block-navigation.js +0 -87
  123. package/coverage/coverage-final.json +0 -51
  124. package/coverage/favicon.png +0 -0
  125. package/coverage/index.html +0 -251
  126. package/coverage/prettify.css +0 -1
  127. package/coverage/prettify.js +0 -2
  128. package/coverage/sort-arrow-sprite.png +0 -0
  129. package/coverage/sorter.js +0 -210
  130. package/coverage/src/config/index.html +0 -191
  131. package/coverage/src/config/index.ts.html +0 -442
  132. package/coverage/src/config/interpolate.ts.html +0 -652
  133. package/coverage/src/config/loader.ts.html +0 -1501
  134. package/coverage/src/config/merge.ts.html +0 -823
  135. package/coverage/src/config/parser.ts.html +0 -1213
  136. package/coverage/src/config/schema.ts.html +0 -1123
  137. package/coverage/src/fleet-manager/errors.ts.html +0 -2326
  138. package/coverage/src/fleet-manager/event-types.ts.html +0 -1219
  139. package/coverage/src/fleet-manager/fleet-manager.ts.html +0 -7030
  140. package/coverage/src/fleet-manager/index.html +0 -206
  141. package/coverage/src/fleet-manager/index.ts.html +0 -469
  142. package/coverage/src/fleet-manager/job-manager.ts.html +0 -2074
  143. package/coverage/src/fleet-manager/job-queue.ts.html +0 -2479
  144. package/coverage/src/fleet-manager/types.ts.html +0 -2602
  145. package/coverage/src/index.html +0 -116
  146. package/coverage/src/index.ts.html +0 -181
  147. package/coverage/src/runner/errors.ts.html +0 -1006
  148. package/coverage/src/runner/index.html +0 -191
  149. package/coverage/src/runner/index.ts.html +0 -256
  150. package/coverage/src/runner/job-executor.ts.html +0 -1429
  151. package/coverage/src/runner/message-processor.ts.html +0 -1150
  152. package/coverage/src/runner/sdk-adapter.ts.html +0 -658
  153. package/coverage/src/runner/types.ts.html +0 -559
  154. package/coverage/src/scheduler/errors.ts.html +0 -388
  155. package/coverage/src/scheduler/index.html +0 -206
  156. package/coverage/src/scheduler/index.ts.html +0 -244
  157. package/coverage/src/scheduler/interval.ts.html +0 -652
  158. package/coverage/src/scheduler/schedule-runner.ts.html +0 -1411
  159. package/coverage/src/scheduler/schedule-state.ts.html +0 -718
  160. package/coverage/src/scheduler/scheduler.ts.html +0 -1795
  161. package/coverage/src/scheduler/types.ts.html +0 -733
  162. package/coverage/src/state/directory.ts.html +0 -736
  163. package/coverage/src/state/errors.ts.html +0 -376
  164. package/coverage/src/state/fleet-state.ts.html +0 -937
  165. package/coverage/src/state/index.html +0 -221
  166. package/coverage/src/state/index.ts.html +0 -322
  167. package/coverage/src/state/job-metadata.ts.html +0 -1420
  168. package/coverage/src/state/job-output.ts.html +0 -1033
  169. package/coverage/src/state/schemas/fleet-state.ts.html +0 -445
  170. package/coverage/src/state/schemas/index.html +0 -176
  171. package/coverage/src/state/schemas/index.ts.html +0 -286
  172. package/coverage/src/state/schemas/job-metadata.ts.html +0 -628
  173. package/coverage/src/state/schemas/job-output.ts.html +0 -616
  174. package/coverage/src/state/schemas/session-info.ts.html +0 -361
  175. package/coverage/src/state/session.ts.html +0 -844
  176. package/coverage/src/state/types.ts.html +0 -262
  177. package/coverage/src/state/utils/atomic.ts.html +0 -748
  178. package/coverage/src/state/utils/index.html +0 -146
  179. package/coverage/src/state/utils/index.ts.html +0 -103
  180. package/coverage/src/state/utils/reads.ts.html +0 -1621
  181. package/coverage/src/work-sources/adapters/github.ts.html +0 -3583
  182. package/coverage/src/work-sources/adapters/index.html +0 -131
  183. package/coverage/src/work-sources/adapters/index.ts.html +0 -277
  184. package/coverage/src/work-sources/errors.ts.html +0 -298
  185. package/coverage/src/work-sources/index.html +0 -176
  186. package/coverage/src/work-sources/index.ts.html +0 -529
  187. package/coverage/src/work-sources/manager.ts.html +0 -1324
  188. package/coverage/src/work-sources/registry.ts.html +0 -619
  189. package/coverage/src/work-sources/types.ts.html +0 -568
  190. package/dist/fleet-manager/__tests__/event-helpers.test.d.ts +0 -7
  191. package/dist/fleet-manager/__tests__/event-helpers.test.d.ts.map +0 -1
  192. package/dist/fleet-manager/__tests__/event-helpers.test.js +0 -368
  193. package/dist/fleet-manager/__tests__/event-helpers.test.js.map +0 -1
  194. package/src/config/__tests__/agent.test.ts +0 -864
  195. package/src/config/__tests__/interpolate.test.ts +0 -644
  196. package/src/config/__tests__/loader.test.ts +0 -784
  197. package/src/config/__tests__/merge.test.ts +0 -751
  198. package/src/config/__tests__/parser.test.ts +0 -533
  199. package/src/config/__tests__/schema.test.ts +0 -873
  200. package/src/config/index.ts +0 -119
  201. package/src/config/interpolate.ts +0 -189
  202. package/src/config/loader.ts +0 -472
  203. package/src/config/merge.ts +0 -246
  204. package/src/config/parser.ts +0 -376
  205. package/src/config/schema.ts +0 -346
  206. package/src/fleet-manager/__tests__/coverage.test.ts +0 -2869
  207. package/src/fleet-manager/__tests__/errors.test.ts +0 -660
  208. package/src/fleet-manager/__tests__/event-helpers.test.ts +0 -448
  209. package/src/fleet-manager/__tests__/integration.test.ts +0 -1209
  210. package/src/fleet-manager/__tests__/job-control.test.ts +0 -283
  211. package/src/fleet-manager/__tests__/job-manager.test.ts +0 -869
  212. package/src/fleet-manager/__tests__/job-queue.test.ts +0 -401
  213. package/src/fleet-manager/__tests__/reload.test.ts +0 -751
  214. package/src/fleet-manager/__tests__/status-queries.test.ts +0 -595
  215. package/src/fleet-manager/__tests__/trigger.test.ts +0 -601
  216. package/src/fleet-manager/errors.ts +0 -747
  217. package/src/fleet-manager/event-types.ts +0 -378
  218. package/src/fleet-manager/fleet-manager.ts +0 -2315
  219. package/src/fleet-manager/index.ts +0 -128
  220. package/src/fleet-manager/job-manager.ts +0 -663
  221. package/src/fleet-manager/job-queue.ts +0 -798
  222. package/src/fleet-manager/types.ts +0 -839
  223. package/src/index.ts +0 -32
  224. package/src/runner/__tests__/errors.test.ts +0 -382
  225. package/src/runner/__tests__/job-executor.test.ts +0 -1708
  226. package/src/runner/__tests__/message-processor.test.ts +0 -960
  227. package/src/runner/__tests__/sdk-adapter.test.ts +0 -626
  228. package/src/runner/errors.ts +0 -307
  229. package/src/runner/index.ts +0 -57
  230. package/src/runner/job-executor.ts +0 -448
  231. package/src/runner/message-processor.ts +0 -355
  232. package/src/runner/sdk-adapter.ts +0 -191
  233. package/src/runner/types.ts +0 -158
  234. package/src/scheduler/__tests__/errors.test.ts +0 -159
  235. package/src/scheduler/__tests__/interval.test.ts +0 -515
  236. package/src/scheduler/__tests__/schedule-runner.test.ts +0 -798
  237. package/src/scheduler/__tests__/schedule-state.test.ts +0 -671
  238. package/src/scheduler/__tests__/scheduler.test.ts +0 -1280
  239. package/src/scheduler/errors.ts +0 -101
  240. package/src/scheduler/index.ts +0 -53
  241. package/src/scheduler/interval.ts +0 -189
  242. package/src/scheduler/schedule-runner.ts +0 -442
  243. package/src/scheduler/schedule-state.ts +0 -211
  244. package/src/scheduler/scheduler.ts +0 -570
  245. package/src/scheduler/types.ts +0 -216
  246. package/src/state/__tests__/directory.test.ts +0 -595
  247. package/src/state/__tests__/fleet-state.test.ts +0 -868
  248. package/src/state/__tests__/job-metadata-schema.test.ts +0 -414
  249. package/src/state/__tests__/job-metadata.test.ts +0 -831
  250. package/src/state/__tests__/job-output.test.ts +0 -856
  251. package/src/state/__tests__/session-schema.test.ts +0 -378
  252. package/src/state/__tests__/session.test.ts +0 -604
  253. package/src/state/directory.ts +0 -217
  254. package/src/state/errors.ts +0 -97
  255. package/src/state/fleet-state.ts +0 -284
  256. package/src/state/index.ts +0 -79
  257. package/src/state/job-metadata.ts +0 -445
  258. package/src/state/job-output.ts +0 -316
  259. package/src/state/schemas/__tests__/job-output.test.ts +0 -338
  260. package/src/state/schemas/fleet-state.ts +0 -120
  261. package/src/state/schemas/index.ts +0 -67
  262. package/src/state/schemas/job-metadata.ts +0 -181
  263. package/src/state/schemas/job-output.ts +0 -177
  264. package/src/state/schemas/session-info.ts +0 -92
  265. package/src/state/session.ts +0 -253
  266. package/src/state/types.ts +0 -59
  267. package/src/state/utils/__tests__/atomic.test.ts +0 -723
  268. package/src/state/utils/__tests__/reads.test.ts +0 -1071
  269. package/src/state/utils/atomic.ts +0 -221
  270. package/src/state/utils/index.ts +0 -6
  271. package/src/state/utils/reads.ts +0 -512
  272. package/src/work-sources/__tests__/github.test.ts +0 -1800
  273. package/src/work-sources/__tests__/manager.test.ts +0 -529
  274. package/src/work-sources/__tests__/registry.test.ts +0 -477
  275. package/src/work-sources/__tests__/types.test.ts +0 -479
  276. package/src/work-sources/adapters/github.ts +0 -1166
  277. package/src/work-sources/adapters/index.ts +0 -64
  278. package/src/work-sources/errors.ts +0 -71
  279. package/src/work-sources/index.ts +0 -148
  280. package/src/work-sources/manager.ts +0 -413
  281. package/src/work-sources/registry.ts +0 -178
  282. package/src/work-sources/types.ts +0 -161
  283. package/tsconfig.json +0 -9
  284. package/vitest.config.ts +0 -19
@@ -1,1071 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { mkdir, readFile, rm, realpath, writeFile } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { tmpdir } from "node:os";
5
- import {
6
- safeReadYaml,
7
- safeReadJsonl,
8
- readYaml,
9
- readJsonl,
10
- safeReadJson,
11
- readJson,
12
- SafeReadError,
13
- } from "../reads.js";
14
- import { atomicWriteYaml, appendJsonl } from "../atomic.js";
15
-
16
- // Helper to create a temp directory
17
- async function createTempDir(): Promise<string> {
18
- const baseDir = join(
19
- tmpdir(),
20
- `herdctl-reads-test-${Date.now()}-${Math.random().toString(36).slice(2)}`
21
- );
22
- await mkdir(baseDir, { recursive: true });
23
- // Resolve to real path to handle macOS /var -> /private/var symlink
24
- return await realpath(baseDir);
25
- }
26
-
27
- describe("SafeReadError", () => {
28
- it("creates error with correct properties", () => {
29
- const cause = new Error("Original error");
30
- const error = new SafeReadError(
31
- "Failed to read",
32
- "/path/to/file.yaml",
33
- cause
34
- );
35
-
36
- expect(error.name).toBe("SafeReadError");
37
- expect(error.message).toBe("Failed to read");
38
- expect(error.path).toBe("/path/to/file.yaml");
39
- expect(error.cause).toBe(cause);
40
- });
41
-
42
- it("extracts error code from cause", () => {
43
- const cause = new Error("File not found") as NodeJS.ErrnoException;
44
- cause.code = "ENOENT";
45
- const error = new SafeReadError("Failed to read", "/path/to/file.yaml", cause);
46
-
47
- expect(error.code).toBe("ENOENT");
48
- });
49
-
50
- it("creates error without cause", () => {
51
- const error = new SafeReadError("Failed to read", "/path/to/file.yaml");
52
-
53
- expect(error.cause).toBeUndefined();
54
- expect(error.code).toBeUndefined();
55
- });
56
- });
57
-
58
- describe("safeReadYaml", () => {
59
- let tempDir: string;
60
-
61
- beforeEach(async () => {
62
- tempDir = await createTempDir();
63
- });
64
-
65
- afterEach(async () => {
66
- await rm(tempDir, { recursive: true, force: true });
67
- });
68
-
69
- it("reads and parses valid YAML file", async () => {
70
- const filePath = join(tempDir, "config.yaml");
71
- await writeFile(filePath, "name: test\nversion: 1\n", "utf-8");
72
-
73
- const result = await safeReadYaml<{ name: string; version: number }>(filePath);
74
-
75
- expect(result.success).toBe(true);
76
- if (result.success) {
77
- expect(result.data).toEqual({ name: "test", version: 1 });
78
- }
79
- });
80
-
81
- it("handles complex nested YAML structures", async () => {
82
- const filePath = join(tempDir, "complex.yaml");
83
- const yamlContent = `
84
- fleet:
85
- name: my-fleet
86
- agents:
87
- - name: agent1
88
- model: claude-sonnet
89
- - name: agent2
90
- model: claude-opus
91
- settings:
92
- timeout: 30
93
- retries: 3
94
- `;
95
- await writeFile(filePath, yamlContent, "utf-8");
96
-
97
- const result = await safeReadYaml(filePath);
98
-
99
- expect(result.success).toBe(true);
100
- if (result.success) {
101
- expect(result.data).toEqual({
102
- fleet: {
103
- name: "my-fleet",
104
- agents: [
105
- { name: "agent1", model: "claude-sonnet" },
106
- { name: "agent2", model: "claude-opus" },
107
- ],
108
- settings: { timeout: 30, retries: 3 },
109
- },
110
- });
111
- }
112
- });
113
-
114
- it("handles empty file by returning null", async () => {
115
- const filePath = join(tempDir, "empty.yaml");
116
- await writeFile(filePath, "", "utf-8");
117
-
118
- const result = await safeReadYaml(filePath);
119
-
120
- expect(result.success).toBe(true);
121
- if (result.success) {
122
- expect(result.data).toBeNull();
123
- }
124
- });
125
-
126
- it("handles file with only whitespace", async () => {
127
- const filePath = join(tempDir, "whitespace.yaml");
128
- await writeFile(filePath, " \n \n ", "utf-8");
129
-
130
- const result = await safeReadYaml(filePath);
131
-
132
- expect(result.success).toBe(true);
133
- if (result.success) {
134
- expect(result.data).toBeNull();
135
- }
136
- });
137
-
138
- it("returns error for non-existent file", async () => {
139
- const filePath = join(tempDir, "nonexistent.yaml");
140
-
141
- const result = await safeReadYaml(filePath);
142
-
143
- expect(result.success).toBe(false);
144
- if (!result.success) {
145
- expect(result.error).toBeInstanceOf(SafeReadError);
146
- expect(result.error.code).toBe("ENOENT");
147
- }
148
- });
149
-
150
- it("returns error for invalid YAML syntax", async () => {
151
- const filePath = join(tempDir, "invalid.yaml");
152
- await writeFile(filePath, "key: [unclosed", "utf-8");
153
-
154
- const result = await safeReadYaml(filePath);
155
-
156
- expect(result.success).toBe(false);
157
- if (!result.success) {
158
- expect(result.error).toBeInstanceOf(SafeReadError);
159
- }
160
- });
161
-
162
- it("retries on transient parse errors", async () => {
163
- const filePath = join(tempDir, "retry.yaml");
164
- let readCount = 0;
165
-
166
- // Mock read function that returns truncated content on first read
167
- const mockReadFn = async () => {
168
- readCount++;
169
- if (readCount === 1) {
170
- return "key: [unclosed array"; // Truly invalid YAML - causes parse error
171
- }
172
- return "key: value\ncomplete: data\n";
173
- };
174
-
175
- const result = await safeReadYaml(filePath, {
176
- readFn: mockReadFn,
177
- baseDelayMs: 1,
178
- });
179
-
180
- expect(result.success).toBe(true);
181
- expect(readCount).toBe(2);
182
- });
183
-
184
- it("handles YAML arrays", async () => {
185
- const filePath = join(tempDir, "array.yaml");
186
- await writeFile(filePath, "- item1\n- item2\n- item3\n", "utf-8");
187
-
188
- const result = await safeReadYaml<string[]>(filePath);
189
-
190
- expect(result.success).toBe(true);
191
- if (result.success) {
192
- expect(result.data).toEqual(["item1", "item2", "item3"]);
193
- }
194
- });
195
-
196
- it("handles YAML with unicode characters", async () => {
197
- const filePath = join(tempDir, "unicode.yaml");
198
- await writeFile(filePath, "greeting: 你好世界\nemoji: 🚀\n", "utf-8");
199
-
200
- const result = await safeReadYaml<{ greeting: string; emoji: string }>(filePath);
201
-
202
- expect(result.success).toBe(true);
203
- if (result.success) {
204
- expect(result.data).toEqual({ greeting: "你好世界", emoji: "🚀" });
205
- }
206
- });
207
-
208
- it("handles YAML with null values", async () => {
209
- const filePath = join(tempDir, "nullable.yaml");
210
- await writeFile(filePath, "present: value\nabsent: null\nempty: ~\n", "utf-8");
211
-
212
- const result = await safeReadYaml(filePath);
213
-
214
- expect(result.success).toBe(true);
215
- if (result.success) {
216
- expect(result.data).toEqual({
217
- present: "value",
218
- absent: null,
219
- empty: null,
220
- });
221
- }
222
- });
223
-
224
- it("respects maxRetries option", async () => {
225
- let readCount = 0;
226
-
227
- const mockReadFn = async () => {
228
- readCount++;
229
- // Use content that triggers a "transient" parse error
230
- return "key: [unclosed array"; // Invalid YAML with "unexpected" error
231
- };
232
-
233
- const result = await safeReadYaml("/fake/path.yaml", {
234
- readFn: mockReadFn,
235
- maxRetries: 5,
236
- baseDelayMs: 1,
237
- });
238
-
239
- expect(result.success).toBe(false);
240
- expect(readCount).toBe(6); // Initial + 5 retries
241
- });
242
-
243
- it("does not retry on ENOENT error", async () => {
244
- let readCount = 0;
245
-
246
- const mockReadFn = async () => {
247
- readCount++;
248
- const error = new Error("File not found") as NodeJS.ErrnoException;
249
- error.code = "ENOENT";
250
- throw error;
251
- };
252
-
253
- const result = await safeReadYaml("/fake/path.yaml", {
254
- readFn: mockReadFn,
255
- maxRetries: 5,
256
- baseDelayMs: 1,
257
- });
258
-
259
- expect(result.success).toBe(false);
260
- expect(readCount).toBe(1); // No retries
261
- });
262
-
263
- it("does not retry on EACCES error", async () => {
264
- let readCount = 0;
265
-
266
- const mockReadFn = async () => {
267
- readCount++;
268
- const error = new Error("Permission denied") as NodeJS.ErrnoException;
269
- error.code = "EACCES";
270
- throw error;
271
- };
272
-
273
- const result = await safeReadYaml("/fake/path.yaml", {
274
- readFn: mockReadFn,
275
- maxRetries: 5,
276
- baseDelayMs: 1,
277
- });
278
-
279
- expect(result.success).toBe(false);
280
- expect(readCount).toBe(1); // No retries
281
- });
282
- });
283
-
284
- describe("safeReadJsonl", () => {
285
- let tempDir: string;
286
-
287
- beforeEach(async () => {
288
- tempDir = await createTempDir();
289
- });
290
-
291
- afterEach(async () => {
292
- await rm(tempDir, { recursive: true, force: true });
293
- });
294
-
295
- it("reads and parses valid JSONL file", async () => {
296
- const filePath = join(tempDir, "events.jsonl");
297
- await writeFile(
298
- filePath,
299
- '{"id":1}\n{"id":2}\n{"id":3}\n',
300
- "utf-8"
301
- );
302
-
303
- const result = await safeReadJsonl<{ id: number }>(filePath);
304
-
305
- expect(result.success).toBe(true);
306
- if (result.success) {
307
- expect(result.data).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
308
- expect(result.skippedLines).toBe(0);
309
- }
310
- });
311
-
312
- it("handles empty file", async () => {
313
- const filePath = join(tempDir, "empty.jsonl");
314
- await writeFile(filePath, "", "utf-8");
315
-
316
- const result = await safeReadJsonl(filePath);
317
-
318
- expect(result.success).toBe(true);
319
- if (result.success) {
320
- expect(result.data).toEqual([]);
321
- expect(result.skippedLines).toBe(0);
322
- }
323
- });
324
-
325
- it("handles file with only whitespace", async () => {
326
- const filePath = join(tempDir, "whitespace.jsonl");
327
- await writeFile(filePath, " \n \n ", "utf-8");
328
-
329
- const result = await safeReadJsonl(filePath);
330
-
331
- expect(result.success).toBe(true);
332
- if (result.success) {
333
- expect(result.data).toEqual([]);
334
- }
335
- });
336
-
337
- it("handles incomplete last line by skipping it", async () => {
338
- const filePath = join(tempDir, "incomplete.jsonl");
339
- await writeFile(
340
- filePath,
341
- '{"id":1}\n{"id":2}\n{"id":3,"partial',
342
- "utf-8"
343
- );
344
-
345
- const result = await safeReadJsonl<{ id: number }>(filePath);
346
-
347
- expect(result.success).toBe(true);
348
- if (result.success) {
349
- expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
350
- expect(result.skippedLines).toBe(1);
351
- }
352
- });
353
-
354
- it("handles file ending with newline", async () => {
355
- const filePath = join(tempDir, "trailing.jsonl");
356
- await writeFile(
357
- filePath,
358
- '{"id":1}\n{"id":2}\n',
359
- "utf-8"
360
- );
361
-
362
- const result = await safeReadJsonl<{ id: number }>(filePath);
363
-
364
- expect(result.success).toBe(true);
365
- if (result.success) {
366
- expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
367
- expect(result.skippedLines).toBe(0);
368
- }
369
- });
370
-
371
- it("returns error for non-existent file", async () => {
372
- const filePath = join(tempDir, "nonexistent.jsonl");
373
-
374
- const result = await safeReadJsonl(filePath);
375
-
376
- expect(result.success).toBe(false);
377
- if (!result.success) {
378
- expect(result.error).toBeInstanceOf(SafeReadError);
379
- expect(result.error.code).toBe("ENOENT");
380
- }
381
- });
382
-
383
- it("fails on invalid middle line by default", async () => {
384
- const filePath = join(tempDir, "invalid-middle.jsonl");
385
- await writeFile(
386
- filePath,
387
- '{"id":1}\ninvalid json here\n{"id":3}\n',
388
- "utf-8"
389
- );
390
-
391
- const result = await safeReadJsonl(filePath);
392
-
393
- expect(result.success).toBe(false);
394
- });
395
-
396
- it("skips invalid lines when skipInvalidLines is true", async () => {
397
- const filePath = join(tempDir, "skip-invalid.jsonl");
398
- await writeFile(
399
- filePath,
400
- '{"id":1}\ninvalid json here\n{"id":3}\n',
401
- "utf-8"
402
- );
403
-
404
- const result = await safeReadJsonl<{ id: number }>(filePath, {
405
- skipInvalidLines: true,
406
- });
407
-
408
- expect(result.success).toBe(true);
409
- if (result.success) {
410
- expect(result.data).toEqual([{ id: 1 }, { id: 3 }]);
411
- expect(result.skippedLines).toBe(1);
412
- }
413
- });
414
-
415
- it("handles complex JSON objects", async () => {
416
- const filePath = join(tempDir, "complex.jsonl");
417
- const obj1 = {
418
- type: "event",
419
- data: { nested: { value: 1 } },
420
- tags: ["a", "b"],
421
- };
422
- const obj2 = {
423
- type: "result",
424
- data: { output: "Hello\nWorld" },
425
- tags: [],
426
- };
427
- await writeFile(
428
- filePath,
429
- `${JSON.stringify(obj1)}\n${JSON.stringify(obj2)}\n`,
430
- "utf-8"
431
- );
432
-
433
- const result = await safeReadJsonl(filePath);
434
-
435
- expect(result.success).toBe(true);
436
- if (result.success) {
437
- expect(result.data).toEqual([obj1, obj2]);
438
- }
439
- });
440
-
441
- it("handles JSON with unicode characters", async () => {
442
- const filePath = join(tempDir, "unicode.jsonl");
443
- await writeFile(
444
- filePath,
445
- '{"text":"你好世界"}\n{"emoji":"🚀"}\n',
446
- "utf-8"
447
- );
448
-
449
- const result = await safeReadJsonl<{ text?: string; emoji?: string }>(filePath);
450
-
451
- expect(result.success).toBe(true);
452
- if (result.success) {
453
- expect(result.data).toEqual([{ text: "你好世界" }, { emoji: "🚀" }]);
454
- }
455
- });
456
-
457
- it("handles primitive JSON values", async () => {
458
- const filePath = join(tempDir, "primitives.jsonl");
459
- await writeFile(
460
- filePath,
461
- '"string"\n42\ntrue\nnull\n',
462
- "utf-8"
463
- );
464
-
465
- const result = await safeReadJsonl(filePath);
466
-
467
- expect(result.success).toBe(true);
468
- if (result.success) {
469
- expect(result.data).toEqual(["string", 42, true, null]);
470
- }
471
- });
472
-
473
- it("handles arrays as JSON lines", async () => {
474
- const filePath = join(tempDir, "arrays.jsonl");
475
- await writeFile(
476
- filePath,
477
- '[1,2,3]\n["a","b"]\n',
478
- "utf-8"
479
- );
480
-
481
- const result = await safeReadJsonl(filePath);
482
-
483
- expect(result.success).toBe(true);
484
- if (result.success) {
485
- expect(result.data).toEqual([[1, 2, 3], ["a", "b"]]);
486
- }
487
- });
488
-
489
- it("retries on transient read errors", async () => {
490
- let readCount = 0;
491
-
492
- const mockReadFn = async () => {
493
- readCount++;
494
- if (readCount === 1) {
495
- throw new Error("Temporary IO error");
496
- }
497
- return '{"id":1}\n{"id":2}\n';
498
- };
499
-
500
- const result = await safeReadJsonl("/fake/path.jsonl", {
501
- readFn: mockReadFn,
502
- baseDelayMs: 1,
503
- });
504
-
505
- expect(result.success).toBe(true);
506
- expect(readCount).toBe(2);
507
- });
508
-
509
- it("does not retry on ENOENT error", async () => {
510
- let readCount = 0;
511
-
512
- const mockReadFn = async () => {
513
- readCount++;
514
- const error = new Error("File not found") as NodeJS.ErrnoException;
515
- error.code = "ENOENT";
516
- throw error;
517
- };
518
-
519
- const result = await safeReadJsonl("/fake/path.jsonl", {
520
- readFn: mockReadFn,
521
- maxRetries: 5,
522
- baseDelayMs: 1,
523
- });
524
-
525
- expect(result.success).toBe(false);
526
- expect(readCount).toBe(1); // No retries
527
- });
528
-
529
- it("handles large JSONL files", async () => {
530
- const filePath = join(tempDir, "large.jsonl");
531
- const lines: string[] = [];
532
- for (let i = 0; i < 1000; i++) {
533
- lines.push(JSON.stringify({ id: i, data: "x".repeat(100) }));
534
- }
535
- await writeFile(filePath, lines.join("\n") + "\n", "utf-8");
536
-
537
- const result = await safeReadJsonl<{ id: number; data: string }>(filePath);
538
-
539
- expect(result.success).toBe(true);
540
- if (result.success) {
541
- expect(result.data).toHaveLength(1000);
542
- expect(result.data[0].id).toBe(0);
543
- expect(result.data[999].id).toBe(999);
544
- }
545
- });
546
- });
547
-
548
- describe("readYaml (throwing variant)", () => {
549
- let tempDir: string;
550
-
551
- beforeEach(async () => {
552
- tempDir = await createTempDir();
553
- });
554
-
555
- afterEach(async () => {
556
- await rm(tempDir, { recursive: true, force: true });
557
- });
558
-
559
- it("returns parsed data on success", async () => {
560
- const filePath = join(tempDir, "config.yaml");
561
- await writeFile(filePath, "name: test\n", "utf-8");
562
-
563
- const data = await readYaml<{ name: string }>(filePath);
564
-
565
- expect(data).toEqual({ name: "test" });
566
- });
567
-
568
- it("throws SafeReadError on failure", async () => {
569
- const filePath = join(tempDir, "nonexistent.yaml");
570
-
571
- await expect(readYaml(filePath)).rejects.toThrow(SafeReadError);
572
- });
573
- });
574
-
575
- describe("readJsonl (throwing variant)", () => {
576
- let tempDir: string;
577
-
578
- beforeEach(async () => {
579
- tempDir = await createTempDir();
580
- });
581
-
582
- afterEach(async () => {
583
- await rm(tempDir, { recursive: true, force: true });
584
- });
585
-
586
- it("returns parsed data on success", async () => {
587
- const filePath = join(tempDir, "events.jsonl");
588
- await writeFile(filePath, '{"id":1}\n{"id":2}\n', "utf-8");
589
-
590
- const data = await readJsonl<{ id: number }>(filePath);
591
-
592
- expect(data).toEqual([{ id: 1 }, { id: 2 }]);
593
- });
594
-
595
- it("throws SafeReadError on failure", async () => {
596
- const filePath = join(tempDir, "nonexistent.jsonl");
597
-
598
- await expect(readJsonl(filePath)).rejects.toThrow(SafeReadError);
599
- });
600
- });
601
-
602
- describe("concurrent read safety", () => {
603
- let tempDir: string;
604
-
605
- beforeEach(async () => {
606
- tempDir = await createTempDir();
607
- });
608
-
609
- afterEach(async () => {
610
- await rm(tempDir, { recursive: true, force: true });
611
- });
612
-
613
- it("handles concurrent reads of same YAML file", async () => {
614
- const filePath = join(tempDir, "concurrent.yaml");
615
- await atomicWriteYaml(filePath, { version: 1, data: "test" });
616
-
617
- // Start multiple reads concurrently
618
- const reads = [];
619
- for (let i = 0; i < 50; i++) {
620
- reads.push(safeReadYaml(filePath));
621
- }
622
-
623
- const results = await Promise.all(reads);
624
-
625
- // All reads should succeed
626
- for (const result of results) {
627
- expect(result.success).toBe(true);
628
- if (result.success) {
629
- expect(result.data).toEqual({ version: 1, data: "test" });
630
- }
631
- }
632
- });
633
-
634
- it("handles concurrent reads of same JSONL file", async () => {
635
- const filePath = join(tempDir, "concurrent.jsonl");
636
- for (let i = 0; i < 10; i++) {
637
- await appendJsonl(filePath, { id: i });
638
- }
639
-
640
- // Start multiple reads concurrently
641
- const reads = [];
642
- for (let i = 0; i < 50; i++) {
643
- reads.push(safeReadJsonl<{ id: number }>(filePath));
644
- }
645
-
646
- const results = await Promise.all(reads);
647
-
648
- // All reads should succeed with same data
649
- for (const result of results) {
650
- expect(result.success).toBe(true);
651
- if (result.success) {
652
- expect(result.data).toHaveLength(10);
653
- const ids = result.data.map((d) => d.id).sort((a, b) => a - b);
654
- expect(ids).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
655
- }
656
- }
657
- });
658
-
659
- it("handles reads while JSONL file is being appended to", async () => {
660
- const filePath = join(tempDir, "append-while-read.jsonl");
661
-
662
- // Start with some initial data
663
- for (let i = 0; i < 5; i++) {
664
- await appendJsonl(filePath, { id: i });
665
- }
666
-
667
- // Start concurrent reads and writes
668
- const operations: Promise<unknown>[] = [];
669
-
670
- // 20 reads
671
- for (let i = 0; i < 20; i++) {
672
- operations.push(safeReadJsonl<{ id: number }>(filePath));
673
- }
674
-
675
- // 10 appends
676
- for (let i = 5; i < 15; i++) {
677
- operations.push(appendJsonl(filePath, { id: i }));
678
- }
679
-
680
- const results = await Promise.all(operations);
681
-
682
- // All read operations should succeed
683
- const readResults = results.slice(0, 20) as Awaited<
684
- ReturnType<typeof safeReadJsonl<{ id: number }>>
685
- >[];
686
- for (const result of readResults) {
687
- expect(result.success).toBe(true);
688
- if (result.success) {
689
- // Should have at least the initial 5 entries
690
- expect(result.data.length).toBeGreaterThanOrEqual(5);
691
- // All entries should be valid
692
- for (const entry of result.data) {
693
- expect(typeof entry.id).toBe("number");
694
- }
695
- }
696
- }
697
-
698
- // Final read should have all entries
699
- const finalResult = await safeReadJsonl<{ id: number }>(filePath);
700
- expect(finalResult.success).toBe(true);
701
- if (finalResult.success) {
702
- expect(finalResult.data).toHaveLength(15);
703
- }
704
- });
705
-
706
- it("handles reads during atomic YAML writes", async () => {
707
- const filePath = join(tempDir, "atomic-write-read.yaml");
708
- await atomicWriteYaml(filePath, { version: 0 });
709
-
710
- // Start concurrent reads and writes
711
- const operations: Promise<unknown>[] = [];
712
-
713
- // 30 reads
714
- for (let i = 0; i < 30; i++) {
715
- operations.push(safeReadYaml<{ version: number }>(filePath));
716
- }
717
-
718
- // 10 writes with different versions
719
- for (let i = 1; i <= 10; i++) {
720
- operations.push(atomicWriteYaml(filePath, { version: i }));
721
- }
722
-
723
- await Promise.all(operations);
724
-
725
- // All reads should succeed (may see different versions)
726
- const readResults = operations.slice(0, 30);
727
- for (const op of readResults) {
728
- const result = (await op) as Awaited<ReturnType<typeof safeReadYaml<{ version: number }>>>;
729
- expect(result.success).toBe(true);
730
- if (result.success) {
731
- expect(typeof result.data?.version).toBe("number");
732
- }
733
- }
734
-
735
- // Final state should be one of the written versions
736
- const finalResult = await safeReadYaml<{ version: number }>(filePath);
737
- expect(finalResult.success).toBe(true);
738
- if (finalResult.success) {
739
- expect(finalResult.data?.version).toBeGreaterThanOrEqual(0);
740
- expect(finalResult.data?.version).toBeLessThanOrEqual(10);
741
- }
742
- });
743
- });
744
-
745
- describe("safeReadJson", () => {
746
- let tempDir: string;
747
-
748
- beforeEach(async () => {
749
- tempDir = await createTempDir();
750
- });
751
-
752
- afterEach(async () => {
753
- await rm(tempDir, { recursive: true, force: true });
754
- });
755
-
756
- it("reads and parses valid JSON file", async () => {
757
- const filePath = join(tempDir, "config.json");
758
- await writeFile(filePath, JSON.stringify({ name: "test", version: 1 }), "utf-8");
759
-
760
- const result = await safeReadJson<{ name: string; version: number }>(filePath);
761
-
762
- expect(result.success).toBe(true);
763
- if (result.success) {
764
- expect(result.data).toEqual({ name: "test", version: 1 });
765
- }
766
- });
767
-
768
- it("handles complex nested JSON structures", async () => {
769
- const filePath = join(tempDir, "complex.json");
770
- const data = {
771
- fleet: {
772
- name: "my-fleet",
773
- agents: [
774
- { name: "agent1", model: "claude-sonnet" },
775
- { name: "agent2", model: "claude-opus" },
776
- ],
777
- settings: { timeout: 30, retries: 3 },
778
- },
779
- };
780
- await writeFile(filePath, JSON.stringify(data), "utf-8");
781
-
782
- const result = await safeReadJson(filePath);
783
-
784
- expect(result.success).toBe(true);
785
- if (result.success) {
786
- expect(result.data).toEqual(data);
787
- }
788
- });
789
-
790
- it("handles empty file by returning null", async () => {
791
- const filePath = join(tempDir, "empty.json");
792
- await writeFile(filePath, "", "utf-8");
793
-
794
- const result = await safeReadJson(filePath);
795
-
796
- // Empty file returns success with null data
797
- expect(result.success).toBe(true);
798
- if (result.success) {
799
- expect(result.data).toBeNull();
800
- }
801
- });
802
-
803
- it("returns error for non-existent file", async () => {
804
- const filePath = join(tempDir, "nonexistent.json");
805
-
806
- const result = await safeReadJson(filePath);
807
-
808
- expect(result.success).toBe(false);
809
- if (!result.success) {
810
- expect(result.error).toBeInstanceOf(SafeReadError);
811
- expect(result.error.code).toBe("ENOENT");
812
- }
813
- });
814
-
815
- it("returns error for invalid JSON syntax", async () => {
816
- const filePath = join(tempDir, "invalid.json");
817
- await writeFile(filePath, "{ invalid json", "utf-8");
818
-
819
- const result = await safeReadJson(filePath);
820
-
821
- expect(result.success).toBe(false);
822
- if (!result.success) {
823
- expect(result.error).toBeInstanceOf(SafeReadError);
824
- }
825
- });
826
-
827
- it("handles JSON with unicode characters", async () => {
828
- const filePath = join(tempDir, "unicode.json");
829
- await writeFile(filePath, JSON.stringify({ greeting: "你好世界", emoji: "🚀" }), "utf-8");
830
-
831
- const result = await safeReadJson<{ greeting: string; emoji: string }>(filePath);
832
-
833
- expect(result.success).toBe(true);
834
- if (result.success) {
835
- expect(result.data).toEqual({ greeting: "你好世界", emoji: "🚀" });
836
- }
837
- });
838
-
839
- it("handles JSON with null values", async () => {
840
- const filePath = join(tempDir, "nullable.json");
841
- await writeFile(filePath, JSON.stringify({ present: "value", absent: null }), "utf-8");
842
-
843
- const result = await safeReadJson(filePath);
844
-
845
- expect(result.success).toBe(true);
846
- if (result.success) {
847
- expect(result.data).toEqual({ present: "value", absent: null });
848
- }
849
- });
850
-
851
- it("handles JSON arrays", async () => {
852
- const filePath = join(tempDir, "array.json");
853
- await writeFile(filePath, JSON.stringify([1, 2, 3, "four"]), "utf-8");
854
-
855
- const result = await safeReadJson<(number | string)[]>(filePath);
856
-
857
- expect(result.success).toBe(true);
858
- if (result.success) {
859
- expect(result.data).toEqual([1, 2, 3, "four"]);
860
- }
861
- });
862
-
863
- it("returns error for invalid JSON", async () => {
864
- const filePath = join(tempDir, "invalid-json.json");
865
- await writeFile(filePath, '{"key": invalid}', "utf-8");
866
-
867
- // Use maxRetries 0 to not test retry behavior (covered by YAML tests)
868
- const result = await safeReadJson("/fake/path.json", {
869
- readFn: async () => '{"key": invalid}',
870
- maxRetries: 0,
871
- });
872
-
873
- expect(result.success).toBe(false);
874
- if (!result.success) {
875
- expect(result.error).toBeInstanceOf(SafeReadError);
876
- }
877
- });
878
-
879
- it("does not retry on ENOENT error", async () => {
880
- let readCount = 0;
881
-
882
- const mockReadFn = async () => {
883
- readCount++;
884
- const error = new Error("File not found") as NodeJS.ErrnoException;
885
- error.code = "ENOENT";
886
- throw error;
887
- };
888
-
889
- const result = await safeReadJson("/fake/path.json", {
890
- readFn: mockReadFn,
891
- maxRetries: 5,
892
- baseDelayMs: 1,
893
- });
894
-
895
- expect(result.success).toBe(false);
896
- expect(readCount).toBe(1);
897
- });
898
-
899
- it("does not retry on EACCES error", async () => {
900
- let readCount = 0;
901
-
902
- const mockReadFn = async () => {
903
- readCount++;
904
- const error = new Error("Permission denied") as NodeJS.ErrnoException;
905
- error.code = "EACCES";
906
- throw error;
907
- };
908
-
909
- const result = await safeReadJson("/fake/path.json", {
910
- readFn: mockReadFn,
911
- maxRetries: 5,
912
- baseDelayMs: 1,
913
- });
914
-
915
- expect(result.success).toBe(false);
916
- expect(readCount).toBe(1);
917
- });
918
-
919
- it("does not retry on EPERM error", async () => {
920
- let readCount = 0;
921
-
922
- const mockReadFn = async () => {
923
- readCount++;
924
- const error = new Error("Operation not permitted") as NodeJS.ErrnoException;
925
- error.code = "EPERM";
926
- throw error;
927
- };
928
-
929
- const result = await safeReadJson("/fake/path.json", {
930
- readFn: mockReadFn,
931
- maxRetries: 5,
932
- baseDelayMs: 1,
933
- });
934
-
935
- expect(result.success).toBe(false);
936
- expect(readCount).toBe(1);
937
- });
938
-
939
- it("handles file with whitespace by returning null", async () => {
940
- const filePath = join(tempDir, "whitespace.json");
941
- await writeFile(filePath, " \n \n ", "utf-8");
942
-
943
- const result = await safeReadJson(filePath);
944
-
945
- expect(result.success).toBe(true);
946
- if (result.success) {
947
- expect(result.data).toBeNull();
948
- }
949
- });
950
- });
951
-
952
- describe("readJson (throwing variant)", () => {
953
- let tempDir: string;
954
-
955
- beforeEach(async () => {
956
- tempDir = await createTempDir();
957
- });
958
-
959
- afterEach(async () => {
960
- await rm(tempDir, { recursive: true, force: true });
961
- });
962
-
963
- it("returns parsed data on success", async () => {
964
- const filePath = join(tempDir, "config.json");
965
- await writeFile(filePath, JSON.stringify({ name: "test" }), "utf-8");
966
-
967
- const data = await readJson<{ name: string }>(filePath);
968
-
969
- expect(data).toEqual({ name: "test" });
970
- });
971
-
972
- it("throws SafeReadError on failure", async () => {
973
- const filePath = join(tempDir, "nonexistent.json");
974
-
975
- await expect(readJson(filePath)).rejects.toThrow(SafeReadError);
976
- });
977
- });
978
-
979
- describe("edge cases", () => {
980
- let tempDir: string;
981
-
982
- beforeEach(async () => {
983
- tempDir = await createTempDir();
984
- });
985
-
986
- afterEach(async () => {
987
- await rm(tempDir, { recursive: true, force: true });
988
- });
989
-
990
- it("YAML: handles file with only comments", async () => {
991
- const filePath = join(tempDir, "comments.yaml");
992
- await writeFile(filePath, "# This is a comment\n# Another comment\n", "utf-8");
993
-
994
- const result = await safeReadYaml(filePath);
995
-
996
- expect(result.success).toBe(true);
997
- if (result.success) {
998
- expect(result.data).toBeNull();
999
- }
1000
- });
1001
-
1002
- it("YAML: returns error for multi-document YAML", async () => {
1003
- const filePath = join(tempDir, "multi.yaml");
1004
- await writeFile(
1005
- filePath,
1006
- "name: first\n---\nname: second\n",
1007
- "utf-8"
1008
- );
1009
-
1010
- const result = await safeReadYaml<{ name: string }>(filePath);
1011
-
1012
- // yaml library throws on multi-document YAML by default
1013
- expect(result.success).toBe(false);
1014
- if (!result.success) {
1015
- expect(result.error.message).toContain("multiple documents");
1016
- }
1017
- });
1018
-
1019
- it("JSONL: handles single line file without trailing newline", async () => {
1020
- const filePath = join(tempDir, "single.jsonl");
1021
- await writeFile(filePath, '{"id":1}', "utf-8");
1022
-
1023
- const result = await safeReadJsonl<{ id: number }>(filePath);
1024
-
1025
- expect(result.success).toBe(true);
1026
- if (result.success) {
1027
- expect(result.data).toEqual([{ id: 1 }]);
1028
- }
1029
- });
1030
-
1031
- it("JSONL: handles multiple empty lines between entries", async () => {
1032
- const filePath = join(tempDir, "sparse.jsonl");
1033
- await writeFile(filePath, '{"id":1}\n\n\n{"id":2}\n\n', "utf-8");
1034
-
1035
- const result = await safeReadJsonl<{ id: number }>(filePath);
1036
-
1037
- expect(result.success).toBe(true);
1038
- if (result.success) {
1039
- expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
1040
- }
1041
- });
1042
-
1043
- it("JSONL: handles lines with only whitespace", async () => {
1044
- const filePath = join(tempDir, "whitespace-lines.jsonl");
1045
- await writeFile(filePath, '{"id":1}\n \n{"id":2}\n', "utf-8");
1046
-
1047
- const result = await safeReadJsonl<{ id: number }>(filePath);
1048
-
1049
- expect(result.success).toBe(true);
1050
- if (result.success) {
1051
- expect(result.data).toEqual([{ id: 1 }, { id: 2 }]);
1052
- }
1053
- });
1054
-
1055
- it("JSONL: handles very long JSON lines", async () => {
1056
- const filePath = join(tempDir, "long-line.jsonl");
1057
- const longString = "x".repeat(100000);
1058
- await writeFile(
1059
- filePath,
1060
- `${JSON.stringify({ data: longString })}\n`,
1061
- "utf-8"
1062
- );
1063
-
1064
- const result = await safeReadJsonl<{ data: string }>(filePath);
1065
-
1066
- expect(result.success).toBe(true);
1067
- if (result.success) {
1068
- expect(result.data[0].data).toBe(longString);
1069
- }
1070
- });
1071
- });