| | | 1 | | package runner |
| | | 2 | | |
| | | 3 | | import ( |
| | | 4 | | "bytes" |
| | | 5 | | "context" |
| | | 6 | | "encoding/json" |
| | | 7 | | "errors" |
| | | 8 | | "fmt" |
| | | 9 | | "os" |
| | | 10 | | "path/filepath" |
| | | 11 | | "runtime" |
| | | 12 | | "strconv" |
| | | 13 | | "sync" |
| | | 14 | | "time" |
| | | 15 | | |
| | | 16 | | "github.com/jedi-knights/neospec/internal/adapters/sandbox" |
| | | 17 | | "github.com/jedi-knights/neospec/internal/domain" |
| | | 18 | | "github.com/jedi-knights/neospec/internal/ports" |
| | | 19 | | ) |
| | | 20 | | |
| | | 21 | | // Runner executes test files in headless Neovim subprocesses. |
| | | 22 | | type Runner struct { |
| | | 23 | | nvimPath string |
| | | 24 | | sandboxF ports.SandboxFactory |
| | | 25 | | exec ports.CommandRunner |
| | | 26 | | verbose bool |
| | | 27 | | initFile string |
| | | 28 | | coverageInclude []string |
| | | 29 | | } |
| | | 30 | | |
| | | 31 | | // New creates a Runner. |
| | | 32 | | // - nvimPath: absolute path to the nvim binary obtained from NeovimProvider.Ensure. |
| | | 33 | | // - sandboxF: factory for creating per-run XDG sandboxes. |
| | | 34 | | // - exec: Strategy for running subprocesses; inject a fake in tests. |
| | | 35 | | // - verbose: whether to pass -V3 to nvim for diagnostic output. |
| | | 36 | | // - initFile: optional path to a Lua file executed before the coverage hook and |
| | | 37 | | // test harness. When non-empty, its dofile() call is the very first line of |
| | | 38 | | // the generated shim so the init file runs outside of coverage instrumentation. |
| | | 39 | | // - coverageInclude: optional list of path substrings. When non-empty, the |
| | | 40 | | // coverage hook only records source files whose path contains at least one |
| | | 41 | | // of these strings, restricting coverage to the plugin's own source tree. |
| | | 42 | | func New(nvimPath string, sandboxF ports.SandboxFactory, exec ports.CommandRunner, verbose bool, initFile string, covera |
| | | 43 | | return &Runner{ |
| | | 44 | | nvimPath: nvimPath, |
| | | 45 | | sandboxF: sandboxF, |
| | | 46 | | exec: exec, |
| | | 47 | | verbose: verbose, |
| | | 48 | | initFile: initFile, |
| | | 49 | | coverageInclude: coverageInclude, |
| | | 50 | | } |
| | | 51 | | } |
| | | 52 | | |
| | | 53 | | // NewWithDefaultSandbox creates a Runner using the standard XDG sandbox factory |
| | | 54 | | // and the real os/exec command runner. Use this in production code. |
| | | 55 | | func NewWithDefaultSandbox(nvimPath string, verbose bool, initFile string, coverageInclude []string) *Runner { |
| | | 56 | | return New(nvimPath, sandbox.NewFactory(), realCommandRunner{}, verbose, initFile, coverageInclude) |
| | | 57 | | } |
| | | 58 | | |
| | | 59 | | // Discover satisfies the discovery half of ports.TestRunner. |
| | | 60 | | func (r *Runner) Discover(ctx context.Context, patterns []string) ([]string, error) { |
| | | 61 | | return Discover(ctx, patterns) |
| | | 62 | | } |
| | | 63 | | |
| | | 64 | | // Run executes each test file in parallel, aggregates results and coverage, and |
| | | 65 | | // returns them in the same order as files. Workers are capped at runtime.NumCPU() |
| | | 66 | | // so the test suite uses available cores without oversubscribing. |
| | | 67 | | func (r *Runner) Run(ctx context.Context, files []string) (*domain.SuiteResult, *domain.CoverageData, error) { |
| | | 68 | | n := len(files) |
| | | 69 | | if n == 0 { |
| | | 70 | | return &domain.SuiteResult{}, &domain.CoverageData{}, nil |
| | | 71 | | } |
| | | 72 | | |
| | | 73 | | type runResult struct { |
| | | 74 | | idx int |
| | | 75 | | suite *domain.SuiteResult |
| | | 76 | | cov *domain.CoverageData |
| | | 77 | | err error |
| | | 78 | | skipped bool // true when the worker skipped this index due to context cancellation |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | // Feed file indices to workers via a buffered jobs channel. |
| | | 82 | | jobs := make(chan int, n) |
| | | 83 | | for i := range n { |
| | | 84 | | jobs <- i |
| | | 85 | | } |
| | | 86 | | close(jobs) |
| | | 87 | | |
| | | 88 | | resultsCh := make(chan runResult, n) |
| | | 89 | | |
| | | 90 | | numWorkers := min(runtime.NumCPU(), n) |
| | | 91 | | |
| | | 92 | | // Start the timer before launching workers so suite.Duration reflects the |
| | | 93 | | // full wall-clock time including goroutine startup and first-job pickup. |
| | | 94 | | start := time.Now() |
| | | 95 | | |
| | | 96 | | var wg sync.WaitGroup |
| | | 97 | | for range numWorkers { |
| | | 98 | | wg.Add(1) |
| | | 99 | | go func() { |
| | | 100 | | defer wg.Done() |
| | | 101 | | for idx := range jobs { |
| | | 102 | | if ctx.Err() != nil { |
| | | 103 | | // Context cancelled — skip remaining jobs as a best-effort early |
| | | 104 | | // exit. Note: this check is non-atomic; runOne may still be called |
| | | 105 | | // for a job that arrives in the window between this check and the |
| | | 106 | | // dispatch below. runOne propagates ctx, so any result it records |
| | | 107 | | // will carry context-cancellation context. Run() surfaces ctx.Err() |
| | | 108 | | // at the return site so callers can distinguish abort from test failure. |
| | | 109 | | // Send a skipped marker so the consumer always receives n results and |
| | | 110 | | // does not need to rely on the (nil, nil) zero-value to detect gaps. |
| | | 111 | | resultsCh <- runResult{idx: idx, skipped: true} |
| | | 112 | | continue |
| | | 113 | | } |
| | | 114 | | suite, cov, err := r.runOne(ctx, files[idx]) |
| | | 115 | | resultsCh <- runResult{idx: idx, suite: suite, cov: cov, err: err} |
| | | 116 | | } |
| | | 117 | | }() |
| | | 118 | | } |
| | | 119 | | go func() { wg.Wait(); close(resultsCh) }() |
| | | 120 | | |
| | | 121 | | // Collect into an ordered slice so output is deterministic regardless of |
| | | 122 | | // which worker finishes first. |
| | | 123 | | ordered := make([]runResult, n) |
| | | 124 | | for res := range resultsCh { |
| | | 125 | | ordered[res.idx] = res |
| | | 126 | | } |
| | | 127 | | |
| | | 128 | | suite := &domain.SuiteResult{} |
| | | 129 | | cov := &domain.CoverageData{} |
| | | 130 | | for _, res := range ordered { |
| | | 131 | | if res.skipped { |
| | | 132 | | // Worker skipped this index due to context cancellation; ctx.Err() |
| | | 133 | | // is returned below so callers know the run was aborted. |
| | | 134 | | continue |
| | | 135 | | } |
| | | 136 | | if res.err != nil { |
| | | 137 | | // Record the error as a test failure rather than aborting the run. |
| | | 138 | | suite.Tests = append(suite.Tests, domain.TestResult{ |
| | | 139 | | Name: files[res.idx], |
| | | 140 | | Status: domain.StatusError, |
| | | 141 | | Error: res.err.Error(), |
| | | 142 | | }) |
| | | 143 | | continue |
| | | 144 | | } |
| | | 145 | | suite.Tests = append(suite.Tests, res.suite.Tests...) |
| | | 146 | | if res.cov != nil { |
| | | 147 | | cov.Files = append(cov.Files, res.cov.Files...) |
| | | 148 | | } |
| | | 149 | | } |
| | | 150 | | suite.Duration = time.Since(start) |
| | | 151 | | |
| | | 152 | | // Propagate context cancellation so callers can distinguish "the run was |
| | | 153 | | // aborted" from "all test files failed normally". |
| | | 154 | | if err := ctx.Err(); err != nil { |
| | | 155 | | return suite, cov, err |
| | | 156 | | } |
| | | 157 | | return suite, cov, nil |
| | | 158 | | } |
| | | 159 | | |
| | | 160 | | // runOutput is the JSON structure that the Lua harness writes to stdout. |
| | | 161 | | // The Error field is populated by reporter.lua's pcall guard when the |
| | | 162 | | // serialisation fails; if non-empty, it indicates a harness-level failure |
| | | 163 | | // and parseOutput surfaces it as a Go error rather than silently returning |
| | | 164 | | // an empty suite and coverage. |
| | | 165 | | type runOutput struct { |
| | | 166 | | Tests []testJSON `json:"tests"` |
| | | 167 | | Coverage []coverageJSON `json:"coverage"` |
| | | 168 | | Error string `json:"error,omitempty"` |
| | | 169 | | } |
| | | 170 | | |
| | | 171 | | type testJSON struct { |
| | | 172 | | Name string `json:"name"` |
| | | 173 | | Status string `json:"status"` |
| | | 174 | | DurationMs float64 `json:"duration_ms"` |
| | | 175 | | Output string `json:"output"` |
| | | 176 | | Error string `json:"error"` |
| | | 177 | | } |
| | | 178 | | |
| | | 179 | | // coverageLines is map[string]int that gracefully handles the case where the |
| | | 180 | | // Lua reporter emits an empty JSON array ("[]") instead of an empty JSON object |
| | | 181 | | // ("{}"). Lua's built-in table encoder has no way to distinguish between an |
| | | 182 | | // empty array and an empty object; rather than crash, we treat "[]" as no data. |
| | | 183 | | type coverageLines map[string]int |
| | | 184 | | |
| | 8 | 185 | | func (cl *coverageLines) UnmarshalJSON(data []byte) error { |
| | 1 | 186 | | if bytes.Equal(bytes.TrimSpace(data), []byte("[]")) { |
| | 1 | 187 | | *cl = nil // empty array → treat as no coverage data |
| | 1 | 188 | | return nil |
| | 1 | 189 | | } |
| | 7 | 190 | | var m map[string]int |
| | 0 | 191 | | if err := json.Unmarshal(data, &m); err != nil { |
| | 0 | 192 | | return err |
| | 0 | 193 | | } |
| | 7 | 194 | | *cl = m |
| | 7 | 195 | | return nil |
| | | 196 | | } |
| | | 197 | | |
| | | 198 | | type coverageJSON struct { |
| | | 199 | | Path string `json:"path"` |
| | | 200 | | Lines coverageLines `json:"lines"` |
| | | 201 | | } |
| | | 202 | | |
| | | 203 | | // runOne executes a single test file in a fresh sandbox. |
| | | 204 | | func (r *Runner) runOne(ctx context.Context, testFile string) (suite *domain.SuiteResult, cov *domain.CoverageData, retE |
| | | 205 | | sb, err := r.sandboxF.Create(ctx) |
| | | 206 | | if err != nil { |
| | | 207 | | return nil, nil, fmt.Errorf("creating sandbox: %w", err) |
| | | 208 | | } |
| | | 209 | | // Join any close error into retErr so temp-dir cleanup failures surface |
| | | 210 | | // as visible errors rather than being silently discarded. |
| | | 211 | | defer func() { |
| | | 212 | | if cerr := sb.Close(); cerr != nil { |
| | | 213 | | retErr = errors.Join(retErr, fmt.Errorf("closing sandbox: %w", cerr)) |
| | | 214 | | } |
| | | 215 | | }() |
| | | 216 | | |
| | | 217 | | // Write the combined harness+hook Lua shim into the sandbox. |
| | | 218 | | shimPath := filepath.Join(sb.Dir(), "neospec_run.lua") |
| | | 219 | | shim, err := buildShim(testFile, r.initFile, r.coverageInclude) |
| | | 220 | | if err != nil { |
| | | 221 | | return nil, nil, fmt.Errorf("building shim: %w", err) |
| | | 222 | | } |
| | | 223 | | if err := os.WriteFile(shimPath, shim, 0o644); err != nil { |
| | | 224 | | return nil, nil, fmt.Errorf("writing shim: %w", err) |
| | | 225 | | } |
| | | 226 | | |
| | | 227 | | args := []string{"--headless", "-l", shimPath} |
| | | 228 | | if r.verbose { |
| | | 229 | | args = append([]string{"-V3"}, args...) |
| | | 230 | | } |
| | | 231 | | |
| | | 232 | | stdout, stderr, err := r.exec.Run(ctx, sb.Env(), r.nvimPath, args...) |
| | | 233 | | if err != nil { |
| | | 234 | | return nil, nil, fmt.Errorf("nvim exited with error: %w (stderr: %.500s)", err, stderr) |
| | | 235 | | } |
| | | 236 | | |
| | | 237 | | suite, cov, retErr = parseOutput(stdout) |
| | | 238 | | return |
| | | 239 | | } |
| | | 240 | | |
| | | 241 | | // parseOutput decodes the JSON emitted by the Lua harness. |
| | | 242 | | func parseOutput(data []byte) (*domain.SuiteResult, *domain.CoverageData, error) { |
| | | 243 | | var out runOutput |
| | | 244 | | if err := json.Unmarshal(data, &out); err != nil { |
| | | 245 | | return nil, nil, fmt.Errorf("parsing harness output: %w (raw: %.200s)", err, string(data)) |
| | | 246 | | } |
| | | 247 | | if out.Error != "" { |
| | | 248 | | return nil, nil, fmt.Errorf("lua reporter error: %s", out.Error) |
| | | 249 | | } |
| | | 250 | | |
| | | 251 | | suite := &domain.SuiteResult{} |
| | | 252 | | for _, t := range out.Tests { |
| | | 253 | | suite.Tests = append(suite.Tests, domain.TestResult{ |
| | | 254 | | Name: t.Name, |
| | | 255 | | Status: parseStatus(t.Status), |
| | | 256 | | Duration: time.Duration(t.DurationMs * float64(time.Millisecond)), |
| | | 257 | | Output: t.Output, |
| | | 258 | | Error: t.Error, |
| | | 259 | | }) |
| | | 260 | | } |
| | | 261 | | |
| | | 262 | | cov := &domain.CoverageData{} |
| | | 263 | | for _, fc := range out.Coverage { |
| | | 264 | | if len(fc.Lines) == 0 { |
| | | 265 | | // Skip entries with no line data — the Lua reporter may emit |
| | | 266 | | // "lines":[] for files that were loaded but had no recorded hits. |
| | | 267 | | continue |
| | | 268 | | } |
| | | 269 | | fileCov := &domain.FileCoverage{ |
| | | 270 | | Path: fc.Path, |
| | | 271 | | Lines: make(map[int]int, len(fc.Lines)), |
| | | 272 | | } |
| | | 273 | | for lineStr, count := range fc.Lines { |
| | | 274 | | lineNo, err := strconv.Atoi(lineStr) |
| | | 275 | | if err != nil { |
| | | 276 | | return nil, nil, fmt.Errorf("coverage file %q: invalid line key %q: %w", fc.Path, lineStr, err) |
| | | 277 | | } |
| | | 278 | | fileCov.Lines[lineNo] = count |
| | | 279 | | } |
| | | 280 | | cov.Files = append(cov.Files, fileCov) |
| | | 281 | | } |
| | | 282 | | |
| | | 283 | | return suite, cov, nil |
| | | 284 | | } |
| | | 285 | | |
| | | 286 | | func parseStatus(s string) domain.TestStatus { |
| | | 287 | | switch s { |
| | | 288 | | case "pass": |
| | | 289 | | return domain.StatusPass |
| | | 290 | | case "fail": |
| | | 291 | | return domain.StatusFail |
| | | 292 | | case "skip": |
| | | 293 | | return domain.StatusSkip |
| | | 294 | | default: |
| | | 295 | | return domain.StatusError |
| | | 296 | | } |
| | | 297 | | } |