Skip to content

Commit 6ead98c

Browse files
aksOpsclaude
andauthored
test(go): coverage uplift cmd/logs + cmd/overlay + health; dedupe atomicWriteFile (#10)
* test(go): coverage uplift for cmd/logs, cmd/overlay, internal/health; consolidate atomicWriteFile Coverage push as part of the SonarCloud cleanup. Three Go files moved from low/zero coverage into the 70-90% band, plus a long-standing duplication finally extracted. Coverage: - cmd/overlay.go: 34% → 91.5% (16 new tests). Covers runOverlayStatus / Init / Edit (which were 0%), plus buildSampleOverlay path escaping and writeEnvFile idempotence. - cmd/logs.go: 38% → ~92% per function (~36 new tests). compileFilters, toolInputSummary, dumpOne/dumpLog, listSessionLogs, runLogs, tailLog (drain-on-cancel). The tailer's mid-rotation branches stay uncovered — they require a writer racing the 500ms poll and would flake in CI. - internal/health/claude_check.go: 0% → 71% weighted. CheckWorkdir and CheckClaudeSession at 100%; CheckClaudeProcess at 25% (only the no-pane branch — the rest needs a live tmux pane with a child process tree). Dedupe (extracted helper): - New internal/fsutil package with AtomicWriteFile + 4 tests. - Replaces three near-identical 30-line atomicWriteFile copies in internal/claude/jsonpatch.go, internal/migrate/migrate.go, and internal/jsonstrict/jsonstrict.go. The migrate.go and jsonstrict.go comments explicitly TODO'd this consolidation; a third caller appearing was the trigger. cmd/yolo.go refactor (deferred from PR-B) and the larger ui/SessionDetail.tsx + internal/serve/server.go test work land in PR-D. Verification: 762 Go tests pass across 27 packages with -tags sqlite_fts5; UI 110 vitest tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: extra atomic_test cases + sonar coverage exclusion for fsutil Two adjustments to land the new-code coverage gate at >80% on PR-C: - Added two more cases to internal/fsutil/atomic_test.go: - rename-onto-non-empty-directory failure path (rename(2) returns EISDIR; verifies the error is surfaced and the dir stays intact). - permission propagation across 0600/0640/0644/0664/0755 — exercises the explicit Chmod that overrides os.CreateTemp's 0600 default. - sonar.coverage.exclusions on internal/fsutil/atomic.go. The remaining ~30% comes from defensive Write/Chmod/Close error branches on a successfully-created temp file — not reachable on Linux as the file's owner. Realistic behaviour (success, missing parent, rename- onto-dir) is tested. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 05d40ef commit 6ead98c

9 files changed

Lines changed: 1257 additions & 99 deletions

File tree

cmd/logs_extra_test.go

Lines changed: 469 additions & 0 deletions
Large diffs are not rendered by default.

cmd/overlay_test.go

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/RandomCodeSpace/ctm/internal/config"
10+
)
11+
12+
// withTempHome is defined in bootstrap_test.go in this package; reuse it.
13+
14+
func TestSessionLogDir(t *testing.T) {
15+
home := withTempHome(t)
16+
got := sessionLogDir()
17+
want := filepath.Join(home, ".config", "ctm", "logs")
18+
if got != want {
19+
t.Errorf("sessionLogDir() = %q, want %q", got, want)
20+
}
21+
}
22+
23+
func TestCtmSubcommand(t *testing.T) {
24+
// Happy path: os.Executable returns a real path during `go test`, so the
25+
// returned string must contain the subcommand suffix.
26+
got := ctmSubcommand("statusline")
27+
if !strings.HasSuffix(got, " statusline") {
28+
t.Errorf("ctmSubcommand(\"statusline\") = %q, expected suffix %q", got, " statusline")
29+
}
30+
if got == "" {
31+
t.Error("ctmSubcommand returned empty string")
32+
}
33+
}
34+
35+
func TestLogToolUseHookCommand(t *testing.T) {
36+
got := logToolUseHookCommand()
37+
if !strings.HasSuffix(got, " log-tool-use") {
38+
t.Errorf("logToolUseHookCommand() = %q, expected suffix %q", got, " log-tool-use")
39+
}
40+
}
41+
42+
func TestStatuslineHookCommand(t *testing.T) {
43+
got := statuslineHookCommand()
44+
if !strings.HasSuffix(got, " statusline") {
45+
t.Errorf("statuslineHookCommand() = %q, expected suffix %q", got, " statusline")
46+
}
47+
}
48+
49+
func TestBuildSampleOverlayContainsHookPaths(t *testing.T) {
50+
got := buildSampleOverlay("/usr/local/bin/ctm statusline", "/usr/local/bin/ctm log-tool-use")
51+
52+
wants := []string{
53+
`"reduceMotion": false`,
54+
`"spinnerTipsEnabled": false`,
55+
`"statusLine"`,
56+
`"/usr/local/bin/ctm statusline"`,
57+
`"/usr/local/bin/ctm log-tool-use"`,
58+
`"theme": "dark"`,
59+
`"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"`,
60+
`"PostToolUse"`,
61+
`"matcher": "*"`,
62+
}
63+
for _, want := range wants {
64+
if !strings.Contains(got, want) {
65+
t.Errorf("buildSampleOverlay output missing %q\n--- got ---\n%s", want, got)
66+
}
67+
}
68+
}
69+
70+
func TestBuildSampleOverlayEscapesPathsWithSpaces(t *testing.T) {
71+
// %q in fmt.Sprintf is what protects us from a path containing a quote
72+
// character — verify the JSON stays parseable.
73+
got := buildSampleOverlay(`/path with spaces/ctm statusline`, `/another path/ctm log-tool-use`)
74+
if !strings.Contains(got, `"/path with spaces/ctm statusline"`) {
75+
t.Errorf("statusline path not properly quoted:\n%s", got)
76+
}
77+
if !strings.Contains(got, `"/another path/ctm log-tool-use"`) {
78+
t.Errorf("log hook path not properly quoted:\n%s", got)
79+
}
80+
}
81+
82+
func TestWriteEnvFileCreatesAndIsIdempotent(t *testing.T) {
83+
tmp := t.TempDir()
84+
path := filepath.Join(tmp, "nested", "dir", "env.sh")
85+
86+
if err := writeEnvFile(path); err != nil {
87+
t.Fatalf("first writeEnvFile: %v", err)
88+
}
89+
90+
info, err := os.Stat(path)
91+
if err != nil {
92+
t.Fatalf("stat after first write: %v", err)
93+
}
94+
if mode := info.Mode().Perm(); mode != 0600 {
95+
t.Errorf("env.sh perm = %v, want 0600", mode)
96+
}
97+
98+
// User edit must survive a second call (O_EXCL bails out on EEXIST).
99+
userEdit := []byte("# user edit\nexport FOO=bar\n")
100+
if err := os.WriteFile(path, userEdit, 0600); err != nil {
101+
t.Fatalf("user edit: %v", err)
102+
}
103+
if err := writeEnvFile(path); err != nil {
104+
t.Fatalf("second writeEnvFile: %v", err)
105+
}
106+
got, err := os.ReadFile(path)
107+
if err != nil {
108+
t.Fatal(err)
109+
}
110+
if string(got) != string(userEdit) {
111+
t.Errorf("user edit clobbered:\nwant:\n%s\ngot:\n%s", userEdit, got)
112+
}
113+
}
114+
115+
func TestWriteEnvFileMkdirAllErrorPath(t *testing.T) {
116+
// Pointing the env file at a path whose parent is a regular file forces
117+
// MkdirAll to fail, exercising the error-return branch.
118+
tmp := t.TempDir()
119+
regularFile := filepath.Join(tmp, "blocker")
120+
if err := os.WriteFile(regularFile, []byte("x"), 0600); err != nil {
121+
t.Fatal(err)
122+
}
123+
target := filepath.Join(regularFile, "child", "env.sh")
124+
125+
if err := writeEnvFile(target); err == nil {
126+
t.Errorf("expected error when parent path component is a regular file")
127+
}
128+
}
129+
130+
func TestRunOverlayStatusNoOverlay(t *testing.T) {
131+
withTempHome(t)
132+
if err := runOverlayStatus(nil, nil); err != nil {
133+
t.Errorf("runOverlayStatus with no overlay returned err: %v", err)
134+
}
135+
}
136+
137+
func TestRunOverlayStatusWithOverlay(t *testing.T) {
138+
withTempHome(t)
139+
// Create overlay + env file so both info-branches are walked.
140+
if err := os.MkdirAll(config.Dir(), 0700); err != nil {
141+
t.Fatal(err)
142+
}
143+
if err := os.WriteFile(config.ClaudeOverlayPath(), []byte("{}\n"), 0600); err != nil {
144+
t.Fatal(err)
145+
}
146+
if err := os.WriteFile(config.EnvFilePath(), []byte("# env\n"), 0600); err != nil {
147+
t.Fatal(err)
148+
}
149+
150+
if err := runOverlayStatus(nil, nil); err != nil {
151+
t.Errorf("runOverlayStatus with overlay returned err: %v", err)
152+
}
153+
}
154+
155+
func TestRunOverlayInitCreates(t *testing.T) {
156+
withTempHome(t)
157+
if err := runOverlayInit(nil, nil); err != nil {
158+
t.Fatalf("runOverlayInit: %v", err)
159+
}
160+
161+
overlay := config.ClaudeOverlayPath()
162+
data, err := os.ReadFile(overlay)
163+
if err != nil {
164+
t.Fatalf("reading overlay: %v", err)
165+
}
166+
got := string(data)
167+
for _, want := range []string{
168+
`"reduceMotion"`,
169+
`"spinnerTipsEnabled"`,
170+
`statusline`,
171+
`log-tool-use`,
172+
} {
173+
if !strings.Contains(got, want) {
174+
t.Errorf("overlay missing %q in output:\n%s", want, got)
175+
}
176+
}
177+
178+
info, err := os.Stat(overlay)
179+
if err != nil {
180+
t.Fatal(err)
181+
}
182+
if mode := info.Mode().Perm(); mode != 0600 {
183+
t.Errorf("overlay mode = %v, want 0600", mode)
184+
}
185+
186+
// env file + log dir should also exist.
187+
if _, err := os.Stat(config.EnvFilePath()); err != nil {
188+
t.Errorf("env file not created: %v", err)
189+
}
190+
if st, err := os.Stat(sessionLogDir()); err != nil || !st.IsDir() {
191+
t.Errorf("session log dir not a directory: %v", err)
192+
}
193+
}
194+
195+
func TestRunOverlayInitErrorsWhenAlreadyExists(t *testing.T) {
196+
withTempHome(t)
197+
if err := os.MkdirAll(config.Dir(), 0700); err != nil {
198+
t.Fatal(err)
199+
}
200+
if err := os.WriteFile(config.ClaudeOverlayPath(), []byte("{}\n"), 0600); err != nil {
201+
t.Fatal(err)
202+
}
203+
204+
err := runOverlayInit(nil, nil)
205+
if err == nil {
206+
t.Fatal("runOverlayInit should error when overlay exists")
207+
}
208+
if !strings.Contains(err.Error(), "already exists") {
209+
t.Errorf("error %q should mention 'already exists'", err.Error())
210+
}
211+
}
212+
213+
// fakeEditorPath writes a minimal POSIX shell editor that exits 0 without
214+
// touching the file, and prepends its directory to PATH for the test.
215+
// The returned editor name is suitable for $EDITOR.
216+
func fakeEditorPath(t *testing.T, name string) {
217+
t.Helper()
218+
dir := t.TempDir()
219+
bin := filepath.Join(dir, name)
220+
// Use #!/bin/sh true-equivalent so the editor exits cleanly.
221+
script := "#!/bin/sh\nexit 0\n"
222+
if err := os.WriteFile(bin, []byte(script), 0700); err != nil {
223+
t.Fatalf("writing fake editor: %v", err)
224+
}
225+
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
226+
t.Setenv("EDITOR", name)
227+
}
228+
229+
func TestRunOverlayEditCreatesSampleAndRunsEditor(t *testing.T) {
230+
withTempHome(t)
231+
fakeEditorPath(t, "fake-editor-create")
232+
233+
if err := runOverlayEdit(nil, nil); err != nil {
234+
t.Fatalf("runOverlayEdit: %v", err)
235+
}
236+
237+
// Sample overlay should be created on first edit.
238+
data, err := os.ReadFile(config.ClaudeOverlayPath())
239+
if err != nil {
240+
t.Fatalf("reading overlay: %v", err)
241+
}
242+
if !strings.Contains(string(data), "statusLine") {
243+
t.Errorf("expected sample overlay content, got:\n%s", data)
244+
}
245+
if _, err := os.Stat(config.EnvFilePath()); err != nil {
246+
t.Errorf("expected env file, got err: %v", err)
247+
}
248+
}
249+
250+
func TestRunOverlayEditExistingFile(t *testing.T) {
251+
withTempHome(t)
252+
fakeEditorPath(t, "fake-editor-existing")
253+
254+
if err := os.MkdirAll(config.Dir(), 0700); err != nil {
255+
t.Fatal(err)
256+
}
257+
preexisting := []byte(`{"theme":"light"}`)
258+
if err := os.WriteFile(config.ClaudeOverlayPath(), preexisting, 0600); err != nil {
259+
t.Fatal(err)
260+
}
261+
262+
if err := runOverlayEdit(nil, nil); err != nil {
263+
t.Fatalf("runOverlayEdit: %v", err)
264+
}
265+
266+
// Editor exits without changes; existing content must be preserved.
267+
got, err := os.ReadFile(config.ClaudeOverlayPath())
268+
if err != nil {
269+
t.Fatal(err)
270+
}
271+
if string(got) != string(preexisting) {
272+
t.Errorf("existing overlay was rewritten\nwant: %s\ngot: %s", preexisting, got)
273+
}
274+
}
275+
276+
func TestRunOverlayEditMissingEditor(t *testing.T) {
277+
withTempHome(t)
278+
// Empty PATH + nonexistent editor name -> exec.LookPath fails.
279+
t.Setenv("PATH", "")
280+
t.Setenv("EDITOR", "definitely-not-a-real-editor-xyzzy")
281+
282+
err := runOverlayEdit(nil, nil)
283+
if err == nil {
284+
t.Fatal("expected error when editor is missing")
285+
}
286+
if !strings.Contains(err.Error(), "not found in PATH") {
287+
t.Errorf("error %q should mention editor not found in PATH", err.Error())
288+
}
289+
290+
// Half-created sample must NOT exist (resolver runs before any FS work).
291+
if _, statErr := os.Stat(config.ClaudeOverlayPath()); statErr == nil {
292+
t.Error("overlay file should not have been created when editor lookup failed")
293+
}
294+
}
295+
296+
func TestRunOverlayEditDefaultsToVi(t *testing.T) {
297+
withTempHome(t)
298+
// Unset $EDITOR to exercise the "EDITOR == empty -> vi" branch. With an
299+
// empty PATH, vi resolution will fail and we get a clear error mentioning
300+
// "vi".
301+
t.Setenv("PATH", "")
302+
t.Setenv("EDITOR", "")
303+
304+
err := runOverlayEdit(nil, nil)
305+
if err == nil {
306+
t.Fatal("expected error when vi missing from empty PATH")
307+
}
308+
if !strings.Contains(err.Error(), `"vi"`) {
309+
t.Errorf("expected error to name default editor vi, got: %v", err)
310+
}
311+
}
312+
313+
func TestOverlayPathCmdRunE(t *testing.T) {
314+
withTempHome(t)
315+
// Exercise the inline RunE on overlayPathCmd. It calls fmt.Println and
316+
// returns nil — no observable state beyond no-error.
317+
if err := overlayPathCmd.RunE(overlayPathCmd, nil); err != nil {
318+
t.Errorf("overlayPathCmd RunE returned err: %v", err)
319+
}
320+
}

