diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f85497d..3623d08 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,4 +14,5 @@ jobs: with: go-version-file: go.mod - uses: julia-actions/setup-julia@v2 + - run: julia -e 'using Pkg; Pkg.add("Revise")' - run: go test -C go -v -timeout 300s diff --git a/go.mod b/go.mod index 3aee3f4..438c775 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,13 @@ module github.com/Beforerr/julia-client go 1.25.0 -require golang.org/x/sync v0.20.0 // indirect +require ( + github.com/stretchr/testify v1.11.1 + golang.org/x/sync v0.20.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 733d716..81bd0d0 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,12 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go/client_test.go b/go/client_test.go index 79603c1..690c4d5 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -9,6 +9,8 @@ import ( "sync" "testing" "time" + + "github.com/stretchr/testify/require" ) // TestMain allows the test binary to act as the CLI when TEST_CLI=1, @@ -35,14 +37,10 @@ func TestPkgPattern(t *testing.T) { "# no package ops here", } for _, s := range hits { - if !pkgPattern.MatchString(s) { - t.Errorf("pkgPattern should match %q", s) - } + require.Truef(t, pkgPattern.MatchString(s), "pkgPattern should match %q", s) } for _, s := range misses { - if pkgPattern.MatchString(s) { - t.Errorf("pkgPattern should not match %q", s) - } + require.Falsef(t, pkgPattern.MatchString(s), "pkgPattern should not match %q", s) } } @@ -60,38 +58,30 @@ func newTestState() *daemonState { func TestHandleRequest_Ping(t *testing.T) { state := newTestState() resp := handleRequest(state, protocolRequest{Action: "ping"}) - if resp.Output != "pong" { - t.Errorf("ping response = %v, want pong", resp.Output) - } + require.Equal(t, "pong", resp.Output) } func TestHandleRequest_SessionsEmpty(t *testing.T) { state := newTestState() resp := handleRequest(state, protocolRequest{Action: "sessions"}) - if resp.Output != "No active Julia sessions." { - t.Errorf("sessions response = %q", resp.Output) - } + require.Equal(t, "No active Julia sessions.", resp.Output) } func TestHandleRequest_UnknownAction(t *testing.T) { state := newTestState() resp := handleRequest(state, protocolRequest{Action: "bogus"}) - if resp.Error == "" { - t.Error("expected error for unknown action") - } + require.NotEmpty(t, resp.Error) } func TestHandleRequest_Stop(t *testing.T) { state := newTestState() resp := handleRequest(state, protocolRequest{Action: "stop"}) - if resp.Output != "Daemon stopping." { - t.Errorf("stop response = %v", resp.Output) - } + require.Equal(t, "Daemon stopping.", resp.Output) select { case <-state.stopCh: // closed as expected default: - t.Error("stopCh not closed after stop action") + require.Fail(t, "stopCh not closed after stop action") } } @@ -101,14 +91,18 @@ func TestHandleRequest_Stop(t *testing.T) { // The returned WaitGroup is done when the daemon exits. func startTestDaemon(t *testing.T) (socketPath string, stop func(), wg *sync.WaitGroup) { t.Helper() - socketPath = filepath.Join(t.TempDir(), "test.sock") + socketDir, err := os.MkdirTemp("/tmp", "julia-client-test-") + require.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(socketDir) }) + socketPath = filepath.Join(socketDir, "test.sock") + errCh := make(chan error, 1) wg = &sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() - serveDaemon(socketPath, time.Hour) + errCh <- serveDaemon(socketPath, time.Hour) }() - waitForSocket(t, socketPath) + waitForSocket(t, socketPath, errCh) stop = func() { conn, _ := net.Dial("unix", socketPath) if conn != nil { @@ -120,28 +114,31 @@ func startTestDaemon(t *testing.T) (socketPath string, stop func(), wg *sync.Wai return } -func waitForSocket(t *testing.T, socketPath string) { +func waitForSocket(t *testing.T, socketPath string, errCh <-chan error) { t.Helper() - deadline := time.Now().Add(5 * time.Second) + deadline := time.Now().Add(15 * time.Second) for time.Now().Before(deadline) { + select { + case err := <-errCh: + require.NoError(t, err) + default: + } if _, err := os.Stat(socketPath); err == nil { return } time.Sleep(20 * time.Millisecond) } - t.Fatal("daemon socket did not appear in time") + require.Fail(t, "daemon socket did not appear in time") } func sendRequest(t *testing.T, socketPath string, req protocolRequest) response { t.Helper() conn, err := net.Dial("unix", socketPath) - if err != nil { - t.Fatalf("dial: %v", err) - } + require.NoError(t, err) defer conn.Close() - json.NewEncoder(conn).Encode(req) + require.NoError(t, json.NewEncoder(conn).Encode(req)) var resp response - json.NewDecoder(conn).Decode(&resp) + require.NoError(t, json.NewDecoder(conn).Decode(&resp)) return resp } @@ -152,9 +149,7 @@ func TestDaemonPingOverSocket(t *testing.T) { defer stop() resp := sendRequest(t, socketPath, protocolRequest{Action: "ping"}) - if resp.Output != "pong" { - t.Errorf("ping over socket = %v, want pong", resp.Output) - } + require.Equal(t, "pong", resp.Output) } // ---- Julia integration ---- @@ -163,7 +158,8 @@ func TestEvalBasic(t *testing.T) { socketPath, stop, _ := startTestDaemon(t) defer stop() - cwd, _ := os.Getwd() + cwd, err := os.Getwd() + require.NoError(t, err) send := func(req protocolRequest) response { if req.Cwd == "" { req.Cwd = cwd @@ -173,38 +169,28 @@ func TestEvalBasic(t *testing.T) { // Eval basic expression resp := send(protocolRequest{Action: "eval", Code: `println("hello world")`}) - if resp.Error != "" { - t.Fatalf("eval error: %v", resp.Error) - } - out := resp.Output - if out != "hello world\n" { - t.Errorf("eval output = %q, want %q", out, "hello world\n") - } + require.Empty(t, resp.Error) + require.Equal(t, "hello world\n", resp.Output) // State persists across calls - send(protocolRequest{Action: "eval", Code: "x = 42"}) + resp = send(protocolRequest{Action: "eval", Code: "x = 42"}) + require.Empty(t, resp.Error) resp2 := send(protocolRequest{Action: "eval", Code: "println(x)"}) - out2 := resp2.Output - if out2 != "42\n" { - t.Errorf("state not persisted: x = %q, want %q", out2, "42\n") - } + require.Empty(t, resp2.Error) + require.Equal(t, "42\n", resp2.Output) // Fresh eval clears state before running code. resp3 := send(protocolRequest{Action: "eval", Code: "println(isdefined(Main, :x))", Fresh: true}) - out3 := resp3.Output - if out3 != "false\n" { - t.Errorf("after fresh eval x should be undefined, got %q", out3) - } + require.Empty(t, resp3.Error) + require.Equal(t, "false\n", resp3.Output) // println adds trailing newline; print does not resp4 := send(protocolRequest{Action: "eval", Code: `print("no-nl")`}) - if resp4.Output != "no-nl" { - t.Errorf("print output = %q, want %q", resp4.Output, "no-nl") - } + require.Empty(t, resp4.Error) + require.Equal(t, "no-nl", resp4.Output) resp5 := send(protocolRequest{Action: "eval", Code: `println("with-nl")`}) - if resp5.Output != "with-nl\n" { - t.Errorf("println output = %q, want %q", resp5.Output, "with-nl\n") - } + require.Empty(t, resp5.Error) + require.Equal(t, "with-nl\n", resp5.Output) } // TestScriptFile exercises the full main() routing: julia-client script.jl @@ -216,33 +202,67 @@ func TestScriptFile(t *testing.T) { cmd := exec.Command(os.Args[0], "--socket", socketPath, "testdata/compute.jl") cmd.Env = append(os.Environ(), "TEST_CLI=1") out, err := cmd.Output() + stderr := "" if err != nil { - stderr := "" if e, ok := err.(*exec.ExitError); ok { stderr = string(e.Stderr) } - t.Fatalf("script run failed: %v\n%s", err, stderr) - } - if got := string(out); got != "42\n" { - t.Errorf("script output = %q, want %q", got, "42\n") } + require.NoErrorf(t, err, "script stderr:\n%s", stderr) + require.Equal(t, "42\n", string(out)) } func TestPrintResult(t *testing.T) { socketPath, stop, _ := startTestDaemon(t) defer stop() - cwd, _ := os.Getwd() + cwd, err := os.Getwd() + require.NoError(t, err) resp := sendRequest(t, socketPath, protocolRequest{ Action: "eval", Code: "1 + 1", Cwd: cwd, PrintResult: true, }) - if resp.Error != "" { - t.Fatalf("print_result error: %v", resp.Error) + require.Empty(t, resp.Error) + require.Equal(t, "2\n", resp.Output) +} + +func TestRevisePicksUpPackageChanges(t *testing.T) { + socketPath, stop, _ := startTestDaemon(t) + defer stop() + + pkgDir := t.TempDir() + srcDir := filepath.Join(pkgDir, "src") + require.NoError(t, os.Mkdir(srcDir, 0755)) + projectToml, err := os.ReadFile(filepath.Join("testdata", "TestRevPkg", "Project.toml")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(pkgDir, "Project.toml"), projectToml, 0644)) + + srcFile := filepath.Join(srcDir, "TestRevPkg.jl") + writePackage := func(greeting string) { + t.Helper() + err := os.WriteFile(srcFile, []byte("module TestRevPkg\ngreet() = "+greeting+"\nend\n"), 0644) + require.NoError(t, err) } - if resp.Output != "2\n" { - t.Errorf("print_result output = %q, want %q", resp.Output, "2\n") + writePackage(`"hello"`) + + send := func(code string) response { + t.Helper() + resp := sendRequest(t, socketPath, protocolRequest{ + Action: "eval", + Code: code, + Cwd: pkgDir, + }) + require.Empty(t, resp.Error, "eval %q failed", code) + return resp } + + send("using TestRevPkg") + resp := send("println(TestRevPkg.greet())") + require.Equal(t, "hello\n", resp.Output) + + writePackage(`"goodbye"`) + resp = send("println(TestRevPkg.greet())") + require.Equal(t, "goodbye\n", resp.Output) } diff --git a/go/session.go b/go/session.go index 81d2ad5..708ed28 100644 --- a/go/session.go +++ b/go/session.go @@ -52,7 +52,6 @@ func newJuliaSession(projectVal, sentinel, juliaCmd string, logFile *os.File) *J } } - func (s *JuliaSession) start(workDir string) error { exe := "julia" var channelArgs, extraFlags []string @@ -116,6 +115,9 @@ func (s *JuliaSession) start(workDir string) error { if _, err := s.executeRaw("using InteractiveUtils", startupTimeout); err != nil { return fmt.Errorf("failed to load InteractiveUtils: %w", err) } + if _, err := s.executeRaw("try; using Revise; catch; end", startupTimeout); err != nil { + return fmt.Errorf("failed to initialize Revise: %w", err) + } return nil } @@ -199,12 +201,12 @@ func (s *JuliaSession) execute(code string, timeoutSecs float64, printResult boo var wrapped string if printResult { wrapped = fmt.Sprintf( - `show(IOContext(stdout, :limit => true), MIME("text/plain"), include_string(Main, String(hex2bytes("%s"))));println(stdout)`, + `try; Revise.revise(); catch; end; show(IOContext(stdout, :limit => true), MIME("text/plain"), include_string(Main, String(hex2bytes("%s"))));println(stdout)`, hexCode, ) } else { wrapped = fmt.Sprintf( - `include_string(Main, String(hex2bytes("%s")));nothing`, + `try; Revise.revise(); catch; end; include_string(Main, String(hex2bytes("%s")));nothing`, hexCode, ) } diff --git a/go/testdata/TestRevPkg/Project.toml b/go/testdata/TestRevPkg/Project.toml new file mode 100644 index 0000000..f9e9fad --- /dev/null +++ b/go/testdata/TestRevPkg/Project.toml @@ -0,0 +1,3 @@ +name = "TestRevPkg" +uuid = "12345678-1234-1234-1234-123456789abc" +version = "0.1.0" diff --git a/skills/julia-client/SKILL.md b/skills/julia-client/SKILL.md index 78471bc..5f00bfc 100644 --- a/skills/julia-client/SKILL.md +++ b/skills/julia-client/SKILL.md @@ -1,22 +1,22 @@ --- name: julia-client -description: "Run Julia code with session state persistence, project env auto-detection and timeout handling. Use for efficient Julia code execution." +description: "Run Julia code with session state persistence, project env auto-detection. Use for efficient Julia code execution, testing, and development." --- ## Running code ```bash -julia-client -e 'const x=1' # Evaluate +julia-client -e 'x=1' # Evaluate julia-client -E 'x' # Evaluate and display -julia-client --fresh -E 'x=2' # Run with clean session state -# Long-running tasks (pkg install, compile, heavy compute): set longer timeout or disable (0) +# Long-running tasks (pkg install, compile, plot, heavy compute): set longer timeout or disable timeout (0) julia-client --timeout 300 heavy_script.jl ``` ## Tips -- Only run setup (e.g. `Pkg.activate`, `using`) once per session. +- Run setup (e.g. `Pkg.activate`, `using PackageOnce`) once per session. +- Prefer `Revise` for automatically updating function definitions: only use `--fresh` flag when clean state is must. ## Session management