@herdctl/core 0.0.1 → 0.0.2
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/dist/config/__tests__/agent.test.js +31 -13
- package/dist/config/__tests__/agent.test.js.map +1 -1
- package/dist/config/__tests__/merge.test.js +9 -2
- package/dist/config/__tests__/merge.test.js.map +1 -1
- package/dist/config/__tests__/schema.test.js +350 -1
- package/dist/config/__tests__/schema.test.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +3 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/schema.d.ts +828 -24
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +118 -6
- package/dist/config/schema.js.map +1 -1
- package/dist/fleet-manager/__tests__/coverage.test.js +11 -332
- package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -1
- package/dist/fleet-manager/__tests__/errors.test.js +1 -49
- package/dist/fleet-manager/__tests__/errors.test.js.map +1 -1
- package/dist/fleet-manager/__tests__/integration.test.js +109 -0
- package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
- package/dist/fleet-manager/__tests__/reload.test.js +1 -1
- package/dist/fleet-manager/__tests__/reload.test.js.map +1 -1
- package/dist/fleet-manager/config-reload.d.ts +164 -0
- package/dist/fleet-manager/config-reload.d.ts.map +1 -0
- package/dist/fleet-manager/config-reload.js +445 -0
- package/dist/fleet-manager/config-reload.js.map +1 -0
- package/dist/fleet-manager/context.d.ts +76 -0
- package/dist/fleet-manager/context.d.ts.map +1 -0
- package/dist/fleet-manager/context.js +11 -0
- package/dist/fleet-manager/context.js.map +1 -0
- package/dist/fleet-manager/errors.d.ts +0 -25
- package/dist/fleet-manager/errors.d.ts.map +1 -1
- package/dist/fleet-manager/errors.js +0 -38
- package/dist/fleet-manager/errors.js.map +1 -1
- package/dist/fleet-manager/event-emitters.d.ts +123 -0
- package/dist/fleet-manager/event-emitters.d.ts.map +1 -0
- package/dist/fleet-manager/event-emitters.js +136 -0
- package/dist/fleet-manager/event-emitters.js.map +1 -0
- package/dist/fleet-manager/event-types.d.ts +0 -15
- package/dist/fleet-manager/event-types.d.ts.map +1 -1
- package/dist/fleet-manager/fleet-manager.d.ts +40 -653
- package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
- package/dist/fleet-manager/fleet-manager.js +95 -1720
- package/dist/fleet-manager/fleet-manager.js.map +1 -1
- package/dist/fleet-manager/index.d.ts +13 -2
- package/dist/fleet-manager/index.d.ts.map +1 -1
- package/dist/fleet-manager/index.js +19 -6
- package/dist/fleet-manager/index.js.map +1 -1
- package/dist/fleet-manager/job-control.d.ts +64 -0
- package/dist/fleet-manager/job-control.d.ts.map +1 -0
- package/dist/fleet-manager/job-control.js +296 -0
- package/dist/fleet-manager/job-control.js.map +1 -0
- package/dist/fleet-manager/log-streaming.d.ts +171 -0
- package/dist/fleet-manager/log-streaming.d.ts.map +1 -0
- package/dist/fleet-manager/log-streaming.js +503 -0
- package/dist/fleet-manager/log-streaming.js.map +1 -0
- package/dist/fleet-manager/schedule-executor.d.ts +63 -0
- package/dist/fleet-manager/schedule-executor.d.ts.map +1 -0
- package/dist/fleet-manager/schedule-executor.js +209 -0
- package/dist/fleet-manager/schedule-executor.js.map +1 -0
- package/dist/fleet-manager/schedule-management.d.ts +71 -0
- package/dist/fleet-manager/schedule-management.d.ts.map +1 -0
- package/dist/fleet-manager/schedule-management.js +171 -0
- package/dist/fleet-manager/schedule-management.js.map +1 -0
- package/dist/fleet-manager/status-queries.d.ts +105 -0
- package/dist/fleet-manager/status-queries.d.ts.map +1 -0
- package/dist/fleet-manager/status-queries.js +247 -0
- package/dist/fleet-manager/status-queries.js.map +1 -0
- package/dist/fleet-manager/types.d.ts +0 -39
- package/dist/fleet-manager/types.d.ts.map +1 -1
- package/dist/runner/__tests__/job-executor.test.js +206 -1
- package/dist/runner/__tests__/job-executor.test.js.map +1 -1
- package/dist/runner/job-executor.d.ts +9 -0
- package/dist/runner/job-executor.d.ts.map +1 -1
- package/dist/runner/job-executor.js +78 -4
- package/dist/runner/job-executor.js.map +1 -1
- package/dist/runner/types.d.ts +2 -0
- package/dist/runner/types.d.ts.map +1 -1
- package/dist/scheduler/__tests__/cron.test.d.ts +2 -0
- package/dist/scheduler/__tests__/cron.test.d.ts.map +1 -0
- package/dist/scheduler/__tests__/cron.test.js +867 -0
- package/dist/scheduler/__tests__/cron.test.js.map +1 -0
- package/dist/scheduler/__tests__/scheduler.test.js +164 -5
- package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
- package/dist/scheduler/cron.d.ts +126 -0
- package/dist/scheduler/cron.d.ts.map +1 -0
- package/dist/scheduler/cron.js +390 -0
- package/dist/scheduler/cron.js.map +1 -0
- package/dist/scheduler/errors.d.ts +81 -1
- package/dist/scheduler/errors.d.ts.map +1 -1
- package/dist/scheduler/errors.js +81 -6
- package/dist/scheduler/errors.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -0
- package/dist/scheduler/index.d.ts.map +1 -1
- package/dist/scheduler/index.js +2 -0
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/schedule-runner.d.ts +2 -2
- package/dist/scheduler/schedule-runner.d.ts.map +1 -1
- package/dist/scheduler/schedule-runner.js +20 -8
- package/dist/scheduler/schedule-runner.js.map +1 -1
- package/dist/scheduler/scheduler.d.ts +4 -4
- package/dist/scheduler/scheduler.d.ts.map +1 -1
- package/dist/scheduler/scheduler.js +86 -20
- package/dist/scheduler/scheduler.js.map +1 -1
- package/dist/scheduler/types.d.ts +1 -1
- package/dist/scheduler/types.d.ts.map +1 -1
- package/dist/state/schemas/job-metadata.d.ts +2 -2
- package/package.json +33 -8
- package/.turbo/turbo-build.log +0 -4
- package/.turbo/turbo-test.log +0 -219
- package/.turbo/turbo-typecheck.log +0 -4
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/coverage-final.json +0 -51
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -251
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/config/index.html +0 -191
- package/coverage/src/config/index.ts.html +0 -442
- package/coverage/src/config/interpolate.ts.html +0 -652
- package/coverage/src/config/loader.ts.html +0 -1501
- package/coverage/src/config/merge.ts.html +0 -823
- package/coverage/src/config/parser.ts.html +0 -1213
- package/coverage/src/config/schema.ts.html +0 -1123
- package/coverage/src/fleet-manager/errors.ts.html +0 -2326
- package/coverage/src/fleet-manager/event-types.ts.html +0 -1219
- package/coverage/src/fleet-manager/fleet-manager.ts.html +0 -7030
- package/coverage/src/fleet-manager/index.html +0 -206
- package/coverage/src/fleet-manager/index.ts.html +0 -469
- package/coverage/src/fleet-manager/job-manager.ts.html +0 -2074
- package/coverage/src/fleet-manager/job-queue.ts.html +0 -2479
- package/coverage/src/fleet-manager/types.ts.html +0 -2602
- package/coverage/src/index.html +0 -116
- package/coverage/src/index.ts.html +0 -181
- package/coverage/src/runner/errors.ts.html +0 -1006
- package/coverage/src/runner/index.html +0 -191
- package/coverage/src/runner/index.ts.html +0 -256
- package/coverage/src/runner/job-executor.ts.html +0 -1429
- package/coverage/src/runner/message-processor.ts.html +0 -1150
- package/coverage/src/runner/sdk-adapter.ts.html +0 -658
- package/coverage/src/runner/types.ts.html +0 -559
- package/coverage/src/scheduler/errors.ts.html +0 -388
- package/coverage/src/scheduler/index.html +0 -206
- package/coverage/src/scheduler/index.ts.html +0 -244
- package/coverage/src/scheduler/interval.ts.html +0 -652
- package/coverage/src/scheduler/schedule-runner.ts.html +0 -1411
- package/coverage/src/scheduler/schedule-state.ts.html +0 -718
- package/coverage/src/scheduler/scheduler.ts.html +0 -1795
- package/coverage/src/scheduler/types.ts.html +0 -733
- package/coverage/src/state/directory.ts.html +0 -736
- package/coverage/src/state/errors.ts.html +0 -376
- package/coverage/src/state/fleet-state.ts.html +0 -937
- package/coverage/src/state/index.html +0 -221
- package/coverage/src/state/index.ts.html +0 -322
- package/coverage/src/state/job-metadata.ts.html +0 -1420
- package/coverage/src/state/job-output.ts.html +0 -1033
- package/coverage/src/state/schemas/fleet-state.ts.html +0 -445
- package/coverage/src/state/schemas/index.html +0 -176
- package/coverage/src/state/schemas/index.ts.html +0 -286
- package/coverage/src/state/schemas/job-metadata.ts.html +0 -628
- package/coverage/src/state/schemas/job-output.ts.html +0 -616
- package/coverage/src/state/schemas/session-info.ts.html +0 -361
- package/coverage/src/state/session.ts.html +0 -844
- package/coverage/src/state/types.ts.html +0 -262
- package/coverage/src/state/utils/atomic.ts.html +0 -748
- package/coverage/src/state/utils/index.html +0 -146
- package/coverage/src/state/utils/index.ts.html +0 -103
- package/coverage/src/state/utils/reads.ts.html +0 -1621
- package/coverage/src/work-sources/adapters/github.ts.html +0 -3583
- package/coverage/src/work-sources/adapters/index.html +0 -131
- package/coverage/src/work-sources/adapters/index.ts.html +0 -277
- package/coverage/src/work-sources/errors.ts.html +0 -298
- package/coverage/src/work-sources/index.html +0 -176
- package/coverage/src/work-sources/index.ts.html +0 -529
- package/coverage/src/work-sources/manager.ts.html +0 -1324
- package/coverage/src/work-sources/registry.ts.html +0 -619
- package/coverage/src/work-sources/types.ts.html +0 -568
- package/dist/fleet-manager/__tests__/event-helpers.test.d.ts +0 -7
- package/dist/fleet-manager/__tests__/event-helpers.test.d.ts.map +0 -1
- package/dist/fleet-manager/__tests__/event-helpers.test.js +0 -368
- package/dist/fleet-manager/__tests__/event-helpers.test.js.map +0 -1
- package/src/config/__tests__/agent.test.ts +0 -864
- package/src/config/__tests__/interpolate.test.ts +0 -644
- package/src/config/__tests__/loader.test.ts +0 -784
- package/src/config/__tests__/merge.test.ts +0 -751
- package/src/config/__tests__/parser.test.ts +0 -533
- package/src/config/__tests__/schema.test.ts +0 -873
- package/src/config/index.ts +0 -119
- package/src/config/interpolate.ts +0 -189
- package/src/config/loader.ts +0 -472
- package/src/config/merge.ts +0 -246
- package/src/config/parser.ts +0 -376
- package/src/config/schema.ts +0 -346
- package/src/fleet-manager/__tests__/coverage.test.ts +0 -2869
- package/src/fleet-manager/__tests__/errors.test.ts +0 -660
- package/src/fleet-manager/__tests__/event-helpers.test.ts +0 -448
- package/src/fleet-manager/__tests__/integration.test.ts +0 -1209
- package/src/fleet-manager/__tests__/job-control.test.ts +0 -283
- package/src/fleet-manager/__tests__/job-manager.test.ts +0 -869
- package/src/fleet-manager/__tests__/job-queue.test.ts +0 -401
- package/src/fleet-manager/__tests__/reload.test.ts +0 -751
- package/src/fleet-manager/__tests__/status-queries.test.ts +0 -595
- package/src/fleet-manager/__tests__/trigger.test.ts +0 -601
- package/src/fleet-manager/errors.ts +0 -747
- package/src/fleet-manager/event-types.ts +0 -378
- package/src/fleet-manager/fleet-manager.ts +0 -2315
- package/src/fleet-manager/index.ts +0 -128
- package/src/fleet-manager/job-manager.ts +0 -663
- package/src/fleet-manager/job-queue.ts +0 -798
- package/src/fleet-manager/types.ts +0 -839
- package/src/index.ts +0 -32
- package/src/runner/__tests__/errors.test.ts +0 -382
- package/src/runner/__tests__/job-executor.test.ts +0 -1708
- package/src/runner/__tests__/message-processor.test.ts +0 -960
- package/src/runner/__tests__/sdk-adapter.test.ts +0 -626
- package/src/runner/errors.ts +0 -307
- package/src/runner/index.ts +0 -57
- package/src/runner/job-executor.ts +0 -448
- package/src/runner/message-processor.ts +0 -355
- package/src/runner/sdk-adapter.ts +0 -191
- package/src/runner/types.ts +0 -158
- package/src/scheduler/__tests__/errors.test.ts +0 -159
- package/src/scheduler/__tests__/interval.test.ts +0 -515
- package/src/scheduler/__tests__/schedule-runner.test.ts +0 -798
- package/src/scheduler/__tests__/schedule-state.test.ts +0 -671
- package/src/scheduler/__tests__/scheduler.test.ts +0 -1280
- package/src/scheduler/errors.ts +0 -101
- package/src/scheduler/index.ts +0 -53
- package/src/scheduler/interval.ts +0 -189
- package/src/scheduler/schedule-runner.ts +0 -442
- package/src/scheduler/schedule-state.ts +0 -211
- package/src/scheduler/scheduler.ts +0 -570
- package/src/scheduler/types.ts +0 -216
- package/src/state/__tests__/directory.test.ts +0 -595
- package/src/state/__tests__/fleet-state.test.ts +0 -868
- package/src/state/__tests__/job-metadata-schema.test.ts +0 -414
- package/src/state/__tests__/job-metadata.test.ts +0 -831
- package/src/state/__tests__/job-output.test.ts +0 -856
- package/src/state/__tests__/session-schema.test.ts +0 -378
- package/src/state/__tests__/session.test.ts +0 -604
- package/src/state/directory.ts +0 -217
- package/src/state/errors.ts +0 -97
- package/src/state/fleet-state.ts +0 -284
- package/src/state/index.ts +0 -79
- package/src/state/job-metadata.ts +0 -445
- package/src/state/job-output.ts +0 -316
- package/src/state/schemas/__tests__/job-output.test.ts +0 -338
- package/src/state/schemas/fleet-state.ts +0 -120
- package/src/state/schemas/index.ts +0 -67
- package/src/state/schemas/job-metadata.ts +0 -181
- package/src/state/schemas/job-output.ts +0 -177
- package/src/state/schemas/session-info.ts +0 -92
- package/src/state/session.ts +0 -253
- package/src/state/types.ts +0 -59
- package/src/state/utils/__tests__/atomic.test.ts +0 -723
- package/src/state/utils/__tests__/reads.test.ts +0 -1071
- package/src/state/utils/atomic.ts +0 -221
- package/src/state/utils/index.ts +0 -6
- package/src/state/utils/reads.ts +0 -512
- package/src/work-sources/__tests__/github.test.ts +0 -1800
- package/src/work-sources/__tests__/manager.test.ts +0 -529
- package/src/work-sources/__tests__/registry.test.ts +0 -477
- package/src/work-sources/__tests__/types.test.ts +0 -479
- package/src/work-sources/adapters/github.ts +0 -1166
- package/src/work-sources/adapters/index.ts +0 -64
- package/src/work-sources/errors.ts +0 -71
- package/src/work-sources/index.ts +0 -148
- package/src/work-sources/manager.ts +0 -413
- package/src/work-sources/registry.ts +0 -178
- package/src/work-sources/types.ts +0 -161
- package/tsconfig.json +0 -9
- 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
|
-
});
|