diff --git a/.github/workflows/test-cli.yml b/.github/workflows/test-cli.yml index 91d226ee..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 @@ -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,16 @@ 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 + working-directory: ./clients/cli + - name: Run integration tests + run: | + cd ./clients/cli + bundle exec rspec - name: License check uses: phrase/actions/lawa-ci@v1 with: 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..987df875 --- /dev/null +++ b/clients/cli/spec/cli_spec.rb @@ -0,0 +1,52 @@ +require "open3" +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: "#{token}" + YAML + + 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 + + describe "a simple command" 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 "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") + + 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 #{token}") + end + end +end diff --git a/clients/cli/spec/spec_helper.rb b/clients/cli/spec/spec_helper.rb new file mode 100644 index 00000000..d1197baf --- /dev/null +++ b/clients/cli/spec/spec_helper.rb @@ -0,0 +1,85 @@ +require "rspec" +require "open3" +require "socket" +require "timeout" +require "tempfile" + +require_relative "support/mock_control" + +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 + + 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, config: nil, env: {}) + stdout, stderr, status = if config.nil? + Open3.capture3(env, CLI_PATH, *args.map(&:to_s)) + else + Tempfile.create(".phrase.yml") do |f| + f.write(config) + f.flush + + Open3.capture3(env, 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 new file mode 100644 index 00000000..50f22451 --- /dev/null +++ b/clients/cli/spec/support/mock_server.ru @@ -0,0 +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| + 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") + + $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]] +}