diff --git a/pkg/tools/execute/execute_test.go b/pkg/tools/execute/execute_test.go index 92fae2f..3d0c202 100644 --- a/pkg/tools/execute/execute_test.go +++ b/pkg/tools/execute/execute_test.go @@ -28,7 +28,7 @@ func newServer(t *testing.T) *Server { return s } -// callCode is a helper that invokes execute_code via Dispatch. +// callCode invokes execute_code via Dispatch with a steps array. func callCode(t *testing.T, s *Server, steps []map[string]any, extra ...map[string]any) (string, error) { t.Helper() payload := map[string]any{"steps": steps} @@ -41,13 +41,22 @@ func callCode(t *testing.T, s *Server, steps []map[string]any, extra ...map[stri return s.Dispatch(context.Background(), "execute_code", raw) } - -func callPipe(t *testing.T, s *Server, pipe []map[string]any) (string, error) { +// callTool invokes call_tool via Dispatch with a calls array. +func callTool(t *testing.T, s *Server, calls []map[string]any) (string, error) { t.Helper() - payload := map[string]any{"pipe": pipe} + payload := map[string]any{"calls": calls} raw, _ := json.Marshal(payload) - return s.Dispatch(context.Background(), "execute_code", raw) + return s.Dispatch(context.Background(), "call_tool", raw) } + +// callPipe invokes the pipe tool via Dispatch with a stages array. +func callPipe(t *testing.T, s *Server, stages []map[string]any) (string, error) { + t.Helper() + payload := map[string]any{"stages": stages} + raw, _ := json.Marshal(payload) + return s.Dispatch(context.Background(), "pipe", raw) +} + // ---- detectLanguage ---- func TestDetectLanguage(t *testing.T) { @@ -268,8 +277,8 @@ func TestDispatchUnknownTool(t *testing.T) { func TestDispatchNoSteps(t *testing.T) { s := newServer(t) _, err := callCode(t, s, []map[string]any{}) - if err == nil || !strings.Contains(err.Error(), "steps or pipe is required") { - t.Errorf("expected 'steps or pipe is required' error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "steps is required") { + t.Errorf("expected 'steps is required' error, got %v", err) } } @@ -292,6 +301,8 @@ func TestDispatchSingleStep(t *testing.T) { } } +// ---- Dispatch / pipe ---- + func TestDispatchPipeline(t *testing.T) { s := newServer(t) out, err := callPipe(t, s, []map[string]any{ @@ -306,6 +317,16 @@ func TestDispatchPipeline(t *testing.T) { } } +func TestDispatchPipeNoStages(t *testing.T) { + s := newServer(t) + _, err := callPipe(t, s, []map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "stages is required") { + t.Errorf("expected 'stages is required' error, got %v", err) + } +} + +// ---- Dispatch / execute_code parallel ---- + func TestDispatchParallel(t *testing.T) { s := newServer(t) out, err := callCode(t, s, []map[string]any{ @@ -322,7 +343,44 @@ func TestDispatchParallel(t *testing.T) { } } -func TestDispatchToolStep(t *testing.T) { +// ---- Dispatch / call_tool ---- + +func TestDispatchCallTool(t *testing.T) { + dir := t.TempDir() + t.Setenv("OLLIE_TOOLS_PATH", dir) + if err := os.WriteFile(filepath.Join(dir, "greet"), []byte("#!/bin/bash\necho hello-from-tool"), 0755); err != nil { + t.Fatal(err) + } + s := newServer(t) + out, err := callTool(t, s, []map[string]any{{"tool": "greet"}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "hello-from-tool") { + t.Errorf("got %q, want output containing 'hello-from-tool'", out) + } +} + +func TestDispatchCallToolNoCalls(t *testing.T) { + s := newServer(t) + _, err := callTool(t, s, []map[string]any{}) + if err == nil || !strings.Contains(err.Error(), "calls is required") { + t.Errorf("expected 'calls is required' error, got %v", err) + } +} + +func TestDispatchCallToolInlineRejected(t *testing.T) { + s := newServer(t) + _, err := callTool(t, s, []map[string]any{{"code": "echo hi"}}) + if err == nil || !strings.Contains(err.Error(), "tool name is required") { + t.Errorf("expected 'tool name is required' error for inline code in call_tool, got %v", err) + } +} + +// The old TestDispatchToolStep tested tool steps via execute_code. +// That path still works (execute_code accepts tool steps in its steps array); +// but call_tool is now the canonical way. Test both for coverage. +func TestDispatchToolStepViaExecuteCode(t *testing.T) { dir := t.TempDir() t.Setenv("OLLIE_TOOLS_PATH", dir) if err := os.WriteFile(filepath.Join(dir, "greet"), []byte("#!/bin/bash\necho hello-from-tool"), 0755); err != nil { @@ -374,670 +432,59 @@ func TestSetEnvInjected(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if strings.TrimSpace(out) != "injected_value" { - t.Errorf("got %q, want %q", out, "injected_value") + if !strings.Contains(out, "injected_value") { + t.Errorf("got %q, want 'injected_value'", out) } } func TestSetCWD(t *testing.T) { - dir := t.TempDir() s := newServer(t) + dir := t.TempDir() s.SetCWD(dir) out, err := s.Execute(context.Background(), "pwd", "bash", 10, "default", true) if err != nil { t.Fatalf("unexpected error: %v", err) } - // TempDir may use symlinks; compare base names. + // TempDir may use a symlink; compare base name only. if !strings.Contains(out, filepath.Base(dir)) { - t.Errorf("pwd output %q doesn't contain expected dir %q", out, dir) + t.Errorf("got %q, want path containing %q", out, filepath.Base(dir)) } } -// ---- Server contract (ListTools / CallTool / Close) ---- +// ---- Strict mode ---- -func TestServerContract(t *testing.T) { +func TestStrictModeRejectsInline(t *testing.T) { s := newServer(t) - tools, err := s.ListTools() - if err != nil { - t.Fatalf("ListTools: %v", err) - } - if len(tools) == 0 { - t.Fatal("ListTools returned no tools") - } - for _, ti := range tools { - if ti.Name == "" { - t.Error("tool has empty Name") - } - if ti.Description == "" { - t.Errorf("tool %q has empty Description", ti.Name) - } - if len(ti.InputSchema) == 0 { - t.Errorf("tool %q has empty InputSchema", ti.Name) - } - } - // CallTool success path - args, _ := json.Marshal(map[string]any{"steps": []map[string]any{{"code": "echo contract"}}}) - res, err := s.CallTool(context.Background(), "execute_code", args) - if err != nil { - t.Fatalf("CallTool: %v", err) - } - if !strings.Contains(string(res), "contract") { - t.Errorf("CallTool result missing expected output: %s", res) - } - // CallTool error path (unknown tool) - res, err = s.CallTool(context.Background(), "bogus", json.RawMessage(`{}`)) - if err != nil { - t.Fatalf("CallTool error path should marshal, not return error: %v", err) - } - if !strings.Contains(string(res), "isError") { - t.Errorf("CallTool error path should contain isError: %s", res) + s.Strict = true + _, err := callCode(t, s, []map[string]any{{"code": "echo hi"}}) + if err == nil || !strings.Contains(err.Error(), "strict") { + t.Errorf("expected strict mode error, got %v", err) } } -// ---- Decl factory ---- - -func TestDecl(t *testing.T) { - factory := Decl(t.TempDir()) - srv := factory() - if srv == nil { - t.Fatal("Decl factory returned nil") - } -} - -// ---- executeElevated (plugin not found) ---- - -func TestExecuteElevatedNoPlugin(t *testing.T) { - s := newServer(t) - t.Setenv("OLLIE_PLUGINS_PATH", t.TempDir()) // empty dir, no elevate script - _, err := s.executeElevated(context.Background(), "echo hi", t.TempDir(), 10) - if err == nil || !strings.Contains(err.Error(), "elevation not available") { - t.Errorf("expected 'elevation not available' error, got %v", err) - } -} - -// ---- injectArgs: all languages ---- - -func TestInjectArgsPerl(t *testing.T) { - got := injectArgs("perl", "test.pl", []string{"a", "b"}, "print @ARGV;") - if !strings.Contains(got, "@ARGV") || !strings.Contains(got, "'a'") { - t.Errorf("perl inject = %q", got) - } -} - -func TestInjectArgsAwk(t *testing.T) { - got := injectArgs("awk", "test.awk", []string{"file.txt"}, "{print $1}") - if !strings.Contains(got, "gawk") || !strings.Contains(got, "file.txt") { - t.Errorf("awk inject = %q", got) - } -} - -func TestInjectArgsSed(t *testing.T) { - got := injectArgs("sed", "test.sed", []string{"file.txt"}, "s/a/b/g") - if !strings.Contains(got, "sed -e") || !strings.Contains(got, "file.txt") { - t.Errorf("sed inject = %q", got) - } -} - -func TestInjectArgsEd(t *testing.T) { - got := injectArgs("ed", "test.ed", []string{"file.txt"}, ",p") - if !strings.Contains(got, "ed -s") || !strings.Contains(got, "file.txt") { - t.Errorf("ed inject = %q", got) - } - // No args - got2 := injectArgs("ed", "test.ed", nil, ",p") - if strings.Contains(got2, "file") { - t.Errorf("ed inject no args = %q", got2) - } -} - -func TestInjectArgsJq(t *testing.T) { - got := injectArgs("jq", "test.jq", []string{"data.json"}, ".name") - if !strings.Contains(got, "jq") || !strings.Contains(got, "data.json") { - t.Errorf("jq inject = %q", got) - } -} - -func TestInjectArgsExpect(t *testing.T) { - got := injectArgs("expect", "test.exp", nil, "spawn ls") - if !strings.Contains(got, "expect -") { - t.Errorf("expect inject = %q", got) - } -} - -func TestInjectArgsBc(t *testing.T) { - got := injectArgs("bc", "test.bc", nil, "2+2") - if !strings.Contains(got, "bc -ql") { - t.Errorf("bc inject = %q", got) - } -} - -func TestInjectArgsLua(t *testing.T) { - got := injectArgs("lua", "test.lua", []string{"x", "y"}, "print(arg[1])") - if !strings.Contains(got, "arg={") || !strings.Contains(got, "\"x\"") { - t.Errorf("lua inject = %q", got) - } -} - -// ---- ansiCEscape: \r branch ---- - -func TestAnsiCEscapeCarriageReturn(t *testing.T) { - got := ansiCEscape("a\rb") - if got != `a\rb` { - t.Errorf("ansiCEscape(a\\rb) = %q; want a\\rb", got) - } -} - -// ---- resolveCodeStep ---- - -func TestResolveCodeStepToolError(t *testing.T) { - _, _, _, err := resolveCodeStep(CodeStep{Tool: "nonexistent-tool-xyz"}) - if err == nil { - t.Error("expected error for missing tool") - } -} - -func TestResolveCodeStepToolWithArgs(t *testing.T) { - // Create a tool that looks like awk +func TestStrictModeAllowsTool(t *testing.T) { dir := t.TempDir() t.Setenv("OLLIE_TOOLS_PATH", dir) - os.WriteFile(filepath.Join(dir, "mytool"), []byte("#!/usr/bin/awk -f\n{print $1}"), 0755) - - code, lang, trusted, err := resolveCodeStep(CodeStep{Tool: "mytool", Args: []string{"file.txt"}}) - if err != nil { - t.Fatalf("resolveCodeStep: %v", err) - } - if !trusted { - t.Error("tool should be trusted") - } - // awk with args switches to bash - if lang != "bash" { - t.Errorf("lang = %q; want bash (awk with args)", lang) - } - if !strings.Contains(code, "gawk") { - t.Errorf("code = %q; want gawk wrapper", code) - } -} - -func TestResolveCodeStepInlineDefault(t *testing.T) { - code, lang, trusted, err := resolveCodeStep(CodeStep{Code: "echo hi"}) - if err != nil { + if err := os.WriteFile(filepath.Join(dir, "safe"), []byte("#!/bin/bash\necho safe"), 0755); err != nil { t.Fatal(err) } - if lang != "bash" || trusted || code != "echo hi" { - t.Errorf("got lang=%q trusted=%v code=%q", lang, trusted, code) - } -} - -// ---- runStage ---- - -func TestRunStageEmptyStage(t *testing.T) { s := newServer(t) - _, err := s.runStage(context.Background(), 0, CodeStep{}, 5, "default", "") - if err == nil || !strings.Contains(err.Error(), "requires code, tool, or parallel") { - t.Errorf("expected empty stage error; got %v", err) - } -} - -func TestRunStageParallelError(t *testing.T) { - s := newServer(t) - stage := CodeStep{ - Parallel: []CodeStep{ - {Code: "echo ok"}, - {Code: "exit 1"}, - }, - } - _, err := s.runStage(context.Background(), 0, stage, 5, "default", "") - if err == nil { - t.Error("expected error from parallel stage with exit 1") - } -} - -func TestRunStageParallelResolveError(t *testing.T) { - s := newServer(t) - stage := CodeStep{ - Parallel: []CodeStep{ - {Tool: "nonexistent-tool-xyz"}, - }, - } - _, err := s.runStage(context.Background(), 0, stage, 5, "default", "") - if err == nil { - t.Error("expected error from parallel stage with bad tool") - } -} - -// ---- dispatchExecuteCode ---- - -func TestDispatchBadArgs(t *testing.T) { - s := newServer(t) - _, err := s.Dispatch(context.Background(), "execute_code", json.RawMessage(`{invalid`)) - if err == nil { - t.Error("expected error for bad JSON") - } -} - -func TestDispatchPipelineError(t *testing.T) { - s := newServer(t) - args := `{"pipe":[{"code":"echo hello"},{"code":"exit 1"}]}` - _, err := s.Dispatch(context.Background(), "execute_code", json.RawMessage(args)) - if err == nil { - t.Error("expected error from pipeline with failing step") - } -} - -// ---- limitedWriter ---- - -func TestLimitedWriterAlreadyAtLimit(t *testing.T) { - var buf strings.Builder - lw := &limitedWriter{w: &buf, limit: 5, written: 5} - n, err := lw.Write([]byte("overflow")) + s.Strict = true + out, err := callCode(t, s, []map[string]any{{"tool": "safe"}}) if err != nil { - t.Fatal(err) + t.Fatalf("strict mode should allow tool steps: %v", err) } - if n != 8 { - t.Errorf("n = %d; want 8", n) - } - if !lw.truncated { - t.Error("should be truncated") - } - if buf.Len() != 0 { - t.Errorf("buf = %q; want empty", buf.String()) + if !strings.Contains(out, "safe") { + t.Errorf("got %q, want 'safe'", out) } } -func TestLimitedWriterError(t *testing.T) { - lw := &limitedWriter{w: &errWriter{}, limit: 100} - _, err := lw.Write([]byte("data")) - if err == nil { - t.Error("expected write error") - } -} - -type errWriter struct{} - -func (errWriter) Write([]byte) (int, error) { return 0, os.ErrClosed } - -// ---- ToolsPath / PluginsPath defaults ---- - -func TestToolsPathDefault(t *testing.T) { - t.Setenv("OLLIE_TOOLS_PATH", "") - got := ToolsPath() - if !strings.HasSuffix(got, "/tools") { - t.Errorf("ToolsPath() = %q; want suffix /tools", got) - } -} - -func TestPluginsPathDefault(t *testing.T) { - t.Setenv("OLLIE_PLUGINS_PATH", "") - got := PluginsPath() - if !strings.HasSuffix(got, "/scripts/x") { - t.Errorf("PluginsPath() = %q; want suffix /scripts/x", got) - } -} - -func TestToolsPathColonSeparated(t *testing.T) { - t.Setenv("OLLIE_TOOLS_PATH", "/first:/second") - if got := ToolsPath(); got != "/first" { - t.Errorf("ToolsPath() = %q; want /first", got) - } -} - -func TestPluginsPathColonSeparated(t *testing.T) { - t.Setenv("OLLIE_PLUGINS_PATH", "/first:/second") - if got := PluginsPath(); got != "/first" { - t.Errorf("PluginsPath() = %q; want /first", got) - } -} - -// ---- ReadTool: permission error ---- - -func TestReadToolPermissionError(t *testing.T) { - dir := t.TempDir() - t.Setenv("OLLIE_TOOLS_PATH", dir) - path := filepath.Join(dir, "noperm") - os.WriteFile(path, []byte("#!/bin/sh"), 0000) - // On some systems root can still read; skip if so - if _, err := os.ReadFile(path); err == nil { - t.Skip("running as root, cannot test permission error") - } - _, err := ReadTool("noperm") - if err == nil { - t.Error("expected permission error") - } -} - -// ---- loadSandboxConfig ---- - -func TestLoadSandboxConfigDefault(t *testing.T) { - cfg, err := loadSandboxConfig("") - if err != nil { - t.Fatalf("loadSandboxConfig default: %v", err) - } - if cfg == nil { - t.Error("expected non-nil config") - } -} - -func TestLoadSandboxConfigNotFound(t *testing.T) { - _, err := loadSandboxConfig("nonexistent-sandbox-xyz") - if err == nil { - t.Error("expected error for missing sandbox config") - } -} - -// ---- detectLanguage: remaining shebangs ---- - -func TestDetectLanguageEnvShebang(t *testing.T) { - for _, tc := range []struct { - shebang, want string - }{ - {"#!/usr/bin/env perl\n", "perl"}, - {"#!/usr/bin/env lua\n", "lua"}, - {"#!/usr/bin/env lua5.4\n", "lua"}, - {"#!/usr/bin/env gawk\n", "awk"}, - {"#!/usr/bin/env gsed\n", "sed"}, - {"#!/usr/bin/env jq\n", "jq"}, - {"#!/usr/bin/env expect\n", "expect"}, - {"#!/usr/bin/env bc\n", "bc"}, - {"#!/usr/bin/env ed\n", "ed"}, - {"#!\n", "bash"}, - } { - got := detectLanguage(tc.shebang + "code") - if got != tc.want { - t.Errorf("detectLanguage(%q) = %q; want %q", tc.shebang, got, tc.want) - } - } -} - -// ---- executeWithStdin: language branches ---- - -func TestExecutePython(t *testing.T) { - s := newServer(t) - out, err := s.Execute(context.Background(), "print('hello')", "python3", 5, "default", false) - if err != nil { - t.Fatalf("python3: %v", err) - } - if strings.TrimSpace(out) != "hello" { - t.Errorf("python3 output = %q", out) - } -} - -func TestExecutePerl(t *testing.T) { - s := newServer(t) - out, err := s.Execute(context.Background(), "print 42", "perl", 5, "default", false) - if err != nil { - t.Fatalf("perl: %v", err) - } - if strings.TrimSpace(out) != "42" { - t.Errorf("perl output = %q", out) - } -} - -func TestExecuteBc(t *testing.T) { - s := newServer(t) - out, err := s.Execute(context.Background(), "2+3\n", "bc", 5, "default", false) - if err != nil { - t.Fatalf("bc: %v", err) - } - if strings.TrimSpace(out) != "5" { - t.Errorf("bc output = %q", out) - } -} - -func TestExecuteJq(t *testing.T) { - s := newServer(t) - out, err := s.executeWithStdin(context.Background(), ".x", "jq", 5, "default", false, `{"x":1}`) - if err != nil { - t.Fatalf("jq: %v", err) - } - if strings.TrimSpace(out) != "1" { - t.Errorf("jq output = %q", out) - } -} - -func TestExecuteAwk(t *testing.T) { - s := newServer(t) - out, err := s.executeWithStdin(context.Background(), "{print $1}", "awk", 5, "default", false, "hello world") - if err != nil { - t.Fatalf("awk: %v", err) - } - if strings.TrimSpace(out) != "hello" { - t.Errorf("awk output = %q", out) - } -} - -func TestExecuteSed(t *testing.T) { - s := newServer(t) - out, err := s.executeWithStdin(context.Background(), "s/a/b/", "sed", 5, "default", false, "abc") - if err != nil { - t.Fatalf("sed: %v", err) - } - if strings.TrimSpace(out) != "bbc" { - t.Errorf("sed output = %q", out) - } -} - -func TestExecuteEd(t *testing.T) { - s := newServer(t) - s.SetCWD(t.TempDir()) - f := filepath.Join(s.cwd, "test.txt") - os.WriteFile(f, []byte("hello\n"), 0644) - out, err := s.Execute(context.Background(), ",p\nq", "ed", 5, "default", false) - // ed without a file arg just reads commands; may error, that's fine - _ = out - _ = err -} - -func TestExecuteLua(t *testing.T) { - s := newServer(t) - out, err := s.Execute(context.Background(), "print(1+1)", "lua", 5, "default", false) - if err != nil { - t.Skipf("lua not available: %v", err) - } - if strings.TrimSpace(out) != "2" { - t.Errorf("lua output = %q", out) - } -} - -// ---- dispatchExecuteCode: multi-step pipeline error mid-pipeline ---- - -func TestDispatchPipelineMidError(t *testing.T) { - s := newServer(t) - args := `{"pipe":[{"code":"echo hello"},{"code":"exit 1"},{"code":"cat"}]}` - result, err := s.Dispatch(context.Background(), "execute_code", json.RawMessage(args)) - // The error should be wrapped with step index - _ = result - if err == nil { - t.Error("expected pipeline error") - } -} - -// ---- executeElevated ---- - -func fakeElevateDir(t *testing.T) string { - t.Helper() - dir := t.TempDir() - // Fake elevate script: runs the command after "--" - script := "#!/bin/sh\nshift # skip --\neval \"$@\"\n" - os.WriteFile(filepath.Join(dir, "elevate"), []byte(script), 0755) - return dir -} - -func TestExecuteElevatedSuccess(t *testing.T) { - s := newServer(t) - dir := fakeElevateDir(t) - t.Setenv("OLLIE_PLUGINS_PATH", dir) - - out, err := s.executeElevated(context.Background(), "echo elevated", t.TempDir(), 5) - if err != nil { - t.Fatalf("executeElevated: %v", err) - } - if strings.TrimSpace(out) != "elevated" { - t.Errorf("output = %q; want elevated", out) - } -} - -func TestExecuteElevatedStderr(t *testing.T) { - s := newServer(t) - dir := fakeElevateDir(t) - t.Setenv("OLLIE_PLUGINS_PATH", dir) - - out, err := s.executeElevated(context.Background(), "echo out && echo err >&2", t.TempDir(), 5) - if err != nil { - t.Fatalf("executeElevated: %v", err) - } - if !strings.Contains(out, "out") || !strings.Contains(out, "err") { - t.Errorf("output = %q; want both stdout and stderr", out) - } -} - -func TestExecuteElevatedNonZeroExit(t *testing.T) { - s := newServer(t) - dir := fakeElevateDir(t) - t.Setenv("OLLIE_PLUGINS_PATH", dir) - - _, err := s.executeElevated(context.Background(), "exit 42", t.TempDir(), 5) - if err == nil { - t.Error("expected error for non-zero exit") - } - if !strings.Contains(err.Error(), "exit 42") { - t.Errorf("error = %q; want to contain 'exit 42'", err) - } -} - -func TestRunStageElevated(t *testing.T) { - s := newServer(t) - dir := fakeElevateDir(t) - t.Setenv("OLLIE_PLUGINS_PATH", dir) - s.SetCWD(t.TempDir()) - - out, err := s.runStage(context.Background(), 0, CodeStep{Code: "echo elev", Elevated: true}, 5, "default", "") - if err != nil { - t.Fatalf("runStage elevated: %v", err) - } - if !strings.Contains(out, "elev") { - t.Errorf("output = %q", out) - } -} - -func TestRunStageParallelElevated(t *testing.T) { - s := newServer(t) - dir := fakeElevateDir(t) - t.Setenv("OLLIE_PLUGINS_PATH", dir) - s.SetCWD(t.TempDir()) - - stage := CodeStep{ - Parallel: []CodeStep{ - {Code: "echo a", Elevated: true}, - {Code: "echo b", Elevated: true}, - }, - } - out, err := s.runStage(context.Background(), 0, stage, 5, "default", "") - if err != nil { - t.Fatalf("parallel elevated: %v", err) - } - if !strings.Contains(out, "a") || !strings.Contains(out, "b") { - t.Errorf("output = %q", out) - } -} - -func TestDispatchSingleElevated(t *testing.T) { - s := newServer(t) - dir := fakeElevateDir(t) - t.Setenv("OLLIE_PLUGINS_PATH", dir) - s.SetCWD(t.TempDir()) - - args := `{"steps":[{"code":"echo elev","elevated":true}]}` - result, err := s.Dispatch(context.Background(), "execute_code", json.RawMessage(args)) - if err != nil { - t.Fatalf("dispatch elevated: %v", err) - } - if !strings.Contains(string(result), "elev") { - t.Errorf("result = %q", result) - } -} - -// ---- flock / locking ---- - -func TestDetectParallelClass(t *testing.T) { - tests := []struct { - code string - want lockClass - }{ - {"#!/bin/sh\n# ollie:parallel read\ncat $1\n", lockClassRead}, - {"#!/bin/sh\n# ollie:parallel write\n", lockClassWrite}, - {"#!/bin/sh\n# ollie:parallel read extra\ncat\n", lockClassRead}, - {"#!/bin/sh\n# ollie:parallel write extra\n", lockClassWrite}, - {"#!/bin/sh\nno annotation\n", lockClassGlobal}, - {"", lockClassGlobal}, - // lua-style comment - {"#!/usr/bin/env lua\n-- ollie:parallel read\n", lockClassRead}, - // C-style comment - {"// ollie:parallel read\n", lockClassRead}, - // annotation after line 10 is ignored - {strings.Repeat("# filler\n", 10) + "# ollie:parallel read\n", lockClassGlobal}, - } - for _, tt := range tests { - got := detectParallelClass(tt.code) - if got != tt.want { - t.Errorf("detectParallelClass(code) = %v; want %v", got, tt.want) - } - } -} - -func TestSanitizeLockName(t *testing.T) { - tests := []struct { - in, want string - }{ - {"simple", "simple"}, - {"path/to/file", "path_to_file"}, - {"a b:c*d?eg|h\"i\\j", "a_b_c_d_e_f_g_h_i_j"}, - {"", "unnamed"}, - {strings.Repeat("x", 100), strings.Repeat("x", 64)}, - } - for _, tt := range tests { - got := sanitizeLockName(tt.in) - if got != tt.want { - t.Errorf("sanitizeLockName(%q) = %q; want %q", tt.in, got, tt.want) - } - } -} - -func TestAcquireFlockDisabled(t *testing.T) { - f, err := acquireFlock("", "name", true) - if err != nil || f != nil { - t.Errorf("acquireFlock with empty dir: f=%v err=%v; want nil,nil", f, err) - } -} - -func TestAcquireFlockShared(t *testing.T) { - dir := t.TempDir() - f, err := acquireFlock(dir, "test", false) - if err != nil { - t.Fatalf("acquireFlock: %v", err) - } - if f == nil { - t.Fatal("expected non-nil file") - } - f.Close() -} - -func TestAcquireFlockExclusive(t *testing.T) { - dir := t.TempDir() - f, err := acquireFlock(dir, "excl", true) - if err != nil { - t.Fatalf("acquireFlock exclusive: %v", err) - } - f.Close() -} +// ---- classifyStep ---- func TestClassifyStep(t *testing.T) { - // inline code → global - if got := classifyStep(CodeStep{Code: "echo hi"}); got != lockClassGlobal { - t.Errorf("inline code: got %v; want global", got) - } - // parallel group → global - if got := classifyStep(CodeStep{Parallel: []CodeStep{{Code: "x"}}}); got != lockClassGlobal { - t.Errorf("parallel group: got %v; want global", got) + // read-safe inline code + if got := classifyStep(CodeStep{Code: "echo hi"}); got != lockClassRead { + t.Errorf("inline echo: got %v; want read", got) } // elevated → global if got := classifyStep(CodeStep{Elevated: true}); got != lockClassGlobal {