@towles/tool 0.0.66 → 0.0.68

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.
@@ -9,29 +9,44 @@ import { initConfig } from "../config";
9
9
  import { ARTIFACTS } from "../prompt-templates/index";
10
10
  import {
11
11
  buildTestContext,
12
- createSpawnClaudeMock,
12
+ createMockClaudeProcess,
13
13
  createTestRepoWithRemote,
14
14
  errorClaudeJson,
15
15
  successClaudeJson,
16
16
  } from "../test-helpers";
17
17
  import type { MockClaudeImpl, TestRepo } from "../test-helpers";
18
18
  import type { IssueContext } from "../utils";
19
+ import type { SpawnClaudeFn } from "../spawn-claude";
19
20
 
20
21
  consola.level = -999;
21
22
 
22
- let mockClaudeImpl: MockClaudeImpl = null;
23
- vi.mock("../spawn-claude", () => createSpawnClaudeMock(() => mockClaudeImpl));
24
-
25
23
  // ── Shared setup/teardown for all step tests ──
26
24
 
27
- function setupStepTest(): { originalCwd: string; repo: TestRepo; ctx: IssueContext } {
25
+ function setupStepTest(): {
26
+ originalCwd: string;
27
+ repo: TestRepo;
28
+ ctx: IssueContext;
29
+ spawnFn: SpawnClaudeFn;
30
+ mockClaudeImpl: { current: MockClaudeImpl };
31
+ } {
28
32
  const originalCwd = process.cwd();
29
33
  const repo = createTestRepoWithRemote();
30
34
  process.chdir(repo.dir);
31
35
  const ctx = buildTestContext(repo.dir);
32
36
  mkdirSync(ctx.issueDir, { recursive: true });
33
- mockClaudeImpl = null;
34
- return { originalCwd, repo, ctx };
37
+
38
+ const mockClaudeImpl = { current: null as MockClaudeImpl };
39
+
40
+ const spawnFn = vi.fn((args: string[]) => {
41
+ const impl = mockClaudeImpl.current;
42
+ if (impl) {
43
+ const { stdout, exitCode } = impl(args);
44
+ return createMockClaudeProcess(stdout, exitCode);
45
+ }
46
+ throw new Error("Unexpected spawnClaude call -- set mockClaudeImpl.current");
47
+ }) as SpawnClaudeFn;
48
+
49
+ return { originalCwd, repo, ctx, spawnFn, mockClaudeImpl };
35
50
  }
36
51
 
37
52
  function teardownStepTest(originalCwd: string, repo: TestRepo): void {
@@ -45,9 +60,11 @@ describe("runStepWithArtifact", () => {
45
60
  let originalCwd: string;
46
61
  let repo: TestRepo;
47
62
  let ctx: IssueContext;
63
+ let spawnFn: SpawnClaudeFn;
64
+ let mockClaudeImpl: { current: MockClaudeImpl };
48
65
 
49
66
  beforeEach(async () => {
50
- ({ originalCwd, repo, ctx } = setupStepTest());
67
+ ({ originalCwd, repo, ctx, spawnFn, mockClaudeImpl } = setupStepTest());
51
68
  await initConfig({ repo: "test/repo", mainBranch: "main" });
52
69
  });
53
70
 
@@ -63,13 +80,14 @@ describe("runStepWithArtifact", () => {
63
80
  ctx,
64
81
  artifactPath,
65
82
  templateName: "01_plan.prompt.md",
83
+ spawnFn,
66
84
  });
67
85
 
68
86
  expect(result).toBe(true);
69
87
  });
70
88
 
71
89
  it("returns false when Claude returns is_error", async () => {
72
- mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
90
+ mockClaudeImpl.current = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
73
91
 
74
92
  const { runStepWithArtifact } = await import("../utils");
75
93
  const artifactPath = join(ctx.issueDir, "missing-artifact.md");
@@ -79,13 +97,14 @@ describe("runStepWithArtifact", () => {
79
97
  ctx,
80
98
  artifactPath,
81
99
  templateName: "01_plan.prompt.md",
100
+ spawnFn,
82
101
  });
83
102
 
84
103
  expect(result).toBe(false);
85
104
  });
86
105
 
87
106
  it("returns false when artifact not produced after Claude run", async () => {
88
- mockClaudeImpl = () => ({ stdout: successClaudeJson(), exitCode: 0 });
107
+ mockClaudeImpl.current = () => ({ stdout: successClaudeJson(), exitCode: 0 });
89
108
 
90
109
  const { runStepWithArtifact } = await import("../utils");
91
110
  const artifactPath = join(ctx.issueDir, "never-created.md");
@@ -95,6 +114,7 @@ describe("runStepWithArtifact", () => {
95
114
  ctx,
96
115
  artifactPath,
97
116
  templateName: "01_plan.prompt.md",
117
+ spawnFn,
98
118
  });
99
119
 
100
120
  expect(result).toBe(false);
@@ -104,7 +124,7 @@ describe("runStepWithArtifact", () => {
104
124
  const { runStepWithArtifact } = await import("../utils");
105
125
  const artifactPath = join(ctx.issueDir, "plan.md");
106
126
 
107
- mockClaudeImpl = () => {
127
+ mockClaudeImpl.current = () => {
108
128
  writeFileSync(artifactPath, "# Plan\n\nDetailed plan content here.");
109
129
  return { stdout: successClaudeJson(), exitCode: 0 };
110
130
  };
@@ -114,6 +134,7 @@ describe("runStepWithArtifact", () => {
114
134
  ctx,
115
135
  artifactPath,
116
136
  templateName: "01_plan.prompt.md",
137
+ spawnFn,
117
138
  });
118
139
 
119
140
  expect(result).toBe(true);
@@ -126,9 +147,11 @@ describe("stepPlan", () => {
126
147
  let originalCwd: string;
127
148
  let repo: TestRepo;
128
149
  let ctx: IssueContext;
150
+ let spawnFn: SpawnClaudeFn;
151
+ let mockClaudeImpl: { current: MockClaudeImpl };
129
152
 
130
153
  beforeEach(async () => {
131
- ({ originalCwd, repo, ctx } = setupStepTest());
154
+ ({ originalCwd, repo, ctx, spawnFn, mockClaudeImpl } = setupStepTest());
132
155
  await initConfig({ repo: "test/repo", mainBranch: "main" });
133
156
  });
134
157
 
@@ -139,7 +162,7 @@ describe("stepPlan", () => {
139
162
 
140
163
  writeFileSync(join(ctx.issueDir, ARTIFACTS.plan), "# Existing plan");
141
164
 
142
- const result = await stepPlan(ctx);
165
+ const result = await stepPlan(ctx, spawnFn);
143
166
  expect(result).toBe(true);
144
167
  });
145
168
 
@@ -147,12 +170,12 @@ describe("stepPlan", () => {
147
170
  const { stepPlan } = await import("./simple-steps");
148
171
  const planPath = join(ctx.issueDir, ARTIFACTS.plan);
149
172
 
150
- mockClaudeImpl = () => {
173
+ mockClaudeImpl.current = () => {
151
174
  writeFileSync(planPath, "# Plan\n\nDetailed plan.");
152
175
  return { stdout: successClaudeJson(), exitCode: 0 };
153
176
  };
154
177
 
155
- const result = await stepPlan(ctx);
178
+ const result = await stepPlan(ctx, spawnFn);
156
179
  expect(result).toBe(true);
157
180
 
158
181
  // Verify we ended up on the branch (ensureBranch was called)
@@ -165,9 +188,9 @@ describe("stepPlan", () => {
165
188
  it("returns false when Claude fails", async () => {
166
189
  const { stepPlan } = await import("./simple-steps");
167
190
 
168
- mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
191
+ mockClaudeImpl.current = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
169
192
 
170
- const result = await stepPlan(ctx);
193
+ const result = await stepPlan(ctx, spawnFn);
171
194
  expect(result).toBe(false);
172
195
  });
173
196
  });
@@ -178,9 +201,11 @@ describe("stepSimplify", () => {
178
201
  let originalCwd: string;
179
202
  let repo: TestRepo;
180
203
  let ctx: IssueContext;
204
+ let spawnFn: SpawnClaudeFn;
205
+ let mockClaudeImpl: { current: MockClaudeImpl };
181
206
 
182
207
  beforeEach(async () => {
183
- ({ originalCwd, repo, ctx } = setupStepTest());
208
+ ({ originalCwd, repo, ctx, spawnFn, mockClaudeImpl } = setupStepTest());
184
209
  await initConfig({ repo: "test/repo", mainBranch: "main" });
185
210
 
186
211
  // stepSimplify doesn't switch branches, but we need to be on one
@@ -194,7 +219,7 @@ describe("stepSimplify", () => {
194
219
 
195
220
  writeFileSync(join(ctx.issueDir, ARTIFACTS.simplifySummary), "# Simplified");
196
221
 
197
- const result = await stepSimplify(ctx);
222
+ const result = await stepSimplify(ctx, spawnFn);
198
223
  expect(result).toBe(true);
199
224
  });
200
225
 
@@ -202,21 +227,21 @@ describe("stepSimplify", () => {
202
227
  const { stepSimplify } = await import("./simple-steps");
203
228
  const artifactPath = join(ctx.issueDir, ARTIFACTS.simplifySummary);
204
229
 
205
- mockClaudeImpl = () => {
230
+ mockClaudeImpl.current = () => {
206
231
  writeFileSync(artifactPath, "# Simplify Summary\n\nCode simplified.");
207
232
  return { stdout: successClaudeJson(), exitCode: 0 };
208
233
  };
209
234
 
210
- const result = await stepSimplify(ctx);
235
+ const result = await stepSimplify(ctx, spawnFn);
211
236
  expect(result).toBe(true);
212
237
  });
213
238
 
214
239
  it("returns false when Claude fails", async () => {
215
240
  const { stepSimplify } = await import("./simple-steps");
216
241
 
217
- mockClaudeImpl = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
242
+ mockClaudeImpl.current = () => ({ stdout: errorClaudeJson(), exitCode: 0 });
218
243
 
219
- const result = await stepSimplify(ctx);
244
+ const result = await stepSimplify(ctx, spawnFn);
220
245
  expect(result).toBe(false);
221
246
  });
222
247
  });
@@ -227,9 +252,11 @@ describe("stepImplement", () => {
227
252
  let originalCwd: string;
228
253
  let repo: TestRepo;
229
254
  let ctx: IssueContext;
255
+ let spawnFn: SpawnClaudeFn;
256
+ let mockClaudeImpl: { current: MockClaudeImpl };
230
257
 
231
258
  beforeEach(async () => {
232
- ({ originalCwd, repo, ctx } = setupStepTest());
259
+ ({ originalCwd, repo, ctx, spawnFn, mockClaudeImpl } = setupStepTest());
233
260
  await initConfig({
234
261
  repo: "test/repo",
235
262
  mainBranch: "main",
@@ -247,7 +274,7 @@ describe("stepImplement", () => {
247
274
 
248
275
  writeFileSync(join(ctx.issueDir, ARTIFACTS.completedSummary), "# Done");
249
276
 
250
- const result = await stepImplement(ctx);
277
+ const result = await stepImplement(ctx, spawnFn);
251
278
  expect(result).toBe(true);
252
279
  });
253
280
 
@@ -255,12 +282,12 @@ describe("stepImplement", () => {
255
282
  const { stepImplement } = await import("./implement");
256
283
 
257
284
  let callCount = 0;
258
- mockClaudeImpl = () => {
285
+ mockClaudeImpl.current = () => {
259
286
  callCount++;
260
287
  return { stdout: successClaudeJson(), exitCode: 0 };
261
288
  };
262
289
 
263
- const result = await stepImplement(ctx);
290
+ const result = await stepImplement(ctx, spawnFn);
264
291
  expect(result).toBe(false);
265
292
  expect(callCount).toBe(3);
266
293
  });
@@ -272,12 +299,12 @@ describe("stepImplement", () => {
272
299
  writeFileSync(join(ctx.issueDir, ARTIFACTS.review), "# Review\n\nFix the tests.");
273
300
 
274
301
  const completedPath = join(ctx.issueDir, ARTIFACTS.completedSummary);
275
- mockClaudeImpl = () => {
302
+ mockClaudeImpl.current = () => {
276
303
  writeFileSync(completedPath, "# Done");
277
304
  return { stdout: successClaudeJson(), exitCode: 0 };
278
305
  };
279
306
 
280
- const result = await stepImplement(ctx);
307
+ const result = await stepImplement(ctx, spawnFn);
281
308
  expect(result).toBe(true);
282
309
  });
283
310
 
@@ -287,7 +314,7 @@ describe("stepImplement", () => {
287
314
  let callCount = 0;
288
315
  const completedPath = join(ctx.issueDir, ARTIFACTS.completedSummary);
289
316
 
290
- mockClaudeImpl = () => {
317
+ mockClaudeImpl.current = () => {
291
318
  callCount++;
292
319
  if (callCount === 2) {
293
320
  writeFileSync(completedPath, "# Implementation Complete\n\nAll tasks done.");
@@ -295,7 +322,7 @@ describe("stepImplement", () => {
295
322
  return { stdout: successClaudeJson(), exitCode: 0 };
296
323
  };
297
324
 
298
- const result = await stepImplement(ctx);
325
+ const result = await stepImplement(ctx, spawnFn);
299
326
  expect(result).toBe(true);
300
327
  expect(callCount).toBe(2);
301
328
  });
@@ -1,22 +1,21 @@
1
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
2
1
  import { describe, expect, it, vi, beforeEach } from "vitest";
3
2
 
4
3
  import { resolveTemplate } from "./templates";
5
- import type { TokenValues } from "./templates";
4
+ import type { TemplateFsDeps, TokenValues } from "./templates";
6
5
 
7
- vi.mock("node:fs", () => ({
8
- readFileSync: vi.fn(),
9
- writeFileSync: vi.fn(),
10
- mkdirSync: vi.fn(),
11
- }));
12
-
13
- const mockedReadFileSync = vi.mocked(readFileSync);
14
- const mockedWriteFileSync = vi.mocked(writeFileSync);
15
- const mockedMkdirSync = vi.mocked(mkdirSync);
6
+ function createMockFs(): TemplateFsDeps {
7
+ return {
8
+ readFileSync: vi.fn().mockReturnValue(""),
9
+ writeFileSync: vi.fn(),
10
+ mkdirSync: vi.fn(),
11
+ };
12
+ }
16
13
 
17
14
  describe("resolveTemplate", () => {
15
+ let mockFs: TemplateFsDeps;
16
+
18
17
  beforeEach(() => {
19
- vi.clearAllMocks();
18
+ mockFs = createMockFs();
20
19
  });
21
20
 
22
21
  const tokens: TokenValues = {
@@ -26,13 +25,13 @@ describe("resolveTemplate", () => {
26
25
  };
27
26
 
28
27
  it("replaces all token placeholders in template", () => {
29
- mockedReadFileSync.mockReturnValue(
28
+ vi.mocked(mockFs.readFileSync).mockReturnValue(
30
29
  "Scope: {{SCOPE_PATH}}\nDir: {{ISSUE_DIR}}\nBranch: {{MAIN_BRANCH}}",
31
30
  );
32
31
 
33
- resolveTemplate("plan.md", tokens, "/tmp/issues/42");
32
+ resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
34
33
 
35
- const writtenContent = mockedWriteFileSync.mock.calls[0][1];
34
+ const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
36
35
  expect(writtenContent).toContain("/home/user/project");
37
36
  expect(writtenContent).toContain("/tmp/issues/42");
38
37
  expect(writtenContent).toContain("main");
@@ -44,28 +43,28 @@ describe("resolveTemplate", () => {
44
43
  ...tokens,
45
44
  REVIEW_FEEDBACK: "Needs more tests",
46
45
  };
47
- mockedReadFileSync.mockReturnValue("Feedback: {{REVIEW_FEEDBACK}}");
46
+ vi.mocked(mockFs.readFileSync).mockReturnValue("Feedback: {{REVIEW_FEEDBACK}}");
48
47
 
49
- resolveTemplate("review.md", tokensWithFeedback, "/tmp/issues/42");
48
+ resolveTemplate("review.md", tokensWithFeedback, "/tmp/issues/42", mockFs);
50
49
 
51
- const writtenContent = mockedWriteFileSync.mock.calls[0][1];
50
+ const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
52
51
  expect(writtenContent).toContain("Needs more tests");
53
52
  });
54
53
 
55
54
  it("creates output directory recursively", () => {
56
- mockedReadFileSync.mockReturnValue("template content");
55
+ vi.mocked(mockFs.readFileSync).mockReturnValue("template content");
57
56
 
58
- resolveTemplate("plan.md", tokens, "/tmp/issues/42");
57
+ resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
59
58
 
60
- expect(mockedMkdirSync).toHaveBeenCalledWith("/tmp/issues/42", { recursive: true });
59
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith("/tmp/issues/42", { recursive: true });
61
60
  });
62
61
 
63
62
  it("writes resolved template to issue dir", () => {
64
- mockedReadFileSync.mockReturnValue("simple content");
63
+ vi.mocked(mockFs.readFileSync).mockReturnValue("simple content");
65
64
 
66
- resolveTemplate("plan.md", tokens, "/tmp/issues/42");
65
+ resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
67
66
 
68
- expect(mockedWriteFileSync).toHaveBeenCalledWith(
67
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(
69
68
  "/tmp/issues/42/plan.md",
70
69
  "simple content",
71
70
  "utf-8",
@@ -73,19 +72,19 @@ describe("resolveTemplate", () => {
73
72
  });
74
73
 
75
74
  it("returns relative path from cwd", () => {
76
- mockedReadFileSync.mockReturnValue("content");
75
+ vi.mocked(mockFs.readFileSync).mockReturnValue("content");
77
76
 
78
- const result = resolveTemplate("plan.md", tokens, "/tmp/issues/42");
77
+ const result = resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
79
78
  // Should be a relative path (not starting with /)
80
79
  expect(result).not.toMatch(/^\/tmp/);
81
80
  });
82
81
 
83
82
  it("handles multiple occurrences of the same token", () => {
84
- mockedReadFileSync.mockReturnValue("{{MAIN_BRANCH}} and {{MAIN_BRANCH}} again");
83
+ vi.mocked(mockFs.readFileSync).mockReturnValue("{{MAIN_BRANCH}} and {{MAIN_BRANCH}} again");
85
84
 
86
- resolveTemplate("plan.md", tokens, "/tmp/issues/42");
85
+ resolveTemplate("plan.md", tokens, "/tmp/issues/42", mockFs);
87
86
 
88
- const writtenContent = mockedWriteFileSync.mock.calls[0][1];
87
+ const writtenContent = vi.mocked(mockFs.writeFileSync).mock.calls[0][1];
89
88
  expect(writtenContent).toBe("main and main again");
90
89
  });
91
90
  });
@@ -1,4 +1,8 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import {
2
+ mkdirSync as defaultMkdirSync,
3
+ readFileSync as defaultReadFileSync,
4
+ writeFileSync as defaultWriteFileSync,
5
+ } from "node:fs";
2
6
  import { dirname, join, relative } from "node:path";
3
7
  import { fileURLToPath } from "node:url";
4
8
 
@@ -14,21 +18,34 @@ export interface TokenValues {
14
18
  REVIEW_FEEDBACK?: string;
15
19
  }
16
20
 
21
+ export interface TemplateFsDeps {
22
+ readFileSync: typeof defaultReadFileSync;
23
+ writeFileSync: typeof defaultWriteFileSync;
24
+ mkdirSync: typeof defaultMkdirSync;
25
+ }
26
+
27
+ const defaultFsDeps: TemplateFsDeps = {
28
+ readFileSync: defaultReadFileSync,
29
+ writeFileSync: defaultWriteFileSync,
30
+ mkdirSync: defaultMkdirSync,
31
+ };
32
+
17
33
  export function resolveTemplate(
18
34
  templateName: string,
19
35
  tokens: TokenValues,
20
36
  issueDir: string,
37
+ fs: TemplateFsDeps = defaultFsDeps,
21
38
  ): string {
22
39
  const templatePath = join(TEMPLATES_DIR, templateName);
23
- let template = readFileSync(templatePath, "utf-8");
40
+ let template = fs.readFileSync(templatePath, "utf-8") as string;
24
41
 
25
42
  for (const [key, value] of Object.entries(tokens)) {
26
43
  template = template.replaceAll(`{{${key}}}`, value);
27
44
  }
28
45
 
29
46
  const resolvedPath = join(issueDir, templateName);
30
- mkdirSync(dirname(resolvedPath), { recursive: true });
31
- writeFileSync(resolvedPath, template, "utf-8");
47
+ fs.mkdirSync(dirname(resolvedPath), { recursive: true });
48
+ fs.writeFileSync(resolvedPath, template, "utf-8");
32
49
 
33
50
  return relative(process.cwd(), resolvedPath);
34
51
  }
@@ -11,6 +11,7 @@ import { getConfig } from "./config.js";
11
11
  import { ARTIFACTS } from "./prompt-templates/index.js";
12
12
  import { resolveTemplate } from "./templates.js";
13
13
 
14
+ import type { SpawnClaudeFn } from "./spawn-claude.js";
14
15
  import type { TokenValues } from "./templates.js";
15
16
 
16
17
  export { ensureDir, fileExists, readFile, writeFile } from "../../utils/fs.js";
@@ -157,6 +158,7 @@ export interface StepRunnerOptions {
157
158
  artifactPath: string;
158
159
  templateName: string;
159
160
  artifactValidator?: (path: string) => boolean;
161
+ spawnFn?: SpawnClaudeFn;
160
162
  }
161
163
 
162
164
  /**
@@ -167,7 +169,7 @@ export interface StepRunnerOptions {
167
169
  * the same pattern.
168
170
  */
169
171
  export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<boolean> {
170
- const { stepName, ctx, artifactPath, templateName, artifactValidator } = opts;
172
+ const { stepName, ctx, artifactPath, templateName, artifactValidator, spawnFn } = opts;
171
173
 
172
174
  const isValid = artifactValidator ?? fileExists;
173
175
  if (isValid(artifactPath)) {
@@ -183,6 +185,7 @@ export async function runStepWithArtifact(opts: StepRunnerOptions): Promise<bool
183
185
  const result = await runClaude({
184
186
  promptFile,
185
187
  maxTurns: getConfig().maxTurns,
188
+ spawnFn,
186
189
  });
187
190
 
188
191
  if (result.is_error) {
@@ -1,13 +1,9 @@
1
- import * as fs from "node:fs";
2
1
  import { describe, expect, it, vi } from "vitest";
3
2
 
3
+ import type { ReadFileFn } from "./parser";
4
4
  import { calculateCutoffMs, filterByDays, parseJsonl, quickTokenCount } from "./parser";
5
5
 
6
- vi.mock("node:fs", () => ({
7
- readFileSync: vi.fn(),
8
- }));
9
-
10
- const mockedReadFileSync = vi.mocked(fs.readFileSync);
6
+ const mockReadFileSync: ReadFileFn = vi.fn();
11
7
 
12
8
  // ── Pure functions (no mocking needed) ──
13
9
 
@@ -67,38 +63,38 @@ describe("filterByDays", () => {
67
63
  });
68
64
  });
69
65
 
70
- // ── parseJsonl and quickTokenCount (require fs mock) ──
66
+ // ── parseJsonl and quickTokenCount (use injected readFile) ──
71
67
 
72
68
  describe("parseJsonl", () => {
73
69
  it("parses valid JSONL lines", () => {
74
- mockedReadFileSync.mockReturnValue(
70
+ vi.mocked(mockReadFileSync).mockReturnValue(
75
71
  '{"type":"user","sessionId":"s1","timestamp":"2025-01-01T00:00:00Z"}\n{"type":"assistant","sessionId":"s1","timestamp":"2025-01-01T00:01:00Z"}\n',
76
72
  );
77
- const entries = parseJsonl("/fake/path.jsonl");
73
+ const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
78
74
  expect(entries).toHaveLength(2);
79
75
  expect(entries[0].type).toBe("user");
80
76
  expect(entries[1].type).toBe("assistant");
81
77
  });
82
78
 
83
79
  it("skips empty lines", () => {
84
- mockedReadFileSync.mockReturnValue(
80
+ vi.mocked(mockReadFileSync).mockReturnValue(
85
81
  '{"type":"user","sessionId":"s1","timestamp":"t"}\n\n\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
86
82
  );
87
- const entries = parseJsonl("/fake/path.jsonl");
83
+ const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
88
84
  expect(entries).toHaveLength(2);
89
85
  });
90
86
 
91
87
  it("skips invalid JSON lines", () => {
92
- mockedReadFileSync.mockReturnValue(
88
+ vi.mocked(mockReadFileSync).mockReturnValue(
93
89
  '{"type":"user","sessionId":"s1","timestamp":"t"}\nnot-json\n{"type":"assistant","sessionId":"s1","timestamp":"t"}\n',
94
90
  );
95
- const entries = parseJsonl("/fake/path.jsonl");
91
+ const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
96
92
  expect(entries).toHaveLength(2);
97
93
  });
98
94
 
99
95
  it("returns empty array for empty file", () => {
100
- mockedReadFileSync.mockReturnValue("");
101
- const entries = parseJsonl("/fake/path.jsonl");
96
+ vi.mocked(mockReadFileSync).mockReturnValue("");
97
+ const entries = parseJsonl("/fake/path.jsonl", mockReadFileSync);
102
98
  expect(entries).toHaveLength(0);
103
99
  });
104
100
  });
@@ -113,8 +109,8 @@ describe("quickTokenCount", () => {
113
109
  message: { usage: { input_tokens: 200, output_tokens: 75 } },
114
110
  }),
115
111
  ].join("\n");
116
- mockedReadFileSync.mockReturnValue(lines);
117
- expect(quickTokenCount("/fake/path.jsonl")).toBe(425);
112
+ vi.mocked(mockReadFileSync).mockReturnValue(lines);
113
+ expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(425);
118
114
  });
119
115
 
120
116
  it("skips entries without usage", () => {
@@ -122,29 +118,29 @@ describe("quickTokenCount", () => {
122
118
  JSON.stringify({ message: { content: "text" } }),
123
119
  JSON.stringify({ message: { usage: { input_tokens: 100, output_tokens: 50 } } }),
124
120
  ].join("\n");
125
- mockedReadFileSync.mockReturnValue(lines);
126
- expect(quickTokenCount("/fake/path.jsonl")).toBe(150);
121
+ vi.mocked(mockReadFileSync).mockReturnValue(lines);
122
+ expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(150);
127
123
  });
128
124
 
129
125
  it("returns 0 for unreadable files", () => {
130
- mockedReadFileSync.mockImplementation(() => {
126
+ vi.mocked(mockReadFileSync).mockImplementation(() => {
131
127
  throw new Error("ENOENT");
132
128
  });
133
- expect(quickTokenCount("/missing/file.jsonl")).toBe(0);
129
+ expect(quickTokenCount("/missing/file.jsonl", mockReadFileSync)).toBe(0);
134
130
  });
135
131
 
136
132
  it("handles entries with partial usage (only input_tokens)", () => {
137
133
  const lines = JSON.stringify({
138
134
  message: { usage: { input_tokens: 100 } },
139
135
  });
140
- mockedReadFileSync.mockReturnValue(lines);
141
- expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
136
+ vi.mocked(mockReadFileSync).mockReturnValue(lines);
137
+ expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
142
138
  });
143
139
 
144
140
  it("skips invalid JSON lines gracefully", () => {
145
- mockedReadFileSync.mockReturnValue(
141
+ vi.mocked(mockReadFileSync).mockReturnValue(
146
142
  '{"message":{"usage":{"input_tokens":50,"output_tokens":50}}}\nbadline\n',
147
143
  );
148
- expect(quickTokenCount("/fake/path.jsonl")).toBe(100);
144
+ expect(quickTokenCount("/fake/path.jsonl", mockReadFileSync)).toBe(100);
149
145
  });
150
146
  });
@@ -1,6 +1,9 @@
1
- import * as fs from "node:fs";
1
+ import { readFileSync as defaultReadFileSync } from "node:fs";
2
+
2
3
  import type { JournalEntry } from "./types.js";
3
4
 
5
+ export type ReadFileFn = (path: string, encoding: BufferEncoding) => string;
6
+
4
7
  /**
5
8
  * Calculate cutoff timestamp for days filtering.
6
9
  * Returns 0 if days <= 0 (no filtering).
@@ -22,8 +25,11 @@ export function filterByDays<T extends { mtime: number }>(items: T[], days: numb
22
25
  /**
23
26
  * Parse JSONL file into JournalEntry array.
24
27
  */
25
- export function parseJsonl(filePath: string): JournalEntry[] {
26
- const content = fs.readFileSync(filePath, "utf-8");
28
+ export function parseJsonl(
29
+ filePath: string,
30
+ readFile: ReadFileFn = defaultReadFileSync,
31
+ ): JournalEntry[] {
32
+ const content = readFile(filePath, "utf-8");
27
33
  const entries: JournalEntry[] = [];
28
34
 
29
35
  for (const line of content.split("\n")) {
@@ -41,9 +47,12 @@ export function parseJsonl(filePath: string): JournalEntry[] {
41
47
  /**
42
48
  * Quick token count from a JSONL file without full parsing.
43
49
  */
44
- export function quickTokenCount(filePath: string): number {
50
+ export function quickTokenCount(
51
+ filePath: string,
52
+ readFile: ReadFileFn = defaultReadFileSync,
53
+ ): number {
45
54
  try {
46
- const content = fs.readFileSync(filePath, "utf-8");
55
+ const content = readFile(filePath, "utf-8");
47
56
  let total = 0;
48
57
  for (const line of content.split("\n")) {
49
58
  if (!line.trim()) continue;