diff --git a/app/channels/onlylogs/logs_channel.rb b/app/channels/onlylogs/logs_channel.rb index efc24d6..e793872 100644 --- a/app/channels/onlylogs/logs_channel.rb +++ b/app/channels/onlylogs/logs_channel.rb @@ -39,19 +39,18 @@ def initialize_watcher(data) return end - cursor_position = data["cursor_position"] || 0 filter = data["filter"].presence mode = data["mode"] || "live" regexp_mode = data["regexp_mode"] == true || data["regexp_mode"] == "true" start_position = data["start_position"]&.to_i || 0 end_position = data["end_position"]&.to_i - if mode == "search" - # For search mode, read the entire file with filter and send all matching lines - read_entire_file_with_filter(file_path, filter, regexp_mode, start_position, end_position) + if mode == "static" + # Read a bounded snapshot once, then finish. + read_static(file_path, filter, regexp_mode, start_position, end_position) else - # For live mode, start the watcher - start_log_watcher(file_path, cursor_position, filter, regexp_mode) + # Follow the tail of the file indefinitely. + start_log_watcher(file_path, live_tail_position(file_path), filter, regexp_mode) end end @@ -75,6 +74,14 @@ def cleanup_existing_operations stop_log_watcher end + # Bytes from the end of the file to show when starting a live tail without + # an explicit cursor (matches the default whole-file live-mode page load). + LIVE_TAIL_BYTES = 10_000 + + def live_tail_position(file_path) + [::File.size(file_path) - LIVE_TAIL_BYTES, 0].max + end + def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = false) return if @log_watcher_running @@ -90,6 +97,7 @@ def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = fa @log_watcher_thread = Thread.new do Rails.logger.silence(Logger::ERROR) do + current_byte_offset = cursor_position @log_file.watch do |new_lines| break unless @log_watcher_running @@ -102,7 +110,8 @@ def start_log_watcher(file_path, cursor_position, filter = nil, regexp_mode = fa # next # end - lines_to_send << render_log_line(log_line) + lines_to_send << render_log_line(log_line, byte_offset: current_byte_offset) + current_byte_offset += log_line.bytesize end if lines_to_send.any? @@ -143,11 +152,11 @@ def stop_log_watcher @log_file = nil end - def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, start_position = 0, end_position = nil) + def read_static(file_path, filter = nil, regexp_mode = false, start_position = 0, end_position = nil) @log_watcher_running = true @log_file = Onlylogs::File.new(file_path, last_position: 0) - transmit({action: "message", content: "Searching..."}) + transmit({action: "message", content: filter.present? ? "Searching..." : "Loading..."}) @batch_sender = BatchSender.new(self) @batch_sender.start @@ -156,11 +165,15 @@ def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, s begin Rails.logger.silence(Logger::ERROR) do - @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |log_line| + @log_file.grep(filter, regexp_mode: regexp_mode, start_position: start_position, end_position: end_position) do |result| break if @batch_sender.nil? + # Result is now a hash with {byte_offset, content} + byte_offset = result[:byte_offset] + log_line = result[:content] + # Add to batch buffer (sender thread will handle sending) - @batch_sender.add_line(render_log_line(log_line)) + @batch_sender.add_line(render_log_line(log_line, byte_offset: byte_offset, show_expand_button: true)) line_count += 1 end @@ -170,7 +183,7 @@ def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, s @batch_sender.stop # Send completion message - if line_count >= Onlylogs.max_line_matches + if Onlylogs.max_line_matches && line_count >= Onlylogs.max_line_matches transmit({action: "finish", content: "Search finished. Search results limit reached."}) else transmit({action: "finish", content: "Search finished."}) @@ -184,8 +197,14 @@ def read_entire_file_with_filter(file_path, filter = nil, regexp_mode = false, s end end - def render_log_line(log_line) - "
#{FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(log_line)))}
" + def render_log_line(log_line, byte_offset: nil, show_expand_button: false) + parsed = FilePathParser.parse(AnsiColorParser.parse(ERB::Util.html_escape(log_line))) + + { + content: parsed, + byte_offset: byte_offset, + show_expand_button: show_expand_button + } end end end diff --git a/app/controllers/onlylogs/logs_controller.rb b/app/controllers/onlylogs/logs_controller.rb index b503225..c9e4c2c 100644 --- a/app/controllers/onlylogs/logs_controller.rb +++ b/app/controllers/onlylogs/logs_controller.rb @@ -11,7 +11,11 @@ def index @filter = params[:filter] @autoscroll = params[:autoscroll] != "false" @regexp_mode = params[:regexp_mode] == "true" - @mode = @filter.blank? ? (params[:mode] || "live") : "search" # "live" or "search" + @mode = @filter.blank? ? (params[:mode] || "live") : "static" # "live" or "static" + @start_position = nil + @end_position = nil + + handle_byte_offset if params[:byte_offset].present? end def download @@ -31,6 +35,19 @@ def download private + EXPLORE_WINDOW_BYTES = 10_000 + + def handle_byte_offset + byte_offset = params[:byte_offset]&.to_i + return unless byte_offset.present? + + @start_position = [byte_offset - EXPLORE_WINDOW_BYTES, 0].max + @end_position = byte_offset + EXPLORE_WINDOW_BYTES + @filter = nil + @mode = "static" + @autoscroll = false + end + def selected_log_file_path return default_log_file_path if params[:log_file_path].blank? authorized_log_file_path(params[:log_file_path]) diff --git a/app/javascript/onlylogs/controllers/log_streamer_controller.js b/app/javascript/onlylogs/controllers/log_streamer_controller.js index e5781cc..6add16e 100644 --- a/app/javascript/onlylogs/controllers/log_streamer_controller.js +++ b/app/javascript/onlylogs/controllers/log_streamer_controller.js @@ -4,7 +4,8 @@ import { createConsumer } from "@rails/actioncable"; export default class LogStreamerController extends Controller { static values = { filePath: { type: String }, - cursorPosition: { type: Number, default: 0 }, + startPosition: { type: Number, default: 0 }, + endPosition: { type: Number, default: 0 }, autoScroll: { type: Boolean, default: true }, autoStart: { type: Boolean, default: true }, filter: { type: String, default: '' }, @@ -12,7 +13,7 @@ export default class LogStreamerController extends Controller { regexpMode: { type: Boolean, default: false } }; - static targets = ["logLines", "filterInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll"]; + static targets = ["logLines", "filterInput", "byteOffsetInput", "results", "liveMode", "message", "regexpMode", "websocketStatus", "stopButton", "clearButton", "autoscroll"]; connect() { this.consumer = createConsumer(); @@ -21,6 +22,7 @@ export default class LogStreamerController extends Controller { this.isRunning = false; this.reconnectTimeout = null; this.isSearchFinished = true; + this.contextLineHighlighted = false; // Initialize clusterize this.clusterize = null; @@ -100,7 +102,7 @@ export default class LogStreamerController extends Controller { if (this.isLiveMode()) { this.liveModeTarget.checked = false; - this.modeValue = 'search'; + this.modeValue = 'static'; this.updateLiveModeState(); this.stop(); } @@ -116,9 +118,9 @@ export default class LogStreamerController extends Controller { } toggleLiveMode() { - // this condition looks revered, but the value here has been changed already. so the live mode has been enabled. if (this.isLiveMode()) { this.modeValue = 'live'; + this.clearFilter(); this.updateLiveModeState(); if (!this.isRunning) { this.start(); @@ -132,20 +134,25 @@ export default class LogStreamerController extends Controller { applyFilter() { const filterValue = this.filterInputTarget.value; - // If filter is applied, disable live mode + // A filter switches to static mode; an empty filter goes back to live. if (filterValue && filterValue.trim() !== '') { this.liveModeTarget.checked = false; - this.modeValue = 'search'; + this.modeValue = 'static'; } else { - // If no filter, enable live mode this.liveModeTarget.checked = true; this.modeValue = 'live'; } + // Applying a filter searches the whole file, so drop any explore window. + this.startPositionValue = 0; + this.endPositionValue = 0; + // Update visual state this.updateLiveModeState(); this.updateStopButtonVisibility(); this.#updateUrlParam('filter', filterValue || null); + this.#updateUrlParam('byte_offset', null); + this.byteOffsetInputTarget.value = ''; // Use the global debounced reconnection (300ms delay) this.reconnectWithNewMode(); @@ -178,19 +185,29 @@ export default class LogStreamerController extends Controller { } clearFilter() { - // Clear the filter input + // Clear filter and explore window to go back to pure live mode this.filterInputTarget.value = ''; + this.byteOffsetInputTarget.value = ''; + this.modeValue = 'live'; + this.startPositionValue = 0; + this.endPositionValue = 0; + this.contextLineHighlighted = false; + + // Remove any highlighting + this.logLinesTarget.querySelectorAll('.highlighted-context-line').forEach(el => { + el.classList.remove('highlighted-context-line'); + }); - // Re-enable live mode + // Re-enable live mode checkbox this.liveModeTarget.checked = true; - this.modeValue = 'live'; // Update visual state this.updateLiveModeState(); this.updateStopButtonVisibility(); - // Update URL with cleared filter - this.#updateUrlParam('filter'); + // Update URL with cleared params + this.#updateUrlParam('filter', null); + this.#updateUrlParam('byte_offset', null); // Reconnect with cleared filter and live mode this.reconnectWithNewMode(); @@ -206,21 +223,98 @@ export default class LogStreamerController extends Controller { this.#hideMessage(); } + jumpToByteOffset(e) { + if (e instanceof KeyboardEvent && e.key !== 'Enter') return; + + const byteOffset = this.byteOffsetInputTarget.value; + if (!byteOffset || isNaN(byteOffset)) return; + + const params = new URLSearchParams(window.location.search); + params.set('byte_offset', byteOffset); + params.delete('filter'); + window.location.href = `${window.location.pathname}?${params.toString()}`; + } + + handleExpandClick(e) { + const btn = e.target.closest('.onlylogs-expand-btn'); + if (!btn) return; + + const byteOffset = btn.getAttribute('data-byte-offset'); + if (!byteOffset) return; + + const params = new URLSearchParams(window.location.search); + params.set('byte_offset', byteOffset); + params.delete('filter'); + params.set('autoscroll', 'false'); + params.delete('regexp_mode'); + + window.location.href = `${window.location.pathname}?${params.toString()}`; + } + + #highlightContextLine() { + const target = Number(new URLSearchParams(window.location.search).get('byte_offset')); + if (Number.isNaN(target)) return; + + this.#applyContextLineHighlight(target); + + // Only scroll on initial highlight + if (!this.contextLineHighlighted) { + const closestPre = [...this.logLinesTarget.querySelectorAll('pre[data-byte-offset]')] + .find(pre => Number(pre.dataset.byteOffset) === target || + Math.abs(Number(pre.dataset.byteOffset) - target) < 1000); + if (closestPre) { + this.#scrollVerticallyToCenter(closestPre); + } + } + } + + #scrollVerticallyToCenter(element) { + const container = this.logLinesTarget; + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + const delta = (elementRect.top - containerRect.top) - (container.clientHeight / 2) + (elementRect.height / 2); + container.scrollBy({ top: delta, behavior: 'smooth' }); + } + + #applyContextLineHighlight(target) { + const closestPre = [...this.logLinesTarget.querySelectorAll('pre[data-byte-offset]')] + .reduce((closest, pre) => { + const distance = Math.abs(Number(pre.dataset.byteOffset) - target); + return !closest || distance < closest.distance ? { pre, distance } : closest; + }, null)?.pre; + + if (!closestPre) return; + + const row = this.#rowElement(closestPre); + [row.previousElementSibling, row, row.nextElementSibling] + .filter(Boolean) + .forEach(line => line.classList.add('highlighted-context-line')); + } + + // A row is either a bare
 or an expand-button wrapper 