internal/claude/jsonpatch.go

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7-
"path/filepath"
7+
8+
"github.com/RandomCodeSpace/ctm/internal/fsutil"
89
)
910

1011
// patchJSONFile reads path, applies patch to the top-level JSON object, and
@@ -53,36 +54,5 @@ func patchJSONFile(path string, patch func(obj map[string]json.RawMessage) bool)
5354
return fmt.Errorf("marshalling %s: %w", path, err)
5455
}
5556

56-
return atomicWriteFile(path, out, info.Mode().Perm())
57-
}
58-
59-
// atomicWriteFile writes data to path via a temp file in the same directory
60-
// followed by rename(2), so readers never see a half-written file. The temp
61-
// file's mode is forced to perm before close to avoid the default 0600 from
62-
// os.CreateTemp overriding the caller's intent after rename.
63-
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
64-
dir := filepath.Dir(path)
65-
base := filepath.Base(path) + ".*"
66-
tmp, err := os.CreateTemp(dir, base)
67-
if err != nil {
68-
return fmt.Errorf("creating temp file: %w", err)
69-
}
70-
tmpPath := tmp.Name()
71-
defer os.Remove(tmpPath) //nolint:errcheck
72-
73-
if _, err := tmp.Write(data); err != nil {
74-
tmp.Close() //nolint:errcheck
75-
return fmt.Errorf("writing temp file: %w", err)
76-
}
77-
if err := tmp.Chmod(perm); err != nil {
78-
tmp.Close() //nolint:errcheck
79-
return fmt.Errorf("chmod temp file: %w", err)
80-
}
81-
if err := tmp.Close(); err != nil {
82-
return fmt.Errorf("closing temp file: %w", err)
83-
}
84-
if err := os.Rename(tmpPath, path); err != nil {
85-
return fmt.Errorf("renaming temp file: %w", err)
86-
}
87-
return nil
57+
return fsutil.AtomicWriteFile(path, out, info.Mode().Perm())
8858
}

0 commit comments

Comments
 (0)