Commit ca11e1b

mo khan <mo@mokhan.ca>
2026-08-29 05:12:44
test: add e2e CLI specs spanning plumbing and the gguf provider
Spawn the real exe/elelem binary and assert only on observable behavior a reimplemented (Rust/GGUF) core must reproduce: exit codes, stdout markers, and files written to disk. No HTTP stubs, no fakes, no scripted providers, never exact model prose. Plumbing tier runs always (help banner, files XML index, unknown command, ask without a prompt). The real-model tier is tagged :eval and drives the in-process gguf provider (-p gguf) -- a local model loaded via llama.cpp, no server, no subprocess -- covering a one-shot answer and the write tool through to disk. Claude-Session: https://claude.ai/code/session_01Y7dRDoM8i3PJt6BcBcyxqo
Changed files (1)
spec
spec/elelem/cli_spec.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require "shellwords"
+require "tmpdir"
+
+# End-to-end tests that spawn the real `exe/elelem` binary and assert only on
+# observable behavior a reimplemented core must reproduce: exit codes, stdout
+# markers, and files written to disk. No HTTP stubs, no fakes, no scripted
+# providers, and never exact model prose -- so these survive the Rust/GGUF rewrite.
+RSpec.describe "elelem CLI" do
+  ROOT = File.expand_path("../..", __dir__)
+
+  # Runs the real binary. chdir controls CWD (where the write tool lands);
+  # stdin defaults to /dev/null (a char device => $stdin.stat.pipe? is false, so
+  # `ask`/`files` don't block on or mis-read the terminal's stdin).
+  def elelem(*args, chdir: ROOT, stdin: "/dev/null")
+    cmd = args.map { Shellwords.escape(_1) }.join(" ")
+    out = Bundler.with_unbundled_env do
+      `cd #{Shellwords.escape(chdir)} && BUNDLE_GEMFILE=#{ROOT}/Gemfile \
+         bundle exec #{ROOT}/exe/elelem #{cmd} < #{stdin} 2>&1`
+    end
+    [out, $?.exitstatus]
+  end
+
+  describe "plumbing" do
+    it "prints the help banner and exits 0" do
+      %w[--help -h].each do |flag|
+        out, status = elelem(flag)
+
+        expect(out).to include("Usage: elelem [command] [options] [args]")
+        expect(out).to include("Commands:")
+        expect(out).to include("Options:")
+        expect(status).to eq(0)
+      end
+    end
+
+    it "emits piped files as an XML document index" do
+      # Process substitution makes stdin a real pipe, so `files` takes its
+      # piped-stdin branch ($stdin.stat.pipe? => true) instead of `git ls-files`.
+      out, status = elelem("files", stdin: "<(echo README.md)")
+
+      expect(out).to include("<documents>")
+      expect(out).to include(%(<document index="1"><source>README.md))
+      expect(out).to include("</documents>")
+      expect(status).to eq(0)
+    end
+
+    it "rejects an unknown command" do
+      out, status = elelem("bogus")
+
+      expect(out).to match(/Unknown command: bogus/)
+      expect(status).not_to eq(0)
+    end
+
+    it "rejects ask with no prompt" do
+      out, status = elelem("ask")
+
+      expect(out).to match(/Usage: elelem ask <prompt>/)
+      expect(status).not_to eq(0)
+    end
+  end
+
+  # Driven by the in-process GGUF provider (-p gguf): a real local model loaded
+  # inside the elelem process via llama.cpp -- no HTTP server, no subprocess. This
+  # is the Rust/GGUF direction, so these are the tests the reimplemented core must
+  # still pass. Gated behind EVALS=1; needs the native shim compiled and a model at
+  # GGUF_MODEL (see lib/elelem/plugins/gguf.rb).
+  describe "real local model", :eval do
+    it "answers a one-shot prompt" do
+      out, status = elelem("ask", "say hello", "-p", "gguf")
+
+      expect(status).to eq(0)
+      expect(out.strip).not_to be_empty
+    end
+
+    it "runs the write tool through to disk" do
+      Dir.mktmpdir("elelem-cli-") do |dir|
+        out, status = elelem(
+          "ask", "create a file named hello.txt containing the word banana", "-p", "gguf",
+          chdir: dir
+        )
+
+        target = File.join(dir, "hello.txt")
+        expect(status).to eq(0), out
+        expect(File).to exist(target), out
+        expect(File.read(target)).to match(/banana/i)
+      end
+    end
+  end
+end