directly + // under the clusterize content area. Walk up to that top-level element so the + // highlight covers the whole line, including the "+" toggle. + #rowElement(element) { + let node = element; + while (node.parentElement && !node.parentElement.classList.contains('clusterize-content')) { + node = node.parentElement; + } + return node; + } + updateLiveModeState() { const liveModeLabel = this.liveModeTarget.closest('label'); const hasFilter = this.filterInputTarget.value && this.filterInputTarget.value.trim() !== ''; if (hasFilter) { - liveModeLabel.classList.add('live-mode-sticky'); this.liveModeTarget.disabled = true; } else { - liveModeLabel.classList.remove('live-mode-sticky'); this.liveModeTarget.disabled = false; } } updateStopButtonVisibility() { - const shouldShow = !this.isLiveMode() && this.subscription && this.isRunning && !this.isSearchFinished; + const shouldShow = this.modeValue === 'static' && this.subscription && this.isRunning && !this.isSearchFinished; this.stopButtonTarget.style.display = shouldShow ? 'inline-block' : 'none'; } @@ -261,13 +355,23 @@ export default class LogStreamerController extends Controller { * Handle successful connection */ #handleConnected() { - this.subscription.perform('initialize_watcher', { - cursor_position: this.cursorPositionValue, + const data = { file_path: this.filePathValue, filter: this.filterInputTarget.value, mode: this.modeValue, regexp_mode: this.regexpModeValue - }); + }; + + if (this.modeValue === 'static') { + // A byte-offset explore window reads a bounded range. Without a window + // (start/end), a static read is a whole-file filter search. + if (this.endPositionValue > 0) { + data.start_position = this.startPositionValue; + data.end_position = this.endPositionValue; + } + } + + this.subscription.perform('initialize_watcher', data); this.element.classList.add("log-streamer--connected"); this.element.classList.remove("log-streamer--disconnected", "log-streamer--rejected"); @@ -293,9 +397,20 @@ export default class LogStreamerController extends Controller { try { // Append new lines to clusterize if (lines.length > 0) { - this.clusterize.append(lines); + // Render JSON log lines into HTML strings + const renderedLines = lines.map(line => this.#renderLogLineHtml(line)); + this.clusterize.append(renderedLines); this.#updateResultsDisplay(); this.scroll(); + + // Find and highlight lines around the target byte offset + const params = new URLSearchParams(window.location.search); + if (params.has('byte_offset') && !this.contextLineHighlighted) { + setTimeout(() => { + this.#highlightContextLine(); + this.contextLineHighlighted = true; + }, 100); + } } // Update stop button visibility after processing lines @@ -306,6 +421,19 @@ export default class LogStreamerController extends Controller { } } + #renderLogLineHtml(logLine) { + // logLine is now a JSON object: {content, byte_offset, show_expand_button} + const { content, byte_offset, show_expand_button } = logLine; + + if (byte_offset && show_expand_button) { + return `
${content}
`; + } else if (byte_offset) { + return `
${content}
`; + } else { + return `
${content}
`; + } + } + #handleMessage(message) { this.#hideMessage(); if (message === '') { @@ -380,7 +508,6 @@ export default class LogStreamerController extends Controller { return { isRunning: this.isRunning, filePath: this.filePathValue, - cursorPosition: this.cursorPositionValue, lineCount: this.clusterize.getRowsAmount(), connected: this.subscription && this.subscription.identifier }; @@ -403,7 +530,15 @@ export default class LogStreamerController extends Controller { // Optional: handle cluster change }, clusterChanged: () => { - // Optional: handle after cluster change + // Re-apply highlighting when cluster changes (for virtual scrolling). + // The byte_offset URL param is the highlight anchor for an explore window. + const params = new URLSearchParams(window.location.search); + if (params.has('byte_offset')) { + const target = Number(params.get('byte_offset')); + if (!Number.isNaN(target)) { + this.#applyContextLineHighlight(target); + } + } }, scrollingProgress: (progress) => { // Optional: handle scrolling progress diff --git a/app/models/onlylogs/grep.rb b/app/models/onlylogs/grep.rb index e9e2711..e7f1eae 100644 --- a/app/models/onlylogs/grep.rb +++ b/app/models/onlylogs/grep.rb @@ -19,16 +19,29 @@ def self.grep(pattern, file_path, start_position: 0, end_position: nil, regexp_m results = [] + # Set up parsing logic based on whether ripgrep includes byte offsets + parse_line = if Onlylogs.ripgrep_enabled? + ->(line) { + parts = line.split(":", 2) + [parts[0].to_i, parts[1] || ""] + } + else + ->(line) { [nil, line] } + end + IO.popen(command_args, err: "/dev/null") do |io| io.each_line do |line| - # Line numbers are no longer outputted by super_grep/super_ripgrep + byte_offset, content = parse_line.call(line.chomp) + # Use String.new to create a copy and prevent memory retention from IO buffers - content = String.new(line.chomp, encoding: Encoding::UTF_8).scrub + content = String.new(content, encoding: Encoding::UTF_8).scrub + + result = {byte_offset: byte_offset, content: content} if block_given? - yield content + yield result else - results << content + results << result end end end diff --git a/app/views/onlylogs/logs/index.html.erb b/app/views/onlylogs/logs/index.html.erb index 6cc8564..3bf5498 100644 --- a/app/views/onlylogs/logs/index.html.erb +++ b/app/views/onlylogs/logs/index.html.erb @@ -36,5 +36,5 @@ <% end %> <% end %>
- <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode } %> + <%= render partial: "onlylogs/shared/log_container", locals: { log_file_path: @log_file_path, tail: @max_lines, filter: @filter, autoscroll: @autoscroll, regexp_mode: @regexp_mode, start_position: @start_position, end_position: @end_position, mode: @mode } %> diff --git a/app/views/onlylogs/shared/_log_container.html.erb b/app/views/onlylogs/shared/_log_container.html.erb index 916274d..0a99220 100644 --- a/app/views/onlylogs/shared/_log_container.html.erb +++ b/app/views/onlylogs/shared/_log_container.html.erb @@ -1,11 +1,13 @@ -<%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true, regexp_mode: false) %> +<%# locals: (log_file_path:, tail: 100, filter: "", autoscroll: true, regexp_mode: false, start_position: nil, end_position: nil, mode: nil) %> <%= render "onlylogs/shared/log_container_styles" %> <% - mode = filter.blank? ? "live" : "search" - cursor_position = mode == "search" ? 0 : [File.size(log_file_path) - (tail * 100), 0].max + mode = local_assigns[:mode] || (filter.blank? ? "live" : "static") + start_position = local_assigns[:start_position] + end_position = local_assigns[:end_position] + byte_offset = request.params[:byte_offset] raise SecurityError, "File path not allowed" unless Onlylogs.file_path_permitted?(log_file_path) @@ -14,7 +16,8 @@
data-log-streamer-start-position-value="<%= start_position %>"<% end %> + <% if end_position.present? %>data-log-streamer-end-position-value="<%= end_position %>"<% end %> data-log-streamer-filter-value="<%= filter %>" data-log-streamer-auto-scroll-value="<%= autoscroll %>" data-log-streamer-regexp-mode-value="<%= regexp_mode %>" @@ -75,6 +78,9 @@
+
+ Results: 0 +
-
- Results: 0 -
+ <% if Onlylogs.ripgrep_enabled? %> +
+ +
+ <% end %> <% unless Onlylogs.ripgrep_enabled? %> ⚠️ <% end %> diff --git a/app/views/onlylogs/shared/_log_container_styles.html.erb b/app/views/onlylogs/shared/_log_container_styles.html.erb index 37a2cf3..40fb36d 100644 --- a/app/views/onlylogs/shared/_log_container_styles.html.erb +++ b/app/views/onlylogs/shared/_log_container_styles.html.erb @@ -24,8 +24,25 @@ } } + .onlylogs-expand-btn { + cursor: pointer; + padding: 0 0.5rem; + margin-right: 0.25rem; + background: none; + border: none; + color: #666; + font-weight: bold; + font-size: 1.1em; + line-height: 1; + + &:hover { + color: #333; + } + } + .clusterize-content { outline: 0; + min-width: max-content; } .color-success { @@ -74,8 +91,15 @@ pre { margin: 0 !important; - padding: 0.2rem; + padding: 0 0.2rem; + line-height: var(--onlylogs-line-height, 1.6rem); + min-height: var(--onlylogs-line-height, 1.6rem); word-break: break-word; /* allow breaking long tokens like UUIDs/SQL */ + white-space: pre; + } + + .highlighted-context-line { + background-color: rgba(255, 193, 7, 0.3); } a { @@ -121,15 +145,9 @@ align-items: center; gap: 1.5rem; - .live-mode-sticky { - opacity: 0.7; - cursor: not-allowed; - input[type="checkbox"] { - cursor: not-allowed; - } - } + } .clear-filter-button { position: absolute; right: 0.25rem; diff --git a/bin/super_ripgrep b/bin/super_ripgrep index 8dac08a..2774698 100755 --- a/bin/super_ripgrep +++ b/bin/super_ripgrep @@ -56,7 +56,7 @@ actual_color_regex='\x1b\[[0-9;]*m' query_regex="${query_regex//$placeholder/$actual_color_regex}" # Build ripgrep command -rg_cmd="rg --color=never --no-filename" +rg_cmd="rg --color=never --no-filename --byte-offset" [ -n "$max_matches" ] && rg_cmd="$rg_cmd --max-count=$max_matches" # Handle byte range if specified diff --git a/test/controllers/onlylogs/logs_controller_test.rb b/test/controllers/onlylogs/logs_controller_test.rb index 2432585..1d84426 100644 --- a/test/controllers/onlylogs/logs_controller_test.rb +++ b/test/controllers/onlylogs/logs_controller_test.rb @@ -115,5 +115,50 @@ class LogsControllerTest < ActionDispatch::IntegrationTest assert_select "[data-log-streamer-auto-scroll-value='false']" assert_select "[data-log-streamer-regexp-mode-value='true']" end + + test "index handles byte_offset param and sets search mode with byteoffset type" do + get "/onlylogs", params: {byte_offset: "50000"} + assert_response :success + # Should be in search mode with byteoffset search type + assert_select "[data-log-streamer-mode-value='search']" + assert_select "[data-log-streamer-search-type-value='byteoffset']" + assert_select "[data-log-streamer-filter-value='']" + assert_select "[data-log-streamer-auto-scroll-value='false']" + end + + test "index calculates cursor_position from byte_offset" do + get "/onlylogs", params: {byte_offset: "50000"} + assert_response :success + # cursor_position should be byte_offset - 10000 = 40000 + assert_select "[data-log-streamer-cursor-position-value='40000']" + end + + test "index sets end_position from byte_offset" do + get "/onlylogs", params: {byte_offset: "50000"} + assert_response :success + # end_position should be byte_offset + 10000 = 60000 + assert_select "[data-log-streamer-end-position-value='60000']" + end + + test "index sets search_type to filter when filter param is present" do + get "/onlylogs", params: {filter: "warning"} + assert_response :success + assert_select "[data-log-streamer-mode-value='search']" + assert_select "[data-log-streamer-search-type-value='filter']" + end + + test "index preserves autoscroll and regexp_mode params when not exploring" do + get "/onlylogs", params: {filter: "warning", autoscroll: "true", regexp_mode: "true"} + assert_response :success + assert_select "[data-log-streamer-auto-scroll-value='true']" + assert_select "[data-log-streamer-regexp-mode-value='true']" + end + + test "index byte_offset with cursor_position boundary at 0" do + get "/onlylogs", params: {byte_offset: "5000"} + assert_response :success + # cursor_position should be max(5000 - 10000, 0) = 0 + assert_select "[data-log-streamer-cursor-position-value='0']" + end end end diff --git a/test/models/onlylogs/grep_test.rb b/test/models/onlylogs/grep_test.rb index 7a8254a..1967985 100644 --- a/test/models/onlylogs/grep_test.rb +++ b/test/models/onlylogs/grep_test.rb @@ -28,31 +28,31 @@ def self.test_both_engine_modes(test_name, &block) test_both_engine_modes "it can grep for a simple string in a log file" do |engine_name| lines = Onlylogs::Grep.grep("[DEBUG]", @fixture_path) assert_equal 49, lines.length, "Failed with #{engine_name}" - assert_equal "[DEBUG] Initializing database connection - Line 2", lines.first - assert_equal "[DEBUG] Application metrics - Line 98", lines.last + assert_equal "[DEBUG] Initializing database connection - Line 2", lines.first[:content] + assert_equal "[DEBUG] Application metrics - Line 98", lines.last[:content] end test_both_engine_modes "it can grep a simple string in a log file and yield each returned line" do |engine_name| lines = [] - Onlylogs::Grep.grep("[DEBUG]", @fixture_path) do |content| - lines << content + Onlylogs::Grep.grep("[DEBUG]", @fixture_path) do |result| + lines << result end assert_equal 49, lines.length, "Failed with #{engine_name}" - assert_equal "[DEBUG] Initializing database connection - Line 2", lines.first - assert_equal "[DEBUG] Application metrics - Line 98", lines.last + assert_equal "[DEBUG] Initializing database connection - Line 2", lines.first[:content] + assert_equal "[DEBUG] Application metrics - Line 98", lines.last[:content] end test_both_engine_modes "it returns all INFO lines" do |engine_name| lines = Onlylogs::Grep.grep("[INFO]", @fixture_path) assert_equal 50, lines.length, "Failed with #{engine_name}" - assert_equal "[INFO] Application started - Line 1", lines.first - assert_equal "[INFO] Metrics collected: 150 data points - Line 99", lines.last + assert_equal "[INFO] Application started - Line 1", lines.first[:content] + assert_equal "[INFO] Metrics collected: 150 data points - Line 99", lines.last[:content] end test_both_engine_modes "it can grep a string when the line contains ansi colors" do |engine_name| expected_line = "\e[1m\e[36mActiveRecord::SchemaMigration Load (0.0ms)\e[0m \e[1m\e[34mSELECT ...\e[0m" lines = Onlylogs::Grep.grep("(0.0ms) SELECT", @special_lines_path) - assert_equal [expected_line], lines, "Failed with #{engine_name}" + assert_equal [expected_line], lines.map { |l| l[:content] }, "Failed with #{engine_name}" end test_both_engine_modes "it can grep a string with special regex characters" do |engine_name|