< Summary - Neospec Coverage

Information
Class: coverageLines
Assembly: runner
File(s): /home/runner/work/neospec/neospec/internal/adapters/runner/executor.go
Line coverage
72%
Covered lines: 8
Uncovered lines: 3
Coverable lines: 11
Total lines: 297
Line coverage: 72.7%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
UnmarshalJSON0%0072.73%

File(s)

/home/runner/work/neospec/neospec/internal/adapters/runner/executor.go

#LineLine coverage
 1package runner
 2
 3import (
 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.
 22type 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.
 42func 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.
 55func 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.
 60func (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.
 67func (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.
 165type runOutput struct {
 166  Tests    []testJSON     `json:"tests"`
 167  Coverage []coverageJSON `json:"coverage"`
 168  Error    string         `json:"error,omitempty"`
 169}
 170
 171type 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.
 183type coverageLines map[string]int
 184
 8185func (cl *coverageLines) UnmarshalJSON(data []byte) error {
 1186  if bytes.Equal(bytes.TrimSpace(data), []byte("[]")) {
 1187    *cl = nil // empty array → treat as no coverage data
 1188    return nil
 1189  }
 7190  var m map[string]int
 0191  if err := json.Unmarshal(data, &m); err != nil {
 0192    return err
 0193  }
 7194  *cl = m
 7195  return nil
 196}
 197
 198type 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.
 204func (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.
 242func 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
 286func 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}

Methods/Properties

UnmarshalJSON