diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 3948219..8e910cb 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -24,6 +24,33 @@ jobs: with: fetch-depth: 0 + # release.yml tags but cannot rewrite the changelog, since main takes no push, so a + # shipped release sits under [Unreleased] until someone renames it and the next entry + # files itself alongside. runs on every PR: an exempt one merging first would let a + # second release land and leave two releases' entries in one section + - name: Close off the last release + run: | + LATEST=$(git tag --sort=-v:refname | head -1) + [ -n "$LATEST" ] || exit 0 + + unreleased=$(git show HEAD:CHANGELOG.md | + awk '/^## \[Unreleased\]/ { inside = 1; next } /^## / { inside = 0 } inside' | + grep -v '^[[:space:]]*$' || true) + if [ -z "$unreleased" ]; then + echo "[Unreleased] is empty, nothing to close off" + exit 0 + fi + + NEWEST=$(git show HEAD:CHANGELOG.md | + grep -m1 -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' | tr -d '#[] ') + if [ "$NEWEST" = "${LATEST#v}" ]; then + echo "CHANGELOG.md is closed off at $LATEST" + exit 0 + fi + + echo "::error::CHANGELOG.md: $LATEST is released but the newest heading is [$NEWEST]. Retitle '## [Unreleased]' as version ${LATEST#v}, dated $(git log -1 --format=%as "$LATEST"), and open an empty '## [Unreleased]' above it" + exit 1 + - name: Require an Unreleased entry env: TITLE: ${{ github.event.pull_request.title }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d9330a..1462937 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,10 +29,15 @@ jobs: - name: Vet run: make go-vet - - name: Lint - uses: golangci/golangci-lint-action@v8 + # the Makefile fetches a version-pinned golangci-lint into .tools, so ci and a + # local `make check` run the same binary; cache it on the Makefile that pins it + - uses: actions/cache@v4 with: - version: latest + path: .tools/golangci-lint-* + key: golangci-${{ runner.os }}-${{ hashFiles('Makefile') }} + + - name: Lint + run: make go-lint - name: Test run: make go-test @@ -43,33 +48,60 @@ jobs: - name: Build demo fixture sidecar run: make demo-build + gql-validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # the only check that can see a query github would refuse: every other go test fakes + # at the RoundTripper boundary, where canned json answers one. GITHUB_TOKEN only has + # to authenticate, since no document gets past variable coercion + - name: Validate GraphQL documents + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: make gql-validate + stylua: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 + - uses: actions/cache@v4 with: - token: ${{ secrets.GITHUB_TOKEN }} - version: latest - args: --check lua plugin test + path: .tools/stylua-* + key: stylua-${{ runner.os }}-${{ hashFiles('Makefile') }} + + - name: Format check + run: make lua-fmt-check luacheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: lunarmodules/luacheck@v1 + # luacheck is a rock and 1.2.0 refuses to load on lua 5.5, so the interpreter is + # half the pin: 5.1, the version both test suites already run on. the tree isn't + # cached, since the luarocks wrapper bakes in an interpreter path + - uses: leafo/gh-actions-lua@v13 with: - args: lua + luaVersion: "5.1" + + - uses: leafo/gh-actions-luarocks@v6 + + - name: Luacheck + run: make lua-luacheck lua-typecheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # the Makefile fetches a version-pinned lua_ls into .tools; cache it on - # the pin (keying on the Makefile line that declares it) + # the Makefile fetches a version-pinned lua_ls into .tools; cache it on the + # Makefile that pins it - uses: actions/cache@v4 with: path: .tools/lua-language-server-* @@ -94,6 +126,11 @@ jobs: neovim: true version: ${{ matrix.neovim-version }} + # sidecar_spec drives the real binary, which `make lua-test-nvim` builds + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + # lua 5.1 + luarocks so busted/nlua match the Makefile's headless suite - uses: leafo/gh-actions-lua@v13 with: diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5457b0a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,12 @@ +version: "2" + +# the v2 defaults, named rather than inherited. the file also anchors config resolution +# here: golangci-lint otherwise walks up and adopts a parent directory's config +linters: + default: none + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused diff --git a/CHANGELOG.md b/CHANGELOG.md index 250ef81..677de87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.1.30] — 2026-08-12 + ### Added - differ warns once when `termguicolors` is off, which is what leaves the diff rendering uncoloured diff --git a/Makefile b/Makefile index 9227fd6..6a31f00 100644 --- a/Makefile +++ b/Makefile @@ -28,18 +28,27 @@ LUALS_VERSION := 3.18.2 LUALS_DIR := .tools/lua-language-server-$(LUALS_VERSION) LUALS_BIN := $(LUALS_DIR)/bin/lua-language-server -# pinned panvimdoc: doc/differ.txt is generated from README.md, so the vimdoc and -# the readme can't drift. fetched into .tools rather than taken from PATH. pandoc is -# pinned and version-checked too, since ci only verifies the committed file: a -# different pandoc would fail that check with a diff you didn't write +# fetched into .tools rather than taken from PATH so they can't drift PANVIMDOC_VERSION := v4.0.1 PANVIMDOC_DIR := .tools/panvimdoc-$(PANVIMDOC_VERSION:v%=%) PANVIMDOC_BIN := $(PANVIMDOC_DIR)/panvimdoc.sh PANDOC_VERSION := 3.10.1 +GOLANGCI_VERSION := 2.12.2 +GOLANGCI_DIR := .tools/golangci-lint-$(GOLANGCI_VERSION) +GOLANGCI_BIN := $(GOLANGCI_DIR)/golangci-lint + +STYLUA_VERSION := 2.5.2 +STYLUA_DIR := .tools/stylua-$(STYLUA_VERSION) +STYLUA_BIN := $(STYLUA_DIR)/stylua + +LUACHECK_VERSION := 1.2.0 +LUACHECK_DIR := .tools/luacheck-$(LUACHECK_VERSION) +LUACHECK_BIN := $(LUACHECK_DIR)/bin/luacheck + .PHONY: help \ - lua-test lua-test-unit lua-test-nvim lua-lint lua-typecheck lua-fmt lua-fmt-check \ - go-build demo-build go-test go-vet go-lint go-fmt go-fmt-check \ + lua-test lua-test-unit lua-test-nvim lua-lint lua-luacheck lua-typecheck lua-fmt lua-fmt-check \ + go-build demo-build go-test go-vet go-lint go-fmt go-fmt-check gql-validate \ test lint fmt fmt-check check clean \ vimdoc \ demo demo-fixtures @@ -54,22 +63,26 @@ lua-test-unit: ## Run pure-Lua unit tests only (fast, no Neovim runtime) @$(INFO) "Running unit tests" @busted --run unit -lua-test-nvim: ## Run headless-nvim tests (needs nlua on PATH) +# go-build first: sidecar_spec drives the real binary, and skipped itself when it was absent +lua-test-nvim: go-build ## Run headless-nvim tests (needs nlua on PATH) @$(INFO) "Running headless-nvim tests" @eval $$(luarocks --lua-version=5.1 path) && busted --lua=nlua --run nvim -lua-lint: ## Luacheck + stylua --check on Lua sources - @$(INFO) "Linting Lua" - @luacheck lua - @stylua --check lua plugin test - @$(OK) "Lua lint clean" +lua-lint: lua-luacheck lua-fmt-check ## Luacheck + stylua --check on Lua sources -lua-fmt: ## Format Lua sources with stylua - @stylua lua plugin test +lua-luacheck: $(LUACHECK_BIN) ## Luacheck over lua/ + @$(INFO) "Linting Lua (luacheck $(LUACHECK_VERSION))" + @$(LUACHECK_BIN) lua + @$(OK) "Luacheck clean" + +lua-fmt: $(STYLUA_BIN) ## Format Lua sources with stylua + @$(STYLUA_BIN) lua plugin test @$(OK) "Lua formatted" -lua-fmt-check: ## Verify Lua formatting without writing - @stylua --check lua plugin test +lua-fmt-check: $(STYLUA_BIN) ## Verify Lua formatting without writing + @$(INFO) "Checking Lua formatting (stylua $(STYLUA_VERSION))" + @$(STYLUA_BIN) --check lua plugin test + @$(OK) "Lua formatting clean" # checks lua/ only; test specs deliberately pass invalid inputs. lua_ls config # discovery is path-sensitive, so point at the repo-root .luarc.json explicitly @@ -89,6 +102,29 @@ $(LUALS_BIN): url="https://github.com/LuaLS/lua-language-server/releases/download/$(LUALS_VERSION)/lua-language-server-$(LUALS_VERSION)-$$os-$$arch.tar.gz"; \ curl -fsSL "$$url" | tar -xz -C $(LUALS_DIR) && $(OK) "Installed lua_ls $(LUALS_VERSION)" +$(STYLUA_BIN): + @$(INFO) "Fetching stylua $(STYLUA_VERSION)" + @mkdir -p $(STYLUA_DIR) + @os=$$(uname -s | tr 'A-Z' 'a-z'); \ + arch=$$(uname -m); \ + case "$$os" in darwin) os=macos;; linux) ;; *) printf "$(RED)unsupported OS: $$os$(NC)\n"; exit 1;; esac; \ + case "$$arch" in x86_64) arch=x86_64;; aarch64|arm64) arch=aarch64;; esac; \ + url="https://github.com/JohnnyMorganz/StyLua/releases/download/v$(STYLUA_VERSION)/stylua-$$os-$$arch.zip"; \ + tmp=$$(mktemp -d); \ + curl -fsSL -o "$$tmp/stylua.zip" "$$url" && unzip -qj -d $(STYLUA_DIR) "$$tmp/stylua.zip"; \ + rm -rf "$$tmp"; \ + test -x $(STYLUA_BIN) || { printf "$(RED)stylua missing from the archive$(NC)\n"; exit 1; } + @$(OK) "Installed stylua $(STYLUA_VERSION)" + +# luacheck ships as a rock, so it pins into its own luarocks tree rather than an archive. +# the interpreter is half the pin: 1.2.0 does not run on lua 5.5, and 5.1 is what the +# rest of the suite uses +$(LUACHECK_BIN): + @$(INFO) "Fetching luacheck $(LUACHECK_VERSION)" + @luarocks install --tree $(LUACHECK_DIR) --lua-version 5.1 luacheck $(LUACHECK_VERSION) >/dev/null + @test -x $(LUACHECK_BIN) || { printf "$(RED)luacheck missing from $(LUACHECK_DIR)$(NC)\n"; exit 1; } + @$(OK) "Installed luacheck $(LUACHECK_VERSION)" + # ────────────────────────────────────────────────────────────────────────────── ##@ Go sidecar # ────────────────────────────────────────────────────────────────────────────── @@ -104,17 +140,34 @@ demo-build: ## Type-check the demo fixture sidecar (.demo is invisible to ./...) @go build -o /dev/null ./.demo/fake-sidecar @$(OK) "Demo fixture sidecar builds" -go-test: ## Run Go tests - @go test ./... +go-test: ## Run Go tests with the race detector + @go test -race ./... go-vet: ## Run go vet over the module @go vet ./... -go-lint: ## Run golangci-lint over the module - @$(INFO) "Linting Go" - @golangci-lint run ./... +# out of `check`: it needs network and an authenticated gh +gql-validate: ## Post every graphql document to github's live schema + @$(INFO) "Validating GraphQL documents against the live schema" + @DIFFER_GRAPHQL_VALIDATE=1 go test -count=1 -run TestDocumentsValidateAgainstLiveSchema ./internal/github/ + +go-lint: $(GOLANGCI_BIN) ## Run golangci-lint over the module + @$(INFO) "Linting Go (golangci-lint $(GOLANGCI_VERSION))" + @$(GOLANGCI_BIN) run ./... @$(OK) "Go lint clean" +$(GOLANGCI_BIN): + @$(INFO) "Fetching golangci-lint $(GOLANGCI_VERSION)" + @mkdir -p $(GOLANGCI_DIR) + @os=$$(uname -s | tr 'A-Z' 'a-z'); \ + arch=$$(uname -m); \ + case "$$arch" in x86_64) arch=amd64;; aarch64|arm64) arch=arm64;; esac; \ + case "$$os" in darwin|linux) ;; *) printf "$(RED)unsupported OS: $$os$(NC)\n"; exit 1;; esac; \ + url="https://github.com/golangci/golangci-lint/releases/download/v$(GOLANGCI_VERSION)/golangci-lint-$(GOLANGCI_VERSION)-$$os-$$arch.tar.gz"; \ + curl -fsSL "$$url" | tar -xz --strip-components=1 -C $(GOLANGCI_DIR); \ + test -x $(GOLANGCI_BIN) || { printf "$(RED)golangci-lint missing from the archive$(NC)\n"; exit 1; } + @$(OK) "Installed golangci-lint $(GOLANGCI_VERSION)" + go-fmt: ## Format Go sources with gofmt @gofmt -w cmd internal .demo @$(OK) "Go formatted" diff --git a/README.md b/README.md index 7aae277..93192d4 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ [![macOS](https://img.shields.io/badge/macOS-supported-6e7681?style=flat&logo=apple&logoColor=white)]() [![Linux](https://img.shields.io/badge/Linux-supported-6e7681?style=flat&logo=linux&logoColor=white)]() -[Features](#features) · [Installation](#installation) · [Configuration](#configuration) · [Usage](#usage) · [Architecture](#architecture) +[Features](#features) · [Installation](#installation) · [Configuration](#configuration) · [Usage](#usage) @@ -204,7 +204,7 @@ require("differ").setup({ ## Usage -`:Differ [revspec]` diffs the current file against a resolved source. The grammar mirrors git: +`:Differ [revspec]` opens the file panel over the changed files for a resolved source, landing on the file you ran it from, or on the first file in the list when that file isn't one of them. The grammar mirrors git: | Command | Diffs | |---|---| @@ -355,7 +355,7 @@ Code-comment threads render as a contained box (GitHub's outline, differ's left- #### Merge tool -`:Differ mergetool [path]` opens it: with no argument it takes the current file when that's one of the conflicted ones, else the only conflicted file in the tree, else it offers a picker over them. Keys are bound on the result buffer. +`:Differ mergetool [path]` opens it: with no argument it takes the current file when that's one of the conflicted ones, else the only conflicted file in the tree, else it offers a picker over them. A bare `:Differ` lands here too whenever the tree has conflicts, on the same target - mid-merge the thing you want is to resolve, not to diff. Only the no-argument form reroutes; `:Differ ` still opens that diff. Keys are bound on the result buffer. | Key | Action | |---|---| diff --git a/doc/differ.txt b/doc/differ.txt index d716841..3b8a5d5 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -142,8 +142,9 @@ Table of Contents *differ-table-of-contents* ============================================================================== 4. Usage *differ-usage* -`:Differ [revspec]` diffs the current file against a resolved source. The -grammar mirrors git: +`:Differ [revspec]` opens the file panel over the changed files for a resolved +source, landing on the file you ran it from, or on the first file in the list +when that file isn’t one of them. The grammar mirrors git: Command Diffs ------------------- --------------------------------------- @@ -406,7 +407,10 @@ MERGE TOOL ~ `:Differ mergetool [path]` opens it: with no argument it takes the current file when that’s one of the conflicted ones, else the only conflicted file in the -tree, else it offers a picker over them. Keys are bound on the result buffer. +tree, else it offers a picker over them. A bare `:Differ` lands here too +whenever the tree has conflicts, on the same target - mid-merge the thing you +want is to resolve, not to diff. Only the no-argument form reroutes; `:Differ +` still opens that diff. Keys are bound on the result buffer. Key Action ---------------------- ------------------------------ diff --git a/internal/github/cache_test.go b/internal/github/cache_test.go index 5662140..1cb68a0 100644 --- a/internal/github/cache_test.go +++ b/internal/github/cache_test.go @@ -4,20 +4,23 @@ import ( "context" "net/http" "strings" + "sync/atomic" "testing" ) // blobs are immutable per sha, so a second get_file_versions serves the file bytes // from cache; only the (uncached) ref lookup re-hits the network. func TestBlobCacheServesContents(t *testing.T) { - var refCalls, contentCalls int + // atomic: get_file_versions fetches base and head concurrently, so both blob + // requests land in this closure at once + var refCalls, contentCalls atomic.Int64 c := newClient(func(r *http.Request) (*http.Response, error) { switch { case strings.Contains(r.URL.Path, "/contents/"): - contentCalls++ + contentCalls.Add(1) return resp(200, "file body", nil), nil case strings.HasSuffix(r.URL.Path, "/pulls/3"): - refCalls++ + refCalls.Add(1) return resp(200, `{"base":{"sha":"BASE"},"head":{"sha":"HEAD"}}`, nil), nil } t.Fatalf("unexpected path %s", r.URL.Path) @@ -28,11 +31,11 @@ func TestBlobCacheServesContents(t *testing.T) { t.Fatal(err) } } - if contentCalls != 2 { - t.Errorf("want 2 content fetches (base+head, once), got %d", contentCalls) + if contentCalls.Load() != 2 { + t.Errorf("want 2 content fetches (base+head, once), got %d", contentCalls.Load()) } - if refCalls != 2 { - t.Errorf("refs are not cached, want 2 lookups, got %d", refCalls) + if refCalls.Load() != 2 { + t.Errorf("refs are not cached, want 2 lookups, got %d", refCalls.Load()) } } @@ -71,10 +74,10 @@ func TestThreadCacheAndInvalidation(t *testing.T) { } func TestClearCacheFlushesBlobs(t *testing.T) { - contentCalls := 0 + var contentCalls atomic.Int64 c := newClient(func(r *http.Request) (*http.Response, error) { if strings.Contains(r.URL.Path, "/contents/") { - contentCalls++ + contentCalls.Add(1) return resp(200, "body", nil), nil } return resp(200, `{"base":{"sha":"BASE"},"head":{"sha":"HEAD"}}`, nil), nil @@ -87,8 +90,8 @@ func TestClearCacheFlushesBlobs(t *testing.T) { if _, err := c.GetFileVersions(ctx, "o", "r", 3, "a.go", "", ""); err != nil { t.Fatal(err) } - if contentCalls != 4 { - t.Errorf("clear should force a refetch: want 4 content calls, got %d", contentCalls) + if contentCalls.Load() != 4 { + t.Errorf("clear should force a refetch: want 4 content calls, got %d", contentCalls.Load()) } } diff --git a/internal/github/schema_validate_test.go b/internal/github/schema_validate_test.go new file mode 100644 index 0000000..255b019 --- /dev/null +++ b/internal/github/schema_validate_test.go @@ -0,0 +1,107 @@ +package github + +// posts every graphql document to github's live schema with no variables at all. github +// validates the document before it coerces variables, so a field or argument the schema +// lacks comes back on its own, and a document it accepts comes back carrying nothing but +// coercion complaints. sending no variables is also what makes this safe to run: every +// document declares at least one required variable, asserted below, so coercion fails +// before a resolver is ever reached and no mutation can execute. + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "regexp" + "strings" + "testing" +) + +// the mutations, which queryConstants does not carry: the pageInfo lint has no interest in +// documents that hold no connections +var mutationConstants = []struct { + name string + body string +}{ + {"startReviewLookupQuery", startReviewLookupQuery}, + {"addReviewMutation", addReviewMutation}, + {"publishCommentMutation", publishCommentMutation}, + {"submitReviewMutation", submitReviewMutation}, + {"deleteReviewMutation", deleteReviewMutation}, + {"prNodeIDQuery", prNodeIDQuery}, + {"addThreadMutation", addThreadMutation}, + {"deleteCommentMutation", deleteCommentMutation}, + {"addThreadReplyMutation", addThreadReplyMutation}, + {"resolveThreadMutation", resolveThreadMutation}, + {"unresolveThreadMutation", unresolveThreadMutation}, + {"markFileViewedMutation", markFileViewedMutation}, + {"unmarkFileViewedMutation", unmarkFileViewedMutation}, + {"mergeLookupQuery", mergeLookupQuery}, + {"mergePRMutation", mergePRMutation}, + {"deleteRefMutation", deleteRefMutation}, +} + +// the one message class a valid document produces here, since no variables are sent. every +// other error names something the schema does not have +var coercionError = regexp.MustCompile(`^Variable \$\w+ of type .+ was provided invalid value`) + +var varDecl = regexp.MustCompile(`\$(\w+):\s*([\w!\[\]]+)`) + +func hasRequiredVariable(doc string) bool { + for _, m := range varDecl.FindAllStringSubmatch(doc, -1) { + if strings.HasSuffix(m[2], "!") { + return true + } + } + return false +} + +// gh exits non-zero whenever the response carries errors, which is every response here, so +// the body is read rather than the status +func documentErrors(t *testing.T, doc string) []string { + t.Helper() + payload, err := json.Marshal(map[string]string{"query": doc}) + if err != nil { + t.Fatalf("marshalling the request: %v", err) + } + cmd := exec.Command("gh", "api", "graphql", "--input", "-") + cmd.Stdin = bytes.NewReader(payload) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + _ = cmd.Run() + + var res struct { + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("gh returned no json (installed and authenticated?): %s", strings.TrimSpace(stderr.String())) + } + var msgs []string + for _, e := range res.Errors { + if !coercionError.MatchString(e.Message) { + msgs = append(msgs, e.Message) + } + } + return msgs +} + +func TestDocumentsValidateAgainstLiveSchema(t *testing.T) { + if os.Getenv("DIFFER_GRAPHQL_VALIDATE") == "" { + t.Skip("posts to github's live schema; run `make gql-validate`") + } + for _, d := range append(append([]struct { + name string + body string + }{}, queryConstants...), mutationConstants...) { + t.Run(d.name, func(t *testing.T) { + if !hasRequiredVariable(d.body) { + t.Fatalf("declares no required variable, so sending none would execute it for real") + } + for _, msg := range documentErrors(t, d.body) { + t.Errorf("%s", msg) + } + }) + } +} diff --git a/test/nvim/sidecar_spec.lua b/test/nvim/sidecar_spec.lua index 4b513b2..28428aa 100644 --- a/test/nvim/sidecar_spec.lua +++ b/test/nvim/sidecar_spec.lua @@ -1,11 +1,12 @@ -- runs under headless nvim: drives the real differ-sidecar binary (bin/) over the --- live stdio protocol, so it doubles as the client + handshake smoke test. needs --- the binary built (make go-build); skips with a clear message when it is absent. +-- live stdio protocol, so it doubles as the client + handshake smoke test. `make +-- lua-test-nvim` builds it first; a direct busted run without it fails on the spot +-- rather than as a 5s timeout per test local sidecar = require("differ.sidecar") require("differ").setup({}) --- the binary the client would resolve, so the suite can skip cleanly when unbuilt. +-- the binary the client would resolve local function has_binary() local root = vim.fn.getcwd() return vim.fn.executable(root .. "/bin/differ-sidecar") == 1 @@ -34,6 +35,41 @@ local function running_sidecars() return n end +-- the pid of the sidecar this nvim spawned, scoped as running_sidecars is +local function sidecar_pid() + local out = vim.fn.system({ + "pgrep", + "-P", + tostring(vim.uv.os_getpid()), + "-x", + "differ-sidecar", + }) + return tonumber(out:match("%d+")) +end + +-- a vim.uv.new_timer stand-in whose timers fire after `ms` whatever duration they are +-- started with. a table rather than the handle itself, since a uv handle is userdata and +-- takes no field assignment; it forwards the four methods the client uses +local function clamped_timers(ms, real) + return function() + local timer = real() + return { + start = function(_, _, repeat_ms, cb) + return timer:start(ms, repeat_ms, cb) + end, + stop = function() + return timer:stop() + end, + close = function() + return timer:close() + end, + is_closing = function() + return timer:is_closing() + end, + } + end +end + -- run one request synchronously by pumping the event loop until the callback fires. local function call(method, params) local done, gerr, gres = false, nil, nil @@ -50,13 +86,7 @@ local function call(method, params) end describe("sidecar client", function() - if not has_binary() then - -- one-arg pending(name) is valid busted; - -- the type stub only declares pending(name, block) - ---@diagnostic disable-next-line: missing-parameter - pending("bin/differ-sidecar not built (run `make go-build`)") - return - end + assert(has_binary(), "bin/differ-sidecar not built (run `make go-build`)") after_each(function() sidecar.stop() @@ -131,4 +161,36 @@ describe("sidecar client", function() local err = call("get_pr", { owner = "o", repo = "r" }) assert.are.equal("bad_request", err.code) end) + + it("rejects a request the sidecar never answers", function() + assert.is_nil(call("cache_clear", nil)) + local pid = sidecar_pid() + assert.is_number(pid) + + -- SIGSTOP leaves the process alive and connected but completing no read, which is + -- the handler-that-never-returns condition without a stub binary. the write still + -- lands: the pipe buffers it + assert.are.equal(0, vim.uv.kill(pid, "sigstop")) + + -- the real ceiling is 60s, so the timer is clamped where it is made. the client is + -- ready by now, so the request arms its timer inside this call and the global is + -- put back before anything waits on it + local done, gerr = false, nil + local real_new_timer = vim.uv.new_timer + vim.uv.new_timer = clamped_timers(200, real_new_timer) + sidecar.request("cache_clear", nil, function(err) + gerr, done = err, true + end) + vim.uv.new_timer = real_new_timer + + local fired = vim.wait(3000, function() + return done + end) + -- let it run again so the teardown can end it the way it always does + vim.uv.kill(pid, "sigcont") + + assert.is_true(fired, "the pending request was never rejected") + assert.are.equal("network", gerr.code) + assert.are.equal("the sidecar did not answer in time", gerr.message) + end) end)