From 466045b4c209b27110ac31dcf82d8b8c4e206c9d Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Thu, 29 Jan 2026 10:07:59 +0100 Subject: [PATCH 1/7] test(CLI): add test harness --- clients/cli/Gemfile | 6 ++ clients/cli/Gemfile.lock | 33 +++++++++++ clients/cli/spec/cli_spec.rb | 19 ++++++ clients/cli/spec/spec_helper.rb | 77 +++++++++++++++++++++++++ clients/cli/spec/support/mock_server.ru | 14 +++++ 5 files changed, 149 insertions(+) create mode 100644 clients/cli/Gemfile create mode 100644 clients/cli/Gemfile.lock create mode 100644 clients/cli/spec/cli_spec.rb create mode 100644 clients/cli/spec/spec_helper.rb create mode 100644 clients/cli/spec/support/mock_server.ru diff --git a/clients/cli/Gemfile b/clients/cli/Gemfile new file mode 100644 index 00000000..9edb610b --- /dev/null +++ b/clients/cli/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "rspec" +gem "rack" +gem "rackup" +gem "webrick" diff --git a/clients/cli/Gemfile.lock b/clients/cli/Gemfile.lock new file mode 100644 index 00000000..29d581b9 --- /dev/null +++ b/clients/cli/Gemfile.lock @@ -0,0 +1,33 @@ +GEM + remote: https://rubygems.org/ + specs: + diff-lcs (1.6.2) + rack (3.2.4) + rackup (2.3.1) + rack (>= 3) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.7) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + webrick (1.9.2) + +PLATFORMS + x86_64-linux + +DEPENDENCIES + rack + rackup + rspec + webrick + +BUNDLED WITH + 2.4.12 diff --git a/clients/cli/spec/cli_spec.rb b/clients/cli/spec/cli_spec.rb new file mode 100644 index 00000000..f05e61fc --- /dev/null +++ b/clients/cli/spec/cli_spec.rb @@ -0,0 +1,19 @@ +require "open3" +require "spec_helper" + +RSpec.describe "General behavior" do + it "info prints the version" do + r = run_cli("info") + expect(r[:exit_code]).to eq(0) + expect(r[:stdout]).to include("Phrase Strings client version") + expect(r[:stderr]).to include("You're running a development version") + end + + it "handles server 500s cleanly" do + # Example: pass a flag so your CLI requests /status/500, if it supports it. + # Otherwise adapt your rack app to respond 500 for the request your CLI already makes. + r = run_cli("call", "/status/500") + expect(r[:exit_code]).not_to eq(0) + expect(r[:stderr]).to match(/500|error/i) + end +end diff --git a/clients/cli/spec/spec_helper.rb b/clients/cli/spec/spec_helper.rb new file mode 100644 index 00000000..2fd54af7 --- /dev/null +++ b/clients/cli/spec/spec_helper.rb @@ -0,0 +1,77 @@ +require "rspec" +require "open3" +require "socket" +require "timeout" + +def free_port + s = TCPServer.new("127.0.0.1", 0) + port = s.addr[1] + s.close + port +end + +RSpec.configure do |config| + config.before(:suite) do + @mock_port = free_port + @mock_url = "http://127.0.0.1:#{@mock_port}" + puts "Starting mock server at #{@mock_url}" + + ru_path = File.expand_path("support/mock_server.ru", __dir__) + + # Start rackup in background + @rack_stdin, @rack_stdout, @rack_stderr, @rack_wait_thr = + Open3.popen3( + "bundle", "exec", "rackup", + "--host", "127.0.0.1", + "--port", @mock_port.to_s, + ru_path + ) + + # Wait until the server is accepting connections + Timeout.timeout(5) do + loop do + begin + TCPSocket.new("127.0.0.1", @mock_port).close + break + rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH + sleep 0.05 + end + end + end + + # Expose to CLI; adapt env var name to whatever your binary uses. + ENV["BASE_URL"] = @mock_url + end + + config.after(:suite) do + ENV.delete("BASE_URL") + + if @rack_wait_thr&.alive? + Process.kill("TERM", @rack_wait_thr.pid) + begin + Timeout.timeout(2) { @rack_wait_thr.join } + rescue Timeout::Error + Process.kill("KILL", @rack_wait_thr.pid) + end + end + + [@rack_stdin, @rack_stdout, @rack_stderr].compact.each do |io| + io.close rescue nil + end + end +end + +# Point this to your actual binary (relative to repo root, or absolute path) +CLI_PATH = File.expand_path("../phrase-cli", __dir__) +# e.g. if it’s built somewhere: File.expand_path("../../target/release/my_cli", __dir__) + +def run_cli(*args, env: {}) + full_env = { + # Whatever your CLI reads to find the server: + # BASE_URL is set in spec_helper, but you can override per test via env: + "BASE_URL" => ENV.fetch("BASE_URL") + }.merge(env) + + stdout, stderr, status = Open3.capture3(full_env, CLI_PATH, *args.map(&:to_s)) + { stdout: stdout, stderr: stderr, status: status, exit_code: status.exitstatus } +end diff --git a/clients/cli/spec/support/mock_server.ru b/clients/cli/spec/support/mock_server.ru new file mode 100644 index 00000000..33ce9dd7 --- /dev/null +++ b/clients/cli/spec/support/mock_server.ru @@ -0,0 +1,14 @@ +require "json" + +run lambda { |env| + path = env["PATH_INFO"] + + case path + when "/ping" + [200, { "Content-Type" => "application/json" }, [JSON.dump({ message: "pong" })]] + when "/status/500" + [500, { "Content-Type" => "application/json" }, [JSON.dump({ error: "boom" })]] + else + [404, { "Content-Type" => "application/json" }, [JSON.dump({ error: "not found" })]] + end +} From b3b9aa307904f0705eea1981f90cf7127805627c Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Thu, 29 Jan 2026 10:46:47 +0100 Subject: [PATCH 2/7] call rspec in github action --- .github/workflows/test-cli.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index 91d226ee..9e1eb670 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -17,7 +17,7 @@ jobs: uses: actions/setup-go@v2 with: go-version: '1.24.4' - - name: Generate CLI and run tests + - name: Generate CLI and run unit tests env: GOPRIVATE: github.com/phrase/phrase-go run: | @@ -28,6 +28,15 @@ jobs: npm run generate.cli go build . go test -v ./... + - name: Install Ruby and rspec + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + - name: Run integration tests + run: | + cd ./clients/cli + bundle exec rspec - name: License check uses: phrase/actions/lawa-ci@v1 with: From 786f97c2a988d945ac0a992bc655e14195eca942 Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Thu, 29 Jan 2026 10:50:33 +0100 Subject: [PATCH 3/7] specify directory --- .github/workflows/test-cli.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index 9e1eb670..454a1117 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -33,6 +33,7 @@ jobs: with: ruby-version: '3.2' bundler-cache: true + working-directory: ./clients/cli - name: Run integration tests run: | cd ./clients/cli From 9400ee26a6d0366e943ff19e8427c3d2184eaee7 Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Fri, 30 Jan 2026 15:14:18 +0100 Subject: [PATCH 4/7] config, request assertion --- clients/cli/spec/cli_spec.rb | 44 +++++++++-- clients/cli/spec/spec_helper.rb | 22 ++++-- clients/cli/spec/support/mock_control.rb | 52 +++++++++++++ clients/cli/spec/support/mock_server.ru | 97 ++++++++++++++++++++++-- 4 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 clients/cli/spec/support/mock_control.rb diff --git a/clients/cli/spec/cli_spec.rb b/clients/cli/spec/cli_spec.rb index f05e61fc..3c0fb6f3 100644 --- a/clients/cli/spec/cli_spec.rb +++ b/clients/cli/spec/cli_spec.rb @@ -2,6 +2,13 @@ require "spec_helper" RSpec.describe "General behavior" do + let(:config) { <<~YAML } + phrase: + host: #{ENV.fetch("BASE_URL")} + project_id: "test-project" + access_token: "test-token" + YAML + it "info prints the version" do r = run_cli("info") expect(r[:exit_code]).to eq(0) @@ -9,11 +16,36 @@ expect(r[:stderr]).to include("You're running a development version") end - it "handles server 500s cleanly" do - # Example: pass a flag so your CLI requests /status/500, if it supports it. - # Otherwise adapt your rack app to respond 500 for the request your CLI already makes. - r = run_cli("call", "/status/500") - expect(r[:exit_code]).not_to eq(0) - expect(r[:stderr]).to match(/500|error/i) + describe "format commands" do + before do + mock_set!("GET", "/formats", status: 200, body: [ + { + name: "Ruby/Rails YAML", + api_name: "yml", + description: "YAML file format for use with Ruby/Rails applications", + extension: "yml", + default_encoding: "UTF-8", + importable: true, + exportable: true, + default_file: "./config/locales/.yml", + renders_default_locale: false, + includes_locale_information: true + } + ]) + end + + it "gets the format list" do + r = run_cli("formats", "list", config:) + expect(r[:exit_code]).to eq(0) + expect(r[:stdout]).to include("Ruby/Rails YAML") + + requests_made = mock_requests + expect(requests_made.length).to eq(1) + + request = requests_made.first + expect(request["method"]).to eq("GET") + expect(request["path"]).to eq("/formats") + expect(request["headers"]["HTTP_AUTHORIZATION"]).to eq("token test-token") + end end end diff --git a/clients/cli/spec/spec_helper.rb b/clients/cli/spec/spec_helper.rb index 2fd54af7..a5876611 100644 --- a/clients/cli/spec/spec_helper.rb +++ b/clients/cli/spec/spec_helper.rb @@ -2,6 +2,9 @@ require "open3" require "socket" require "timeout" +require "tempfile" + +require_relative "support/mock_control" def free_port s = TCPServer.new("127.0.0.1", 0) @@ -59,19 +62,24 @@ def free_port io.close rescue nil end end + + config.include MockControl end # Point this to your actual binary (relative to repo root, or absolute path) CLI_PATH = File.expand_path("../phrase-cli", __dir__) # e.g. if it’s built somewhere: File.expand_path("../../target/release/my_cli", __dir__) -def run_cli(*args, env: {}) - full_env = { - # Whatever your CLI reads to find the server: - # BASE_URL is set in spec_helper, but you can override per test via env: - "BASE_URL" => ENV.fetch("BASE_URL") - }.merge(env) +def run_cli(*args, config: nil, env: {}) + stdout, stderr, status = if config.nil? + Open3.capture3({}, CLI_PATH, *args.map(&:to_s)) + else + Tempfile.create(".phrase.yml") do |f| + f.write(config) + f.flush - stdout, stderr, status = Open3.capture3(full_env, CLI_PATH, *args.map(&:to_s)) + Open3.capture3({}, CLI_PATH, "--config", f.path, *args.map(&:to_s)) + end + end { stdout: stdout, stderr: stderr, status: status, exit_code: status.exitstatus } end diff --git a/clients/cli/spec/support/mock_control.rb b/clients/cli/spec/support/mock_control.rb new file mode 100644 index 00000000..42aff580 --- /dev/null +++ b/clients/cli/spec/support/mock_control.rb @@ -0,0 +1,52 @@ +require "net/http" +require "json" +require "uri" + +module MockControl + def mock_reset! + post_json("/__control__/reset", {}) + end + + def mock_set!(method, path, status:, body:, headers: {}) + post_json("/__control__/set", { + method: method, + path: path, + status: status, + headers: headers, + body: body + }) + end + + def mock_requests + get_json("/__control__/requests").fetch("requests") + end + + def mock_clear_requests! + post_json("/__control__/requests/clear", {}) + end + + private + + def base_uri + URI(ENV.fetch("BASE_URL")) + end + + def post_json(path, payload) + uri = URI.join(base_uri.to_s, path) + req = Net::HTTP::Post.new(uri) + req["content-type"] = "application/json" + req.body = JSON.dump(payload) + + Net::HTTP.start(uri.host, uri.port) do |http| + res = http.request(req) + raise "Mock control failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess) + end + end + + def get_json(path) + uri = URI.join(base_uri.to_s, path) + res = Net::HTTP.get_response(uri) + raise "Mock control failed: #{res.code} #{res.body}" unless res.is_a?(Net::HTTPSuccess) + JSON.parse(res.body) + end +end diff --git a/clients/cli/spec/support/mock_server.ru b/clients/cli/spec/support/mock_server.ru index 33ce9dd7..50f22451 100644 --- a/clients/cli/spec/support/mock_server.ru +++ b/clients/cli/spec/support/mock_server.ru @@ -1,14 +1,95 @@ require "json" +$mock = { + routes: {}, + requests: [] # array of hashes +} + +def json(status, obj, headers: {}) + [status, { "content-type" => "application/json" }.merge(headers), [JSON.dump(obj)]] +end + +def read_body(env) + io = env["rack.input"] + return "" unless io + body = io.read + io.rewind if io.respond_to?(:rewind) + body +end + +def record_request!(env) + method = env["REQUEST_METHOD"] + path = env["PATH_INFO"] + query = env["QUERY_STRING"] + body = read_body(env) + + # Record a safe subset of headers (HTTP_... keys + content-type/length) + headers = env.each_with_object({}) do |(k, v), h| + next unless k.start_with?("HTTP_") || k == "CONTENT_TYPE" || k == "CONTENT_LENGTH" + h[k] = v + end + + $mock[:requests] << { + method: method, + path: path, + query: query, + headers: headers, + body: body + } +end + run lambda { |env| - path = env["PATH_INFO"] + method = env["REQUEST_METHOD"] + path = env["PATH_INFO"] + + # ---- Control API ---- + if method == "POST" && path == "/__control__/reset" + $mock[:routes].clear + $mock[:requests].clear + return json(200, { ok: true }) + end + + if method == "POST" && path == "/__control__/set" + payload = JSON.parse(read_body(env)) + m = payload.fetch("method") + p = payload.fetch("path") - case path - when "/ping" - [200, { "Content-Type" => "application/json" }, [JSON.dump({ message: "pong" })]] - when "/status/500" - [500, { "Content-Type" => "application/json" }, [JSON.dump({ error: "boom" })]] - else - [404, { "Content-Type" => "application/json" }, [JSON.dump({ error: "not found" })]] + $mock[:routes][[m, p]] = { + status: payload.fetch("status"), + headers: payload["headers"] || {}, + body: payload["body"] || "" + } + + return json(200, { ok: true }) + end + + if method == "GET" && path == "/__control__/requests" + return json(200, { requests: $mock[:requests] }) end + + if method == "POST" && path == "/__control__/requests/clear" + $mock[:requests].clear + return json(200, { ok: true }) + end + + # ---- Record every non-control request ---- + record_request!(env) + + # ---- Serve mocked responses ---- + route = $mock[:routes][[method, path]] + return json(404, { error: "not mocked", method: method, path: path }) unless route + + status = route[:status] + headers = route[:headers] || {} + body = route[:body] + + body_str = + case body + when String then body + else JSON.dump(body) + end + + headers = { "content-type" => "application/json" }.merge(headers) if body.is_a?(Hash) || body.is_a?(Array) + + [status, headers, [body_str]] } From d41639a230be571f3f2623bbf9964ce4106267df Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Fri, 30 Jan 2026 15:16:11 +0100 Subject: [PATCH 5/7] test descriptions --- clients/cli/spec/cli_spec.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/clients/cli/spec/cli_spec.rb b/clients/cli/spec/cli_spec.rb index 3c0fb6f3..987df875 100644 --- a/clients/cli/spec/cli_spec.rb +++ b/clients/cli/spec/cli_spec.rb @@ -2,11 +2,12 @@ require "spec_helper" RSpec.describe "General behavior" do + let(:token) { "test-token" } let(:config) { <<~YAML } phrase: host: #{ENV.fetch("BASE_URL")} project_id: "test-project" - access_token: "test-token" + access_token: "#{token}" YAML it "info prints the version" do @@ -16,7 +17,7 @@ expect(r[:stderr]).to include("You're running a development version") end - describe "format commands" do + describe "a simple command" do before do mock_set!("GET", "/formats", status: 200, body: [ { @@ -34,7 +35,7 @@ ]) end - it "gets the format list" do + it "performs the authenticated request" do r = run_cli("formats", "list", config:) expect(r[:exit_code]).to eq(0) expect(r[:stdout]).to include("Ruby/Rails YAML") @@ -45,7 +46,7 @@ request = requests_made.first expect(request["method"]).to eq("GET") expect(request["path"]).to eq("/formats") - expect(request["headers"]["HTTP_AUTHORIZATION"]).to eq("token test-token") + expect(request["headers"]["HTTP_AUTHORIZATION"]).to eq("token #{token}") end end end From 09fbbf9c8f5d14c9f9bdbe46067e728106bfa171 Mon Sep 17 00:00:00 2001 From: Mladen Jablanovic Date: Fri, 30 Jan 2026 15:21:12 +0100 Subject: [PATCH 6/7] correct action trigger --- .github/workflows/test-cli.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index 454a1117..72ba41ab 100644 --- a/.github/workflows/test-cli.yml +++ b/.github/workflows/test-cli.yml @@ -6,7 +6,7 @@ on: paths: - .github/workflows/test-cli.yml - openapi-generator/cli_lang.yaml - - clients/cli/* + - 'clients/cli/**' jobs: test: runs-on: ubuntu-latest From cb406332269ff8ab86a3952710180162eba46428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mladen=20Jablanovi=C4=87?= Date: Fri, 30 Jan 2026 15:36:22 +0100 Subject: [PATCH 7/7] Update clients/cli/spec/spec_helper.rb Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- clients/cli/spec/spec_helper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/clients/cli/spec/spec_helper.rb b/clients/cli/spec/spec_helper.rb index a5876611..d1197baf 100644 --- a/clients/cli/spec/spec_helper.rb +++ b/clients/cli/spec/spec_helper.rb @@ -72,13 +72,13 @@ def free_port def run_cli(*args, config: nil, env: {}) stdout, stderr, status = if config.nil? - Open3.capture3({}, CLI_PATH, *args.map(&:to_s)) + Open3.capture3(env, CLI_PATH, *args.map(&:to_s)) else Tempfile.create(".phrase.yml") do |f| f.write(config) f.flush - Open3.capture3({}, CLI_PATH, "--config", f.path, *args.map(&:to_s)) + Open3.capture3(env, CLI_PATH, "--config", f.path, *args.map(&:to_s)) end end { stdout: stdout, stderr: stderr, status: status, exit_code: status.exitstatus }