From faadf87726defaf90b1d559e9d36474ad6b13548 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Mon, 24 Aug 2026 11:32:43 +0800 Subject: [PATCH 1/2] Tell the user when a scraper runs but outputs nothing Previously a scraper that wrote nothing to stdout or stderr finished silently, leaving no way to tell a successful quiet run from one that did nothing at all. Track which streams the run log lines came from and, when none of them were the scraper's own stdout or stderr, print a confirmation that the run succeeded. Lines on morph's internalout stream don't count as scraper output. The streaming upload moves from execute into a new MorphCLI.run to keep the method within the RuboCop metrics backlog; the module length allowance in .rubocop_todo.yml grows to cover the new lines. Resolves #7 Assisted-by: OpenCode:anthropic.claude-fable-5 Signed-off-by: Ben Fairless --- .rubocop_todo.yml | 2 +- lib/morph-cli.rb | 19 ++++++++++++++++++- spec/morph_cli_spec.rb | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 06896ae..9a226b5 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -36,7 +36,7 @@ Metrics/MethodLength: # Offense count: 1 # Configuration parameters: CountComments, CountAsOne. Metrics/ModuleLength: - Max: 114 + Max: 124 # Offense count: 1 # Configuration parameters: ExpectMatchingDefinition, CheckDefinitionPathHierarchy, CheckDefinitionPathHierarchyRoots, Regex, IgnoreExecutableScripts, AllowedAcronyms. diff --git a/lib/morph-cli.rb b/lib/morph-cli.rb index ddadb4d..59db84b 100644 --- a/lib/morph-cli.rb +++ b/lib/morph-cli.rb @@ -24,6 +24,15 @@ def self.execute(directory, _development, env_config) file = MorphCLI.create_tar(directory, all_paths) + scraper_output = run(file, env_config) + + puts "Scraper didn't output anything, but it ran successfully." unless scraper_output + end + + # Uploads the code to the server, streams the run output to the local + # stdout/stderr and returns whether the scraper itself wrote anything + # to stdout or stderr + def self.run(file, env_config) timeout = if env_config.key?(:timeout) env_config[:timeout] else @@ -39,6 +48,7 @@ def self.execute(directory, _development, env_config) end buffer = +"" + scraper_output = false connection.post("/run") do |req| req.body = { api_key: env_config[:api_key], @@ -50,12 +60,18 @@ def self.execute(directory, _development, env_config) before, match, after = chunk.rpartition("\n") buffer << before << match - buffer.split("\n").each { |l| log(l) } + buffer.split("\n").each do |l| + stream = log(l) + scraper_output = true if %w[stdout stderr].include?(stream) + end buffer = after end end + scraper_output end + # Writes the line to the local stdout/stderr and returns the name of the + # stream it came from def self.log(line) return if line.empty? @@ -70,6 +86,7 @@ def self.log(line) end s.puts a["text"] + a["stream"] end def self.config_path diff --git a/spec/morph_cli_spec.rb b/spec/morph_cli_spec.rb index 66111de..121468e 100644 --- a/spec/morph_cli_spec.rb +++ b/spec/morph_cli_spec.rb @@ -30,6 +30,38 @@ def with_scraper_directory end end + it "tells the user the run succeeded when the scraper produces no output" do + stub_request(:post, "https://morph.io/run") + .to_return(status: 200, body: %({"stream":"internalout","text":"Injecting configuration"}\n)) + + with_scraper_directory do |dir| + expect { described_class.execute(dir, false, env_config) } + .to output(/Scraper didn't output anything, but it ran successfully\./).to_stdout + end + end + + it "doesn't add a message when the scraper writes to stdout" do + stub_request(:post, "https://morph.io/run") + .to_return(status: 200, body: %({"stream":"stdout","text":"hello from morph"}\n)) + + with_scraper_directory do |dir| + expect { described_class.execute(dir, false, env_config) } + .not_to output(/ran successfully/).to_stdout + end + end + + it "doesn't add a message when the scraper writes to stderr" do + stub_request(:post, "https://morph.io/run") + .to_return(status: 200, body: %({"stream":"stderr","text":"oops"}\n)) + + with_scraper_directory do |dir| + expect do + expect { described_class.execute(dir, false, env_config) } + .not_to output(/ran successfully/).to_stdout + end.to output("oops\n").to_stderr + end + end + it "posts the API key and the code as multipart form data" do stub_request(:post, "https://morph.io/run").to_return(status: 200, body: "") From 3dfbb07feac177ac2502bdbe2f518da693b19220 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Mon, 24 Aug 2026 13:40:31 +0800 Subject: [PATCH 2/2] Fix WebMock assertions for data.sqlite in gzip-compressed tar The assertions used req.body.include?("data.sqlite") which worked when the upload was an uncompressed tar (the filename appeared as plain text in the tar header). After gzip compression was added the string no longer appears in the raw multipart body. Replace with tar_entry_names(), which locates the gzip magic bytes in the multipart body, decompresses the tar, and returns the list of entry names to check against. Assisted-by: Claude Code:claude-sonnet-4-6 --- spec/morph_cli_spec.rb | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/spec/morph_cli_spec.rb b/spec/morph_cli_spec.rb index 72093ab..93b7e26 100644 --- a/spec/morph_cli_spec.rb +++ b/spec/morph_cli_spec.rb @@ -1,5 +1,7 @@ require "fileutils" require "tmpdir" +require "stringio" +require "zlib" require "spec_helper" @@ -20,6 +22,23 @@ def with_scraper_directory end end + # Extracts tar entry names from a multipart request body that contains + # a gzip-compressed tar as one of its parts. + def tar_entry_names(body) + binary = body.b + gzip_start = binary.index("\x1f\x8b".b) + return [] unless gzip_start + + gzip_io = StringIO.new(binary[gzip_start..]) + names = [] + Zlib::GzipReader.wrap(gzip_io) do |gz| + Minitar::Input.open(gz) { |tar| tar.each { |entry| names << entry.full_name } } + end + names + rescue StandardError + [] + end + it "uploads the scraper and streams the run output to stdout" do stub_request(:post, "https://morph.io/run") .to_return(status: 200, body: %({"stream":"stdout","text":"hello from morph"}\n)) @@ -88,7 +107,7 @@ def with_scraper_directory end expect(WebMock).to(have_requested(:post, "https://morph.io/run").with do |req| - req.body.include?("data.sqlite") + tar_entry_names(req.body).include?("data.sqlite") end) end @@ -103,7 +122,7 @@ def with_scraper_directory end expect(WebMock).to(have_requested(:post, "https://morph.io/run").with do |req| - !req.body.include?("data.sqlite") + !tar_entry_names(req.body).include?("data.sqlite") end) end