Skip to content

Commit d1d1338

Browse files
authored
feat: add GitHub Actions CI and improve CLI interface (#1)
- Add test workflow for Go tests with Julia setup - Simplify CLI: support `-e` flag and stdin piping without subcommand - Rename `--env` to `--project` for clarity - Add comprehensive Go tests for client, daemon, and Julia integration - Update README with new CLI examples and alternatives section
1 parent db7c08b commit d1d1338

6 files changed

Lines changed: 340 additions & 56 deletions

File tree

.github/workflows/test.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v6
13+
- uses: actions/setup-go@v5
14+
with:
15+
go-version-file: go.mod
16+
- uses: julia-actions/setup-julia@v2
17+
- run: go test -v -timeout 300s ./go/

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,16 @@ GitHub Actions builds cross-platform binaries (Linux/macOS × amd64/arm64, Windo
3030

3131
```bash
3232
# Evaluate code (daemon starts automatically)
33-
julia-client eval 'println("hello")'
33+
julia-client -e 'println("hello")'
3434

3535
# Pkg operations (disable timeout)
36-
julia-client eval --timeout 0 'using Pkg; Pkg.add("Example")'
37-
38-
# Custom Julia binary
39-
julia-client eval --julia-cmd "julia +1.11" 'versioninfo()'
36+
julia-client --timeout 0 -e 'using Pkg; Pkg.add("Example")'
4037

4138
# Explicit project environment
42-
julia-client eval --env /path/to/project 'using MyPackage'
39+
julia-client --project /path/to/project -e 'using MyPackage'
40+
41+
# Read from stdin
42+
echo 'println("hello")' | julia-client
4343

4444
# Session management
4545
julia-client sessions # list active sessions
@@ -57,3 +57,8 @@ A single `julia-client` binary serves as both client and daemon:
5757

5858
- **Client mode** (default) — sends JSON requests over a Unix socket (`~/.local/share/julia-client/julia-daemon.sock`)
5959
- **Daemon mode** (`julia-client daemon`) — background server managing persistent Julia processes; auto-started on first `eval`, shuts down after 30 minutes of inactivity
60+
61+
## Alternatives
62+
63+
- [julia-mcp](https://github.com/aplavin/julia-mcp?tab=readme-ov-file) is very similar but uses MCP server instead
64+
- [DaemonicCabal.jl](https://github.com/tecosaur/DaemonicCabal.jl) only runs on Linux

go/client_test.go

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"net"
6+
"os"
7+
"path/filepath"
8+
"sync"
9+
"testing"
10+
"time"
11+
)
12+
13+
// ---- detectEnv / resolveProject ----
14+
15+
func TestDetectEnv_FindsProjectToml(t *testing.T) {
16+
root := t.TempDir()
17+
sub := filepath.Join(root, "a", "b")
18+
if err := os.MkdirAll(sub, 0755); err != nil {
19+
t.Fatal(err)
20+
}
21+
if err := os.WriteFile(filepath.Join(root, "Project.toml"), []byte{}, 0644); err != nil {
22+
t.Fatal(err)
23+
}
24+
25+
got := detectEnv(sub)
26+
if got != root {
27+
t.Errorf("detectEnv(%q) = %q, want %q", sub, got, root)
28+
}
29+
}
30+
31+
func TestDetectEnv_NoneFound(t *testing.T) {
32+
dir := t.TempDir()
33+
// Make sure there's no Project.toml anywhere up the tree
34+
// (TempDir is under /tmp which never has one)
35+
got := detectEnv(dir)
36+
if got != "" {
37+
t.Errorf("detectEnv(%q) = %q, want empty", dir, got)
38+
}
39+
}
40+
41+
func TestResolveProject_Empty(t *testing.T) {
42+
// When empty, result is either a detected env or "".
43+
// Just ensure it doesn't panic and returns a valid absolute path or "".
44+
got := resolveProject("")
45+
if got != "" {
46+
if !filepath.IsAbs(got) {
47+
t.Errorf("resolveProject(\"\") = %q, want absolute path or empty", got)
48+
}
49+
}
50+
}
51+
52+
func TestResolveProject_Relative(t *testing.T) {
53+
root := t.TempDir()
54+
sub := filepath.Join(root, "proj")
55+
os.Mkdir(sub, 0755)
56+
57+
orig, _ := os.Getwd()
58+
os.Chdir(root)
59+
defer os.Chdir(orig)
60+
61+
got := resolveProject("proj")
62+
// Resolve symlinks on both sides (macOS /var → /private/var)
63+
gotR, _ := filepath.EvalSymlinks(got)
64+
subR, _ := filepath.EvalSymlinks(sub)
65+
if gotR != subR {
66+
t.Errorf("resolveProject(\"proj\") = %q, want %q", got, sub)
67+
}
68+
}
69+
70+
// ---- pkgPattern ----
71+
72+
func TestPkgPattern(t *testing.T) {
73+
hits := []string{
74+
"Pkg.add(\"Example\")",
75+
"using Pkg; Pkg.update()",
76+
"Pkg.resolve()",
77+
}
78+
misses := []string{
79+
"println(\"hello\")",
80+
"x = 1 + 2",
81+
"# no package ops here",
82+
}
83+
for _, s := range hits {
84+
if !pkgPattern.MatchString(s) {
85+
t.Errorf("pkgPattern should match %q", s)
86+
}
87+
}
88+
for _, s := range misses {
89+
if pkgPattern.MatchString(s) {
90+
t.Errorf("pkgPattern should not match %q", s)
91+
}
92+
}
93+
}
94+
95+
// ---- handleRequest (no Julia needed) ----
96+
97+
func newTestState() *daemonState {
98+
return &daemonState{
99+
manager: newSessionManager(),
100+
lastRequest: time.Now(),
101+
stopCh: make(chan struct{}),
102+
}
103+
}
104+
105+
func TestHandleRequest_Ping(t *testing.T) {
106+
state := newTestState()
107+
resp := handleRequest(state, map[string]any{"action": "ping"})
108+
if resp["output"] != "pong" {
109+
t.Errorf("ping response = %v, want pong", resp["output"])
110+
}
111+
}
112+
113+
func TestHandleRequest_SessionsEmpty(t *testing.T) {
114+
state := newTestState()
115+
resp := handleRequest(state, map[string]any{"action": "sessions"})
116+
out, _ := resp["output"].(string)
117+
if out != "No active Julia sessions." {
118+
t.Errorf("sessions response = %q", out)
119+
}
120+
}
121+
122+
func TestHandleRequest_UnknownAction(t *testing.T) {
123+
state := newTestState()
124+
resp := handleRequest(state, map[string]any{"action": "bogus"})
125+
if resp["error"] == nil {
126+
t.Error("expected error for unknown action")
127+
}
128+
}
129+
130+
func TestHandleRequest_Stop(t *testing.T) {
131+
state := newTestState()
132+
resp := handleRequest(state, map[string]any{"action": "stop"})
133+
if resp["output"] != "Daemon stopping." {
134+
t.Errorf("stop response = %v", resp["output"])
135+
}
136+
select {
137+
case <-state.stopCh:
138+
// closed as expected
139+
default:
140+
t.Error("stopCh not closed after stop action")
141+
}
142+
}
143+
144+
// ---- daemon socket integration (no Julia) ----
145+
146+
func TestDaemonPingOverSocket(t *testing.T) {
147+
socketPath := filepath.Join(t.TempDir(), "test.sock")
148+
149+
var wg sync.WaitGroup
150+
wg.Add(1)
151+
go func() {
152+
defer wg.Done()
153+
serveDaemon(socketPath, time.Hour)
154+
}()
155+
156+
// Wait for socket to appear
157+
deadline := time.Now().Add(5 * time.Second)
158+
for time.Now().Before(deadline) {
159+
if _, err := os.Stat(socketPath); err == nil {
160+
break
161+
}
162+
time.Sleep(20 * time.Millisecond)
163+
}
164+
165+
conn, err := net.Dial("unix", socketPath)
166+
if err != nil {
167+
t.Fatalf("dial: %v", err)
168+
}
169+
defer conn.Close()
170+
171+
if err := json.NewEncoder(conn).Encode(map[string]any{"action": "ping"}); err != nil {
172+
t.Fatal(err)
173+
}
174+
var resp map[string]any
175+
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
176+
t.Fatal(err)
177+
}
178+
if resp["output"] != "pong" {
179+
t.Errorf("ping over socket = %v, want pong", resp["output"])
180+
}
181+
182+
// Stop the daemon so the goroutine exits
183+
conn2, _ := net.Dial("unix", socketPath)
184+
json.NewEncoder(conn2).Encode(map[string]any{"action": "stop"})
185+
conn2.Close()
186+
wg.Wait()
187+
}
188+
189+
// ---- Julia integration ----
190+
191+
func TestEvalBasic(t *testing.T) {
192+
socketPath := filepath.Join(t.TempDir(), "test.sock")
193+
194+
var wg sync.WaitGroup
195+
wg.Add(1)
196+
go func() {
197+
defer wg.Done()
198+
serveDaemon(socketPath, time.Hour)
199+
}()
200+
201+
// Wait for socket
202+
deadline := time.Now().Add(5 * time.Second)
203+
for time.Now().Before(deadline) {
204+
if _, err := os.Stat(socketPath); err == nil {
205+
break
206+
}
207+
time.Sleep(20 * time.Millisecond)
208+
}
209+
210+
send := func(payload map[string]any) map[string]any {
211+
conn, err := net.Dial("unix", socketPath)
212+
if err != nil {
213+
t.Fatalf("dial: %v", err)
214+
}
215+
defer conn.Close()
216+
json.NewEncoder(conn).Encode(payload)
217+
var resp map[string]any
218+
json.NewDecoder(conn).Decode(&resp)
219+
return resp
220+
}
221+
222+
// Eval basic expression
223+
resp := send(map[string]any{"action": "eval", "code": `println("hello world")`})
224+
if resp["error"] != nil {
225+
t.Fatalf("eval error: %v", resp["error"])
226+
}
227+
out, _ := resp["output"].(string)
228+
if out != "hello world" {
229+
t.Errorf("eval output = %q, want %q", out, "hello world")
230+
}
231+
232+
// State persists across calls
233+
send(map[string]any{"action": "eval", "code": "x = 42"})
234+
resp2 := send(map[string]any{"action": "eval", "code": "println(x)"})
235+
out2, _ := resp2["output"].(string)
236+
if out2 != "42" {
237+
t.Errorf("state not persisted: x = %q, want 42", out2)
238+
}
239+
240+
// Restart clears state
241+
send(map[string]any{"action": "restart"})
242+
resp3 := send(map[string]any{"action": "eval", "code": "println(isdefined(Main, :x))"})
243+
out3, _ := resp3["output"].(string)
244+
if out3 != "false" {
245+
t.Errorf("after restart x should be undefined, got %q", out3)
246+
}
247+
248+
// Stop daemon
249+
conn, _ := net.Dial("unix", socketPath)
250+
json.NewEncoder(conn).Encode(map[string]any{"action": "stop"})
251+
conn.Close()
252+
wg.Wait()
253+
}

0 commit comments

Comments
 (0)