diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 83546616c..7e6f107bf 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -237,3 +237,49 @@ table.reports td.diff div { text-overflow: ellipsis; width: 100px; } + +/* Search form */ + +form.search-form { + max-width: 55em; +} + +form.search-form .search-row { + display: flex; + align-items: center; + gap: 0.5em; + margin: 0.5em 0; +} + +form.search-form .search-row label { + display: flex; + align-items: center; + gap: 0.25em; +} + +form.search-form .search-text-row label { + flex: 1; +} + +form.search-form .search-text-row input[type="text"] { + flex: 1; +} + +form.search-form .search-filter-row { + justify-content: space-between; +} + +/* Search results reuse table.reports, but their columns are in a different + order, so the reports failure-column rule lands on Match and Diff. */ +table.search-results td.match, +table.search-results td.diff { + background-color: transparent; +} + +table.search-results td.match code { + display: block; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + width: 320px; +} diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb new file mode 100644 index 000000000..d77c850d7 --- /dev/null +++ b/app/controllers/search_controller.rb @@ -0,0 +1,53 @@ +class SearchController < ApplicationController + PER_PAGE = 50 + MAX_PAGE = 200 + + # GET /search + # Searches log excerpts of failed builds. Reports are narrowed by the + # indexed columns (server_id, branch, datetime, revision) first, then + # matched against log_excerpts.content (trigram GIN index on PostgreSQL). + def index + @q = params[:q].to_s.strip + @server_id = params[:server].to_s[/\A\d+\z/] + @branch = params[:branch].to_s.strip + @revision = params[:revision].to_s.strip + @from = parse_time(params[:from]) + @to = parse_time(params[:to], end_of_day: true) + # to_s first: params[:page] is an Array for ?page[]=1, which has no to_i. + # Clamping also keeps OFFSET within range for absurd page numbers. + @page = params[:page].to_s.to_i.clamp(1, MAX_PAGE) + @servers = Server.order(:ordinal).to_a + + @searched = @q.present? || @revision.present? || @branch.present? || + @server_id.present? || @from || @to + unless @searched + @reports = [] + return + end + + reports = Report.joins(:log_excerpt).includes(:server, :log_excerpt). + order("reports.datetime DESC") + reports = reports.where(server_id: @server_id) if @server_id + reports = reports.where(branch: @branch) if @branch.present? + reports = reports.where("reports.datetime >= ?", @from) if @from + reports = reports.where("reports.datetime <= ?", @to) if @to + if @revision.present? + reports = reports.where("reports.revision LIKE ?", "#{Report.sanitize_sql_like(@revision)}%") + end + reports = reports.merge(LogExcerpt.content_match(@q)) if @q.present? + + page = reports.limit(PER_PAGE + 1).offset((@page - 1) * PER_PAGE).to_a + @has_next = page.size > PER_PAGE + @reports = page.first(PER_PAGE) + end + + private + + def parse_time(str, end_of_day: false) + return nil if str.blank? + date = Date.parse(str) rescue nil + return nil unless date + time = Time.utc(date.year, date.month, date.day) + end_of_day ? time + 86399 : time + end +end diff --git a/app/models/log_excerpt.rb b/app/models/log_excerpt.rb new file mode 100644 index 000000000..34a241533 --- /dev/null +++ b/app/models/log_excerpt.rb @@ -0,0 +1,74 @@ +require 'net/http' +require 'zlib' +require 'stringio' + +class LogExcerpt < ApplicationRecord + belongs_to :report + + MAX_CONTENT_BYTES = 100_000 + + # Fetch fail.txt and diff.txt from S3 for the report and store them as a + # searchable excerpt. Creates an empty-content row when nothing is available + # (missing or empty objects) so that backfill can resume by max report_id. + # Network errors are raised to the caller. + def self.capture(report) + return nil unless %r{\Ahttps?://rubyci\.s3\.amazonaws\.com/}.match?(report.server&.uri.to_s) + content = fetch_content(report) + excerpt = find_or_initialize_by(report_id: report.id) + excerpt.content = content + excerpt.save! + excerpt + end + + def self.fetch_content(report) + sections = [report.failtxt_uri, report.difftxt_uri].filter_map do |uri| + body = fetch_text(uri) + body unless body.nil? || body.empty? + end + return "" if sections.empty? + # Budget each section separately, otherwise a fail.txt over the limit + # crowds the diff out entirely. A section under budget donates its + # remainder to the other one. The separators come out of the budget so + # the joined result still fits in MAX_CONTENT_BYTES. + budget = (MAX_CONTENT_BYTES - (sections.size - 1)) / sections.size + slack = sections.sum { |s| [budget - s.bytesize, 0].max } + sections.map { |s| truncate_bytes(s, budget + slack) }.join("\n") + end + + # GET a gzipped text file. Returns nil on 404. S3 serves these either with + # Content-Encoding: gzip (Net::HTTP inflates the body) or as raw gzip bytes. + def self.fetch_text(uri) + uri = URI(uri) + res = Net::HTTP.start(uri.host, uri.port, open_timeout: 10, read_timeout: 30, use_ssl: uri.scheme == "https") do |h| + h.get(uri.path) + end + return nil if res.code == "404" + res.value + body = res.body + begin + body = Zlib::GzipReader.new(StringIO.new(body)).read + rescue Zlib::Error + end + body.force_encoding(Encoding::UTF_8).scrub("?").tr("\0", "") + end + + def self.truncate_bytes(str, max) + return str if str.bytesize <= max + str.byteslice(0, max).scrub("") + end + + # Substring match on content. On PostgreSQL the trigram GIN index makes + # ILIKE efficient; SQLite (development/test) falls back to plain LIKE. + def self.content_match(query) + operator = defined?(SQLite3) ? "LIKE" : "ILIKE" + # explicit ESCAPE: SQLite's LIKE has no default escape character + where("log_excerpts.content #{operator} ? ESCAPE '\\'", "%#{sanitize_sql_like(query)}%") + end + + def matching_line(query) + return nil if query.blank? + q = query.downcase + line = content.each_line.find { |l| l.downcase.include?(q) } + line&.strip&.truncate(200) + end +end diff --git a/app/models/report.rb b/app/models/report.rb index a6275c642..2aefd0936 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -6,6 +6,7 @@ class Report < ApplicationRecord belongs_to :server + has_one :log_excerpt, dependent: :delete validates :server_id, :presence => true # SVN revision number or full 40-hex git commit SHA validates :revision, :format => { :with => /\A(?:\d+|\h{40})\z/ }, allow_nil: true @@ -130,6 +131,21 @@ def recenturi server.recent_uri(branch_opts) end + def failtxt_uri + s3txt_uri('compressed_failhtml_relpath', 'fail') + end + + def difftxt_uri + s3txt_uri('compressed_diffhtml_relpath', 'diff') + end + + def s3txt_uri(key, kind) + relpath = meta&.[](key)&.sub('.html.gz', '.txt.gz') || + datetime.strftime("log/%Y%m%dT%H%M%SZ.#{kind}.txt.gz") + "#{server.uri.chomp('/')}/#{depsuffixed_name}/#{relpath}" + end + private :s3txt_uri + def meta if defined?(@meta) @meta @@ -174,7 +190,7 @@ def self.scan_recent_ltsv(server, depsuffixed_name, body) diff = h["different_sections"] summary << (diff ? " (diff:#{diff})" : " (no diff)") - Report.create!( + report = Report.create!( server_id: server.id, datetime: datetime, branch: branch, @@ -183,6 +199,13 @@ def self.scan_recent_ltsv(server, depsuffixed_name, body) ltsv: line, summary: summary.gsub(/<[^>]*>/, ''), ) + if h["result"] != "success" + begin + LogExcerpt.capture(report) + rescue => e + warn [e, server.uri, "failed to capture log excerpt", report.id].inspect + end + end end rescue RuntimeError => e # It seems not a chkbuild log warn [e, server.uri, path, "failed to scan_reports"].inspect diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 2bc968d7f..b7782760e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -19,6 +19,7 @@
diff --git a/app/views/search/index.html.erb b/app/views/search/index.html.erb new file mode 100644 index 000000000..84f20868e --- /dev/null +++ b/app/views/search/index.html.erb @@ -0,0 +1,76 @@ +<% content_for(:title) { "Search failures - RubyCI" } %> + ++ + <%# The revision query parameter is kept for machine clients (planned MCP + server) but intentionally has no visible form field. %> + <%= hidden_field_tag :revision, @revision if @revision.present? %> + <%= submit_tag "Search" %> +
++ + + + +
+<% end %> + +<% if @searched %> + <% if @reports.empty? %> +No matching failure logs found<%= " on page #{@page}" if @page > 1 %>.
+ <% else %> +| Server | +Datetime | +Branch | +Option | +Revision | +Summary | +Match | +Diff | +
|---|---|---|---|---|---|---|---|
| <%= report.server.name %> | +<%= link_to report.sjstdt, report.loguri, title: report.jstdt %> | +<%= report.branch %> | +<%= report.option %> | +<%= revision_link %> | +<%= link_to report.shortsummary, failuri, title: report.shortsummary %> |
+ <%= snippet %> |
+ <%= link_to report.diffstat, report.diffuri, title: report.diffstat %> |
+
+ <% if @page > 1 %> + <%= link_to "Prev", search_path(request.query_parameters.merge("page" => @page - 1)) %> + <% end %> + <% if @has_next %> + <%= link_to "Next", search_path(request.query_parameters.merge("page" => @page + 1)) %> + <% end %> +
+ <% end %> +<% else %> +Search the excerpted fail/diff logs of failed builds. Datetimes are UTC.
+<% end %> diff --git a/config/routes.rb b/config/routes.rb index 018709d00..15bc4a4a6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,8 @@ Rails.application.routes.draw do root :to => 'reports#current' + get "search" => "search#index" + resources :reports, only: [:show, :index] do collection do get "current" diff --git a/db/migrate/20260728023000_create_log_excerpts.rb b/db/migrate/20260728023000_create_log_excerpts.rb new file mode 100644 index 000000000..6c418e075 --- /dev/null +++ b/db/migrate/20260728023000_create_log_excerpts.rb @@ -0,0 +1,18 @@ +class CreateLogExcerpts < ActiveRecord::Migration[8.1] + def change + create_table :log_excerpts do |t| + t.integer :report_id, null: false + t.text :content, null: false + t.timestamps + t.index [:report_id], unique: true + end + add_foreign_key :log_excerpts, :reports + + # pg_trgm and GIN index are PostgreSQL-only; development and test run on SQLite + if connection.adapter_name == "PostgreSQL" + enable_extension "pg_trgm" + add_index :log_excerpts, :content, using: :gin, opclass: :gin_trgm_ops, + name: "index_log_excerpts_on_content_trgm" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 600b9eb82..af1dd98fe 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_27_095902) do +ActiveRecord::Schema[8.1].define(version: 2026_07_28_023000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + enable_extension "pg_trgm" create_table "active_storage_attachments", force: :cascade do |t| t.bigint "blob_id", null: false @@ -42,6 +43,15 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "log_excerpts", force: :cascade do |t| + t.text "content", null: false + t.datetime "created_at", null: false + t.integer "report_id", null: false + t.datetime "updated_at", null: false + t.index ["content"], name: "index_log_excerpts_on_content_trgm", opclass: :gin_trgm_ops, using: :gin + t.index ["report_id"], name: "index_log_excerpts_on_report_id", unique: true + end + create_table "recents", force: :cascade do |t| t.datetime "created_at", null: false t.string "etag", null: false @@ -63,7 +73,7 @@ t.datetime "updated_at", precision: nil t.index ["branch"], name: "index_reports_on_branch" t.index ["datetime"], name: "index_reports_on_datetime" - t.index ["revision"], name: "index_reports_on_revision", opclass: :varchar_pattern_ops + t.index ["revision"], name: "index_reports_on_revision" t.index ["server_id", "branch", "option"], name: "index_reports_on_server_id_and_branch_and_option" end @@ -79,5 +89,6 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "log_excerpts", "reports" add_foreign_key "recents", "servers" end diff --git a/lib/tasks/log_excerpts.rake b/lib/tasks/log_excerpts.rake new file mode 100644 index 000000000..1f177a087 --- /dev/null +++ b/lib/tasks/log_excerpts.rake @@ -0,0 +1,93 @@ +module LogExcerptTaskEnv + module_function + + # UTC to match reports.datetime, which Report.scan_recent_ltsv stores as UTC. + def time(key, default) + value = ENV[key].presence or return default + date = begin + Date.strptime(value, "%Y-%m-%d") + rescue Date::Error + abort "#{key} must be a date like 2026-07-20 (UTC), got #{value.inspect}" + end + Time.utc(date.year, date.month, date.day) + end + + def integer(key, default) + value = ENV[key].presence or return default + Integer(value) rescue abort("#{key} must be an integer, got #{value.inspect}") + end + + def float(key, default) + value = ENV[key].presence or return default + Float(value) rescue abort("#{key} must be a number of seconds, got #{value.inspect}") + end +end + +namespace :log_excerpts do + desc "Backfill log excerpts from S3. FROM/TO (UTC date, default last 1 year), START_ID to force resume point, SLEEP seconds between reports (default 0.1), FORCE=1 to refetch reports that already have an excerpt" + task :backfill => :environment do + from = LogExcerptTaskEnv.time("FROM", 1.year.ago) + to = LogExcerptTaskEnv.time("TO", Time.now) + interval = LogExcerptTaskEnv.float("SLEEP", 0.1) + start_id = LogExcerptTaskEnv.integer("START_ID", nil) + + scope = Report.joins(:server). + where(datetime: from..to). + where.not(ltsv: nil). + where("ltsv NOT LIKE '%result:success%'"). + where("servers.uri LIKE 'https://rubyci.s3.amazonaws.com/%'") + + # Resume by skipping reports that already have an excerpt. Using the max + # captured report_id instead would skip everything, because scan_recent_ltsv + # captures new failures as they arrive and that id is always the newest one. + scope = scope.where.missing(:log_excerpt) unless ENV["FORCE"] + scope = scope.where("reports.id > ?", start_id) if start_id + + total = scope.count + puts "backfilling #{total} reports (datetime #{from}..#{to}, sleep #{interval}s#{start_id ? ", report_id > #{start_id}" : ""}#{ENV["FORCE"] ? ", forced" : ""})" + + started = Time.now + captured = empty = failed = 0 + failed_ids = [] + scope.includes(:server).find_each(batch_size: 100) do |report| + begin + excerpt = LogExcerpt.capture(report) + excerpt&.content.presence ? captured += 1 : empty += 1 + rescue => e + failed += 1 + failed_ids << report.id + warn "report #{report.id} (#{report.failtxt_uri}): #{e.class}: #{e.message}" + end + done = captured + empty + failed + puts "#{done}/#{total} (report_id=#{report.id}, #{(Time.now - started).round}s elapsed)" if done % 100 == 0 + sleep interval + end + + puts "done in #{(Time.now - started).round}s: #{captured} captured, #{empty} empty, #{failed} failed" + unless failed.zero? + warn "failed report ids: #{failed_ids.join(", ")}" + abort "backfill finished with #{failed} failures" + end + end + + desc "Delete log excerpts whose report datetime is older than KEEP_DAYS (default 366) days. DRY_RUN=1 to only count." + task :prune => :environment do + keep_days = LogExcerptTaskEnv.integer("KEEP_DAYS", 366) + abort "KEEP_DAYS must be at least 1, got #{keep_days}" if keep_days < 1 + cutoff = keep_days.days.ago + scope = LogExcerpt.where(report_id: Report.where("datetime < ?", cutoff).select(:id)) + + if ENV["DRY_RUN"] + puts "would delete #{scope.count} log excerpts of reports older than #{cutoff}" + next + end + + deleted = 0 + scope.in_batches(of: 10_000) do |batch| + deleted += batch.delete_all + puts "deleted #{deleted} ..." + end + puts "deleted #{deleted} log excerpts of reports older than #{cutoff}" + puts "note: disk space returns via autovacuum; VACUUM FULL is needed to reclaim it immediately" + end +end diff --git a/test/controllers/search_controller_test.rb b/test/controllers/search_controller_test.rb new file mode 100644 index 000000000..4fb2b7cb5 --- /dev/null +++ b/test/controllers/search_controller_test.rb @@ -0,0 +1,72 @@ +require "test_helper" + +class SearchControllerTest < ActionDispatch::IntegrationTest + SHA = "abcdef0123456789abcdef0123456789abcdef01" + + setup do + @server = Server.create!(name: "search-srv", uri: "https://rubyci.s3.amazonaws.com/search-srv/", ordinal: 999) + @report = Report.create!( + server: @server, + branch: "master", + datetime: Time.utc(2026, 7, 20, 1, 2, 3), + revision: SHA, + summary: "ruby 3.5.0dev failed(test-all) (diff:test-all)", + ltsv: "depsuffixed_name:ruby-master\tresult:failure", + ) + LogExcerpt.create!(report: @report, content: "TestSearch#test_hit [foo_test.rb:12]:\nExpected true.") + end + + test "top page without params shows only the form" do + get search_url + assert_response :success + assert_no_match "TestSearch#test_hit", response.body + end + + test "search by text matches excerpt content" do + get search_url, params: { q: "test_hit" } + assert_response :success + assert_match "TestSearch#test_hit", response.body + assert_match @report.loguri, response.body + end + + test "search by text with no match" do + get search_url, params: { q: "does_not_exist_anywhere" } + assert_response :success + assert_match "No matching failure logs", response.body + end + + test "search by revision prefix" do + get search_url, params: { revision: SHA[0, 10] } + assert_response :success + assert_match @report.loguri, response.body + end + + test "search narrowed by branch and date range" do + get search_url, params: { q: "test_hit", branch: "master", from: "2026-07-20", to: "2026-07-20" } + assert_response :success + assert_match @report.loguri, response.body + + get search_url, params: { q: "test_hit", branch: "ruby_3_4" } + assert_match "No matching failure logs", response.body + + get search_url, params: { q: "test_hit", to: "2026-07-19" } + assert_match "No matching failure logs", response.body + end + + test "array page param does not raise" do + get search_url, params: { q: "test_hit", page: ["1"] } + assert_response :success + end + + test "out of range page is clamped" do + get search_url, params: { q: "test_hit", page: "99999999999999999999" } + assert_response :success + assert_match "No matching failure logs", response.body + end + + test "search escapes LIKE wildcards" do + get search_url, params: { q: "%" } + assert_response :success + assert_match "No matching failure logs", response.body + end +end diff --git a/test/models/log_excerpt_test.rb b/test/models/log_excerpt_test.rb new file mode 100644 index 000000000..9fc9ca244 --- /dev/null +++ b/test/models/log_excerpt_test.rb @@ -0,0 +1,176 @@ +require "test_helper" + +class LogExcerptTest < ActiveSupport::TestCase + @@ordinal = 100 + + def create_server(name, uri: "https://rubyci.s3.amazonaws.com/#{name}/") + Server.create!(name: name, uri: uri, ordinal: (@@ordinal += 1)) + end + + # minitest 6 no longer bundles minitest/mock, so swap the singleton method + def stub_fetch_text(callable) + singleton = LogExcerpt.singleton_class + original = LogExcerpt.method(:fetch_text) + singleton.send(:define_method, :fetch_text) { |uri| callable.call(uri) } + yield + ensure + singleton.send(:define_method, :fetch_text, original) + end + + def create_report(server, attrs = {}) + Report.create!({ + server: server, + branch: "master", + datetime: Time.now.utc, + summary: "ruby 3.5.0dev failed(test-all)", + }.merge(attrs)) + end + + test "failtxt_uri and difftxt_uri from ltsv relpath" do + server = create_server("uri-ltsv") + report = create_report(server, ltsv: "depsuffixed_name:ruby-master\tcompressed_failhtml_relpath:log/20260727T023004Z.fail.html.gz\tcompressed_diffhtml_relpath:log/20260727T023004Z.diff.html.gz") + assert_equal "https://rubyci.s3.amazonaws.com/uri-ltsv/ruby-master/log/20260727T023004Z.fail.txt.gz", report.failtxt_uri + assert_equal "https://rubyci.s3.amazonaws.com/uri-ltsv/ruby-master/log/20260727T023004Z.diff.txt.gz", report.difftxt_uri + end + + test "failtxt_uri falls back to datetime" do + server = create_server("uri-dt") + report = create_report(server, datetime: Time.utc(2026, 7, 27, 2, 30, 4)) + assert_equal "https://rubyci.s3.amazonaws.com/uri-dt/master/log/20260727T023004Z.fail.txt.gz", report.failtxt_uri + end + + test "capture stores fail and diff content" do + server = create_server("cap-both") + report = create_report(server) + fetch = ->(uri) { uri.include?(".fail.") ? "FAIL BODY" : "DIFF BODY" } + stub_fetch_text(fetch) do + LogExcerpt.capture(report) + end + assert_equal "FAIL BODY\nDIFF BODY", report.reload.log_excerpt.content + end + + test "capture truncates content to MAX_CONTENT_BYTES" do + server = create_server("cap-trunc") + report = create_report(server) + stub_fetch_text(->(uri) { "x" * 200_000 }) do + LogExcerpt.capture(report) + end + assert_operator report.reload.log_excerpt.content.bytesize, :<=, LogExcerpt::MAX_CONTENT_BYTES + end + + test "capture keeps the diff section when fail is over budget" do + server = create_server("cap-budget") + report = create_report(server) + fetch = ->(uri) { uri.include?(".fail.") ? "F" * 200_000 : "D" * 1_000 } + stub_fetch_text(fetch) do + LogExcerpt.capture(report) + end + content = report.reload.log_excerpt.content + assert_operator content.bytesize, :<=, LogExcerpt::MAX_CONTENT_BYTES + assert_equal 1_000, content.count("D") + assert_operator content.count("F"), :>, 90_000 + end + + test "capture lets one section use the other's unused budget" do + server = create_server("cap-slack") + report = create_report(server) + fetch = ->(uri) { uri.include?(".fail.") ? "F" * 90_000 : "D" * 1_000 } + stub_fetch_text(fetch) do + LogExcerpt.capture(report) + end + content = report.reload.log_excerpt.content + assert_equal 90_000, content.count("F") + assert_equal 1_000, content.count("D") + end + + test "capture saves empty row when nothing is available" do + server = create_server("cap-empty") + report = create_report(server) + stub_fetch_text(->(uri) { nil }) do + LogExcerpt.capture(report) + end + assert_equal "", report.reload.log_excerpt.content + end + + test "capture skips non-rubyci servers" do + server = create_server("cap-other", uri: "https://example.com/chkbuild/logs") + report = create_report(server) + stub_fetch_text(->(uri) { flunk "must not fetch" }) do + assert_nil LogExcerpt.capture(report) + end + assert_nil report.reload.log_excerpt + end + + test "capture is idempotent per report" do + server = create_server("cap-idem") + report = create_report(server) + stub_fetch_text(->(uri) { "first" }) do + LogExcerpt.capture(report) + end + stub_fetch_text(->(uri) { "second" }) do + LogExcerpt.capture(report) + end + assert_equal 1, LogExcerpt.where(report_id: report.id).count + assert_equal "second\nsecond", report.reload.log_excerpt.content + end + + test "content_match finds substring" do + server = create_server("cap-match") + report = create_report(server) + LogExcerpt.create!(report: report, content: "TestFoo#test_bar [test.rb:1]:\nExpected 1 to equal 2.") + assert_includes LogExcerpt.content_match("test_bar").to_a.map(&:report_id), report.id + assert_empty LogExcerpt.content_match("no_such_token").where(report_id: report.id).to_a + end + + test "matching_line returns first matching line" do + excerpt = LogExcerpt.new(content: "line one\nTestFoo#test_bar failed\nline three") + assert_equal "TestFoo#test_bar failed", excerpt.matching_line("TEST_BAR") + assert_nil excerpt.matching_line("absent") + assert_nil excerpt.matching_line("") + end + + test "scan_recent_ltsv captures excerpt for failed build" do + server = create_server("scan-fail") + line = [ + "start_time:20260727T051800Z", + "title:ruby 3.5.0dev (2026-07-27) [x86_64-linux]", + "result:failure", + "compressed_failhtml_relpath:log/20260727T051800Z.fail.html.gz", + "compressed_diffhtml_relpath:log/20260727T051800Z.diff.html.gz", + ].join("\t") + stub_fetch_text(->(uri) { "some failure output" }) do + Report.scan_recent_ltsv(server, "ruby-master", line + "\n") + end + report = Report.where(server_id: server.id).last + assert_equal "some failure output\nsome failure output", report.log_excerpt.content + end + + test "scan_recent_ltsv does not capture excerpt for success build" do + server = create_server("scan-ok") + line = [ + "start_time:20260727T051800Z", + "title:ruby 3.5.0dev (2026-07-27) [x86_64-linux]", + "result:success", + ].join("\t") + stub_fetch_text(->(uri) { flunk "must not fetch" }) do + Report.scan_recent_ltsv(server, "ruby-master", line + "\n") + end + report = Report.where(server_id: server.id).last + assert_nil report.log_excerpt + end + + test "scan_recent_ltsv survives capture errors" do + server = create_server("scan-err") + line = [ + "start_time:20260727T051800Z", + "title:ruby 3.5.0dev (2026-07-27) [x86_64-linux]", + "result:failure", + ].join("\t") + stub_fetch_text(->(uri) { raise Net::OpenTimeout }) do + Report.scan_recent_ltsv(server, "ruby-master", line + "\n") + end + report = Report.where(server_id: server.id).last + assert_not_nil report + assert_nil report.log_excerpt + end +end