diff --git a/CHANGELOG.md b/CHANGELOG.md index 09fa1a3c..fa99dcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,14 @@ Unreleased * JSON formatter: group stats changed from `{ "covered_percent": 80.0 }` to full stats shape `{ "covered": 8, "missed": 2, "total": 10, "percent": 80.0, "strength": 0.0 }`. The key `covered_percent` is renamed to `percent`. * JSON formatter: `simplecov_json_formatter` gem is now built in. `require "simplecov_json_formatter"` continues to work via a shim. * `StringFilter` now matches at path-segment boundaries. `"lib"` matches `/lib/` but no longer matches `/library/`. Use a `Regexp` filter for substring matching. +* `SourceFile#project_filename` now returns a truly relative path with no leading separator (e.g. `lib/foo.rb` instead of `/lib/foo.rb`). This also removes the leading `/` from file path keys in `coverage.json` and from the filename in `minimum_coverage_by_file` error messages. Anchored `RegexFilter`s that relied on a leading `/` (e.g. `%r{^/lib/}`) should be rewritten (e.g. `%r{\Alib/}`). * Removed `docile` gem dependency. The `SimpleCov.configure` DSL block is now evaluated via `instance_exec` with instance variable proxying. +* Removed automatic activation of `JSONFormatter` when the `CC_TEST_REPORTER_ID` environment variable is set. The default `HTMLFormatter` now emits `coverage.json` alongside the HTML report (using `JSONFormatter.build_hash` to serialize the same payload `JSONFormatter` writes), so the env-var special case is no longer needed. ## Enhancements +* JSON formatter: `meta.timestamp` is now emitted with millisecond precision (`iso8601(3)`) so the concurrent-overwrite warning can distinguish writes within the same wall-clock second * JSON formatter: added `total` section with aggregate coverage statistics (covered, missed, total, percent, strength) for line, branch, and method coverage. Line stats additionally include `omitted` (count of blank/comment lines, i.e. lines that cannot be covered) -* JSON formatter: per-file output now includes `lines_covered_percent`, and when enabled: `branches_covered_percent`, `methods` array, and `methods_covered_percent` +* JSON formatter: per-file output now includes `total_lines`, `lines_covered_percent`, and when enabled: `branches_covered_percent`, `methods` array, and `methods_covered_percent` * JSON formatter: group stats now include full statistics for all enabled coverage types, not just line coverage percent * JSON formatter: added `silent:` keyword to `JSONFormatter.new` to suppress console output * Merged `simplecov-html` formatter into the main gem. A backward-compatibility shim ensures `require "simplecov-html"` still works. diff --git a/README.md b/README.md index a8e5f40e..5939001b 100644 --- a/README.md +++ b/README.md @@ -905,9 +905,6 @@ SimpleCov includes a `SimpleCov::Formatter::JSONFormatter` that provides you wit SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter ``` -> _Note:_ In case you plan to report your coverage results to CodeClimate services, know that SimpleCov will automatically use the -> JSON formatter along with the HTML formatter when the `CC_TEST_REPORTER_ID` variable is present in the environment. - > The JSON formatter was originally a separate gem called [simplecov_json_formatter](https://github.com/codeclimate-community/simplecov_json_formatter). It is now built in and loaded by default. Existing code that does `require "simplecov_json_formatter"` will continue to work. ## Available formatters, editor integrations and hosted services diff --git a/Rakefile b/Rakefile index 38855950..353ec052 100644 --- a/Rakefile +++ b/Rakefile @@ -40,7 +40,7 @@ task test: %i[spec cucumber test_html] task default: %i[rubocop spec cucumber test_html] namespace :assets do - desc "Compile frontend assets (JS + CSS) using esbuild" + desc "Compile frontend assets (HTML, JS, CSS) using esbuild" task :compile do frontend = File.expand_path("html_frontend", __dir__) outdir = File.expand_path("lib/simplecov/formatter/html_formatter/public", __dir__) @@ -62,5 +62,8 @@ namespace :assets do io.close_write File.write("#{outdir}/application.css", io.read) end + + # HTML: copy static index.html + FileUtils.cp(File.join(frontend, "src/index.html"), File.join(outdir, "index.html")) end end diff --git a/features/branch_coverage.feature b/features/branch_coverage.feature index 9740c4a1..b8128fb3 100644 --- a/features/branch_coverage.feature +++ b/features/branch_coverage.feature @@ -15,8 +15,7 @@ Feature: end """ When I open the coverage report generated with `bundle exec rspec spec` - Then the output should contain "Line coverage: 56 / 61 (91.80%)" - And the output should contain "Branch coverage: 2 / 4 (50.00%)" + Then the output should contain "56 / 61 LOC (91.8%) covered" And I should see the groups: | name | coverage | files | | All Files | 91.80% | 7 | diff --git a/features/config_json_formatter.feature b/features/config_json_formatter.feature index 6d0ab9c6..994d2c34 100644 --- a/features/config_json_formatter.feature +++ b/features/config_json_formatter.feature @@ -24,23 +24,3 @@ Feature: When I successfully run `bundle exec rake test` Then a JSON coverage report should have been generated in "coverage" And the output should contain "JSON Coverage report generated" - - Scenario: When CC_TEST_REPORTER_ID is set in the environment - Given SimpleCov for Test/Unit is configured with: - """ - require 'simplecov' - SimpleCov.at_exit do - puts SimpleCov.result.format! - end - SimpleCov.start do - add_group 'Libs', 'lib/faked_project/' - end - """ - And I set the environment variables to: - | variable | value | - | CC_TEST_REPORTER_ID | some-id | - - When I successfully run `bundle exec rake test` - - Then a JSON coverage report should have been generated in "coverage" - And the output should contain "JSON Coverage report generated" diff --git a/features/minimum_coverage_by_file.feature b/features/minimum_coverage_by_file.feature index c7978275..799d0d40 100644 --- a/features/minimum_coverage_by_file.feature +++ b/features/minimum_coverage_by_file.feature @@ -18,7 +18,7 @@ Feature: When I run `bundle exec rake test` Then the exit status should not be 0 - And the output should contain "Line coverage by file (75.00%) is below the expected minimum coverage (75.01%) in /lib/faked_project/framework_specific.rb." + And the output should contain "Line coverage by file (75.00%) is below the expected minimum coverage (75.01%) in lib/faked_project/framework_specific.rb." And the output should contain "SimpleCov failed with exit 2" Scenario: Just passing it @@ -48,8 +48,8 @@ Feature: When I run `bundle exec rake test` Then the exit status should not be 0 - And the output should contain "Line coverage by file (80.00%) is below the expected minimum coverage (90.00%) in /lib/faked_project/some_class.rb." - And the output should contain "Branch coverage by file (50.00%) is below the expected minimum coverage (70.00%) in /lib/faked_project/some_class.rb." + And the output should contain "Line coverage by file (80.00%) is below the expected minimum coverage (90.00%) in lib/faked_project/some_class.rb." + And the output should contain "Branch coverage by file (50.00%) is below the expected minimum coverage (70.00%) in lib/faked_project/some_class.rb." And the output should contain "SimpleCov failed with exit 2" @branch_coverage @@ -67,6 +67,6 @@ Feature: When I run `bundle exec rake test` Then the exit status should not be 0 - And the output should contain "Branch coverage by file (50.00%) is below the expected minimum coverage (70.00%) in /lib/faked_project/some_class.rb." + And the output should contain "Branch coverage by file (50.00%) is below the expected minimum coverage (70.00%) in lib/faked_project/some_class.rb." And the output should not contain "Line coverage (" And the output should contain "SimpleCov failed with exit 2" diff --git a/features/step_definitions/html_steps.rb b/features/step_definitions/html_steps.rb index 18d35137..a0fb6326 100644 --- a/features/step_definitions/html_steps.rb +++ b/features/step_definitions/html_steps.rb @@ -60,7 +60,29 @@ end Then /^there should be (\d+) skipped lines in the source files$/ do |expected_count| - count = page.evaluate_script("document.querySelectorAll('.source_files template').length > 0 ? Array.from(document.querySelectorAll('.source_files template')).reduce(function(sum, t) { return sum + t.content.querySelectorAll('ol li.skipped').length; }, 0) : document.querySelectorAll('.source_table ol li.skipped').length") + # Materialize all source files (renders them from coverage data), then count skipped lines + count = page.evaluate_script(<<~JS) + (function() { + // Check for pre-rendered templates (old simplecov-html) + var templates = document.querySelectorAll('.source_files template'); + if (templates.length > 0) { + return Array.from(templates).reduce(function(sum, t) { + return sum + t.content.querySelectorAll('ol li.skipped').length; + }, 0); + } + // New architecture: count skipped lines directly from coverage data + if (window.SIMPLECOV_DATA) { + var count = 0; + var coverage = window.SIMPLECOV_DATA.coverage; + Object.keys(coverage).forEach(function(fn) { + var lines = coverage[fn].lines; + lines.forEach(function(l) { if (l === 'ignored') count++; }); + }); + return count; + } + return document.querySelectorAll('.source_table ol li.skipped').length; + })() + JS expect(count).to eq(expected_count.to_i) end diff --git a/features/step_definitions/json_steps.rb b/features/step_definitions/json_steps.rb deleted file mode 100644 index fd94548e..00000000 --- a/features/step_definitions/json_steps.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -Then /^the JSON coverage report should match the output for the basic case$/ do - cd(".") do - json_report = JSON.parse(File.read("coverage/coverage.json")) - coverage_hash = json_report.fetch "coverage" - directory = Dir.pwd - - faked_project = coverage_hash.fetch("#{directory}/lib/faked_project.rb") - expect(faked_project["lines"]).to eq [nil, nil, 1, 1, 1, nil, nil, nil, 5, 3, nil, nil, 1] - expect(faked_project["lines_covered_percent"]).to be_a(Float) - - some_class = coverage_hash.fetch("#{directory}/lib/faked_project/some_class.rb") - expect(some_class["lines"]).to eq [nil, nil, 1, 1, 1, nil, 1, 2, nil, nil, 1, 1, nil, nil, 1, 1, 1, nil, 0, nil, nil, 0, nil, nil, 1, nil, 1, 0, nil, nil] - expect(some_class["lines_covered_percent"]).to be_a(Float) - end -end diff --git a/features/support/env.rb b/features/support/env.rb index 6dc1cfce..603e00a6 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -23,8 +23,23 @@ def extended(base) # Rack app for Capybara which returns the latest coverage report from Aruba temp project dir coverage_dir = File.expand_path("../../tmp/aruba/project/coverage/", __dir__) + +# Prevent the browser from caching coverage_data.js between scenario visits +class NoCacheMiddleware + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = @app.call(env) + headers["cache-control"] = "no-store" + [status, headers, body] + end +end + Capybara.app = Rack::Builder.new do - use Rack::Static, urls: {"/" => "index.html"}, root: coverage_dir, header_rules: [[:all, {"cache-control" => "no-store"}]] + use NoCacheMiddleware + use Rack::Static, urls: {"/" => "index.html"}, root: coverage_dir run Rack::Directory.new(coverage_dir) end.to_app diff --git a/features/test_unit_basic.feature b/features/test_unit_basic.feature index d5da65aa..872d8672 100644 --- a/features/test_unit_basic.feature +++ b/features/test_unit_basic.feature @@ -35,36 +35,3 @@ Feature: And the report should be based upon: | Unit Tests | - - Scenario: - Given SimpleCov for Test/Unit is configured with: - """ - ENV['CC_TEST_REPORTER_ID'] = "9719ac886877886b7e325d1e828373114f633683e429107d1221d25270baeabf" - require 'simplecov' - SimpleCov.start - """ - - When I open the coverage report generated with `bundle exec rake test` - Then I should see the groups: - | name | coverage | files | - | All Files | 91.37% | 6 | - - And I should see the source files: - | name | coverage | - | lib/faked_project.rb | 100.00% | - | lib/faked_project/some_class.rb | 80.00% | - | lib/faked_project/framework_specific.rb | 75.00% | - | lib/faked_project/meta_magic.rb | 100.00% | - | test/meta_magic_test.rb | 100.00% | - | test/some_class_test.rb | 100.00% | - - # Note: faked_test.rb is not appearing here since that's the first unit test file - # loaded by Rake, and only there test_helper is required, which then loads simplecov - # and triggers tracking of all other loaded files! Solution for this would be to - # configure simplecov in this first test instead of test_helper. - - And the report should be based upon: - | Unit Tests | - - And a JSON coverage report should have been generated - And the JSON coverage report should match the output for the basic case diff --git a/html_frontend/src/app.ts b/html_frontend/src/app.ts index b4db4dfd..bc2a44bc 100644 --- a/html_frontend/src/app.ts +++ b/html_frontend/src/app.ts @@ -1,8 +1,87 @@ import hljs from 'highlight.js/lib/core'; import ruby from 'highlight.js/lib/languages/ruby'; +import { hash } from './hash'; hljs.registerLanguage('ruby', ruby); +// --- Types for coverage data --------------------------------- + +interface CoverageData { + meta: { + simplecov_version: string; + command_name: string; + project_name: string; + timestamp: string; + root: string; + branch_coverage: boolean; + method_coverage: boolean; + }; + total: StatGroup; + coverage: Record; + groups: Record; +} + +interface StatGroup { + lines: CoverageStat; + branches?: CoverageStat; + methods?: CoverageStat; +} + +interface CoverageStat { + covered: number; + missed: number; + total: number; + percent: number; + strength: number; +} + +interface FileCoverage { + lines: (number | null | 'ignored')[]; + source: string[]; + lines_covered_percent: number; + covered_lines: number; + missed_lines: number; + total_lines: number; + branches?: BranchEntry[]; + branches_covered_percent?: number; + covered_branches?: number; + missed_branches?: number; + total_branches?: number; + methods?: MethodEntry[]; + methods_covered_percent?: number; + covered_methods?: number; + missed_methods?: number; + total_methods?: number; +} + +interface BranchEntry { + type: string; + start_line: number; + end_line: number; + coverage: number | 'ignored'; + inline: boolean; + report_line: number; +} + +interface MethodEntry { + name: string; + start_line: number; + end_line: number; + coverage: number | 'ignored'; +} + +interface GroupData { + lines: CoverageStat; + branches?: CoverageStat; + methods?: CoverageStat; + files?: string[]; +} + +declare global { + interface Window { + SIMPLECOV_DATA: CoverageData; + } +} // --- Constants ------------------------------------------------ @@ -39,15 +118,24 @@ function on( } } +function escapeHTML(str: string): string { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; +} + // --- Timeago -------------------------------------------------- +// Thresholds in seconds and their display unit. Ordered largest-first so +// the first match wins. +const TIMEAGO_INTERVALS: [number, string][] = [ + [31536000, 'year'], [2592000, 'month'], [86400, 'day'], + [3600, 'hour'], [60, 'minute'], [1, 'second'] +]; + function timeago(date: Date): string { const seconds = Math.floor((Date.now() - date.getTime()) / 1000); - const intervals: [number, string][] = [ - [31536000, 'year'], [2592000, 'month'], [86400, 'day'], - [3600, 'hour'], [60, 'minute'], [1, 'second'] - ]; - for (const [secs, label] of intervals) { + for (const [secs, label] of TIMEAGO_INTERVALS) { const count = Math.floor(seconds / secs); if (count >= 1) { return count === 1 ? `about 1 ${label} ago` : `${count} ${label}s ago`; @@ -56,6 +144,25 @@ function timeago(date: Date): string { return 'just now'; } +// Returns the number of milliseconds until the displayed timeago text would +// change for the given date. For example, if "3 minutes ago" is displayed, +// the text won't change until the 4th minute boundary, so we return the +// remaining ms until that boundary (plus a small buffer). +function timeagoNextTick(date: Date): number { + const elapsedSec = (Date.now() - date.getTime()) / 1000; + for (const [secs] of TIMEAGO_INTERVALS) { + const count = Math.floor(elapsedSec / secs); + if (count >= 1) { + // The text will next change when elapsed reaches (count + 1) * secs + const nextBoundary = (count + 1) * secs; + // Add 500ms buffer so we land just after the boundary, not right on it + return Math.max((nextBoundary - elapsedSec) * 1000 + 500, 1000); + } + } + // Currently "just now" — update in 1 second (when it becomes "1 second ago") + return 1000; +} + // --- Coverage helpers ----------------------------------------- function pctClass(pct: number): string { @@ -68,31 +175,341 @@ function fmtNum(n: number): string { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } -function updateCoverageCells( - container: Element, - prefix: string, - covered: number, - total: number -): void { - const covCell = $(prefix + '-pct', container); - const numEl = $(prefix + '-num', container); - const denEl = $(prefix + '-den', container); - if (total === 0) { - if (covCell) { covCell.innerHTML = ''; covCell.className = covCell.className.replace(/green|yellow|red/g, '').trim(); } - if (numEl) numEl.textContent = ''; - if (denEl) denEl.textContent = ''; - return; +function fmtPct(pct: number): string { + return (Math.floor(pct * 100) / 100).toFixed(2); +} + +// Populated by precomputeFileIds before any rendering happens. +const fileIds: Record = {}; + +function fileId(filename: string): string { + return fileIds[filename]; +} + +async function precomputeFileIds(filenames: string[]): Promise { + const hashes = await Promise.all(filenames.map(hash)); + filenames.forEach((fn, i) => { fileIds[fn] = hashes[i]; }); +} + +function toHtmlId(value: string): string { + return value.replace(/^[^a-zA-Z]+/, '').replace(/[^a-zA-Z0-9\-_]/g, ''); +} + +// --- Coverage rendering helpers ------------------------------- + +function renderCoverageBar(pct: number): string { + const css = pctClass(pct); + const width = fmtPct(pct); + return `
`; +} + +function renderCoverageCells(pct: number, covered: number, total: number, type: string, totals: boolean): string { + const css = pctClass(pct); + const pctStr = fmtPct(pct); + const barAndPct = `
${renderCoverageBar(pct)}${pctStr}%
`; + + if (totals) { + return `${barAndPct}` + + `${fmtNum(covered)}/` + + `${fmtNum(total)}`; } - const p = (covered * 100.0) / total; - const cls = pctClass(p); - if (covCell) { - covCell.innerHTML = `
${p.toFixed(2)}%
`; - covCell.className = `${covCell.className.replace(/green|yellow|red/g, '').trim()} ${cls}`; + const order = ` data-order="${fmtPct(pct)}"`; + return `${barAndPct}` + + `${fmtNum(covered)}/` + + `${fmtNum(total)}`; +} + +function renderHeaderCells(label: string, type: string, coveredLabel: string, totalLabel: string): string { + return ` +
+ ${label} +
+ + +
+
+ + ${coveredLabel} + ${totalLabel}`; +} + +// --- Rendering: Coverage summary (per source file) ------------ + +function renderTypeSummary(type: string, label: string, covered: number, total: number, enabled: boolean, opts: { suffix?: string; missedClass?: string; toggle?: boolean } = {}): string { + if (!enabled) { + return `
\n ${label}: disabled\n
`; } - if (numEl) numEl.textContent = fmtNum(covered) + '/'; - if (denEl) denEl.textContent = fmtNum(total); + const missed = total - covered; + const pct = total > 0 ? (covered * 100.0 / total) : 100.0; + const css = pctClass(pct); + const suffix = opts.suffix || 'covered'; + const missedClass = opts.missedClass || 'red'; + + let parts = `
\n ${label}: ` + + `${fmtPct(pct)}%` + + ` ${covered}/${total} ${suffix}`; + + if (missed > 0) { + const missedHtml = opts.toggle + ? `${missed} missed` + : `${missed} missed`; + parts += `,\n ${missedHtml}`; + } + parts += '\n
'; + return parts; } +function renderCoverageSummary( + coveredLines: number, totalLines: number, + coveredBranches: number, totalBranches: number, + coveredMethods: number, totalMethods: number, + branchCoverage: boolean, methodCoverage: boolean, + showMethodToggle: boolean +): string { + return '
' + + renderTypeSummary('line', 'Line coverage', coveredLines, totalLines, true, { suffix: 'relevant lines covered' }) + + renderTypeSummary('branch', 'Branch coverage', coveredBranches, totalBranches, branchCoverage, { missedClass: 'missed-branch-text' }) + + renderTypeSummary('method', 'Method coverage', coveredMethods, totalMethods, methodCoverage, { missedClass: 'missed-method-text-color', toggle: showMethodToggle }) + + '
'; +} + +// --- Rendering: Source file view ------------------------------ + +function lineStatus( + lineIndex: number, + lineCov: number | null | 'ignored', + branchesReport: Record, + missedMethodLines: Set, + branchCoverage: boolean, + methodCoverage: boolean +): string { + const lineNum = lineIndex + 1; + + // Check basic status + if (lineCov === 'ignored') return 'skipped'; + + // Branch miss takes priority + if (branchCoverage) { + const branches = branchesReport[lineNum]; + if (branches && branches.some(([, count]) => count === 0)) return 'missed-branch'; + } + + // Method miss + if (methodCoverage && missedMethodLines.has(lineNum)) return 'missed-method'; + + if (lineCov === null) return 'never'; + if (lineCov === 0) return 'missed'; + return 'covered'; +} + +function buildBranchesReport(branches: BranchEntry[] | undefined): Record { + const report: Record = {}; + if (!branches) return report; + for (const b of branches) { + if (b.coverage === 'ignored') continue; + if (!report[b.report_line]) report[b.report_line] = []; + report[b.report_line].push([b.type, b.coverage as number]); + } + return report; +} + +function buildMissedMethodLines(methods: MethodEntry[] | undefined): Set { + const set = new Set(); + if (!methods) return set; + for (const m of methods) { + if (m.coverage === 0 && m.start_line && m.end_line) { + for (let i = m.start_line; i <= m.end_line; i++) set.add(i); + } + } + return set; +} + +function renderSourceFile(filename: string, data: FileCoverage, branchCoverage: boolean, methodCoverage: boolean): string { + const id = fileId(filename); + const coveredLines = data.covered_lines; + const totalLines = data.total_lines; + const coveredBranches = branchCoverage ? (data.covered_branches || 0) : 0; + const totalBranches = branchCoverage ? (data.total_branches || 0) : 0; + const coveredMethods = methodCoverage ? (data.covered_methods || 0) : 0; + const totalMethods = methodCoverage ? (data.total_methods || 0) : 0; + + const missedMethodsList = (data.methods || []).filter(m => m.coverage === 0); + const showMethodToggle = methodCoverage && missedMethodsList.length > 0; + + const branchesReport = buildBranchesReport(data.branches); + const missedMethodLineSet = buildMissedMethodLines(data.methods); + + let html = `
`; + html += '
'; + html += `

${escapeHTML(filename)}

`; + html += renderCoverageSummary(coveredLines, totalLines, coveredBranches, totalBranches, coveredMethods, totalMethods, branchCoverage, methodCoverage, showMethodToggle); + + if (showMethodToggle) { + html += ''; + } + html += '
'; + + // Source lines + html += '
    '; + for (let i = 0; i < data.source.length; i++) { + const lineCov = data.lines[i]; + const status = lineStatus(i, lineCov, branchesReport, missedMethodLineSet, branchCoverage, methodCoverage); + const lineNum = i + 1; + const hitsAttr = lineCov !== null && lineCov !== 'ignored' ? ` data-hits="${lineCov}"` : ''; + + html += `
  1. `; + + if (status === 'covered' || (lineCov !== null && lineCov !== 'ignored' && lineCov !== 0)) { + html += ``; + } else if (lineCov === 'ignored') { + html += ''; + } + + if (branchCoverage) { + const lineBranches = branchesReport[lineNum]; + if (lineBranches) { + for (const [branchType, hitCount] of lineBranches) { + html += ``; + } + } + } + + html += `${escapeHTML(data.source[i])}
  2. `; + } + html += '
'; + return html; +} + +// --- Rendering: File list table -------------------------------- + +function renderFileList( + title: string, + filenames: string[], + stats: StatGroup, + allCoverage: Record, + branchCoverage: boolean, + methodCoverage: boolean +): string { + const containerId = toHtmlId(title); + + const lineStats = stats.lines; + const branchStats = branchCoverage ? stats.branches : undefined; + const methodStats = methodCoverage ? stats.methods : undefined; + + let html = `
`; + html += `${escapeHTML(title)}`; + html += `${fmtPct(lineStats.percent)}%`; + + html += '
'; + html += ``; + html += renderHeaderCells('Line Coverage', 'line', 'Covered', 'Lines'); + if (branchCoverage) html += renderHeaderCells('Branch Coverage', 'branch', 'Covered', 'Branches'); + if (methodCoverage) html += renderHeaderCells('Method Coverage', 'method', 'Covered', 'Methods'); + html += ''; + + // Totals row + const fileLabel = filenames.length === 1 ? 'file' : 'files'; + html += ``; + html += renderCoverageCells(lineStats.percent, lineStats.covered, lineStats.total, 'line', true); + if (branchStats) html += renderCoverageCells(branchStats.percent, branchStats.covered, branchStats.total, 'branch', true); + if (methodStats) html += renderCoverageCells(methodStats.percent, methodStats.covered, methodStats.total, 'method', true); + html += ''; + + // File rows + for (const fn of filenames) { + const f = allCoverage[fn]; + if (!f) continue; + const id = fileId(fn); + + let dataAttrs = `data-covered-lines="${f.covered_lines}" data-relevant-lines="${f.total_lines}"`; + if (branchCoverage) { + dataAttrs += ` data-covered-branches="${f.covered_branches || 0}" data-total-branches="${f.total_branches || 0}"`; + } + if (methodCoverage) { + dataAttrs += ` data-covered-methods="${f.covered_methods || 0}" data-total-methods="${f.total_methods || 0}"`; + } + + html += ``; + html += ``; + html += renderCoverageCells(f.lines_covered_percent, f.covered_lines, f.total_lines, 'line', false); + if (branchCoverage) { + html += renderCoverageCells(f.branches_covered_percent || 100.0, f.covered_branches || 0, f.total_branches || 0, 'branch', false); + } + if (methodCoverage) { + html += renderCoverageCells(f.methods_covered_percent || 100.0, f.covered_methods || 0, f.total_methods || 0, 'method', false); + } + html += ''; + } + + html += '
File Name
${fmtNum(filenames.length)} ${fileLabel}
${escapeHTML(fn)}
'; + return html; +} + +// --- Rendering: Full page from data --------------------------- + +function renderPage(data: CoverageData): void { + const meta = data.meta; + const branchCoverage = meta.branch_coverage; + const methodCoverage = meta.method_coverage; + + // Page title and favicon + document.title = `Code coverage for ${meta.project_name}`; + const allFiles = Object.keys(data.coverage); + const overallPct = data.total.lines.total > 0 ? data.total.lines.percent : 100.0; + const faviconLink = document.createElement('link'); + faviconLink.rel = 'icon'; + faviconLink.type = 'image/png'; + faviconLink.href = `favicon_${pctClass(overallPct)}.png`; + document.head.appendChild(faviconLink); + + if (branchCoverage) document.body.setAttribute('data-branch-coverage', 'true'); + + // Content: file lists + const content = document.getElementById('content')!; + content.innerHTML = renderFileList('All Files', allFiles, data.total, data.coverage, branchCoverage, methodCoverage); + + for (const groupName of Object.keys(data.groups)) { + const group = data.groups[groupName]; + const groupFiles = group.files || []; + content.innerHTML += renderFileList(groupName, groupFiles, group, data.coverage, branchCoverage, methodCoverage); + } + + // Build id → filename lookup map for O(1) source file materialization + const idToFilename: Record = {}; + for (const fn of allFiles) { + idToFilename[fileId(fn)] = fn; + } + (window as any)._simplecovIdMap = idToFilename; + (window as any)._simplecovFiles = data.coverage; + (window as any)._simplecovBranchCoverage = branchCoverage; + (window as any)._simplecovMethodCoverage = methodCoverage; + + // Footer + const timestamp = new Date(meta.timestamp); + const footer = document.getElementById('footer')!; + footer.innerHTML = `Generated ${timestamp.toISOString()}` + + ` by simplecov v${escapeHTML(meta.simplecov_version)}` + + ` using ${escapeHTML(meta.command_name)}`; + + // Source legend + const legend = document.getElementById('source-legend')!; + let legendHtml = 'Covered' + + 'Skipped' + + 'Missed line'; + if (branchCoverage) { + legendHtml += 'Missed branch'; + } + if (methodCoverage) { + legendHtml += 'Missed method'; + } + legend.innerHTML = legendHtml; +} + + // --- Sort state ----------------------------------------------- interface SortEntry { @@ -284,22 +701,53 @@ function updateTotalsRow(container: Element): void { } } -// --- Template materialization ---------------------------------- +function updateCoverageCells( + container: Element, + prefix: string, + covered: number, + total: number +): void { + const covCell = $(prefix + '-pct', container); + const numEl = $(prefix + '-num', container); + const denEl = $(prefix + '-den', container); + if (total === 0) { + if (covCell) { covCell.innerHTML = ''; covCell.className = covCell.className.replace(/green|yellow|red/g, '').trim(); } + if (numEl) numEl.textContent = ''; + if (denEl) denEl.textContent = ''; + return; + } + const p = (covered * 100.0) / total; + const cls = pctClass(p); + if (covCell) { + covCell.innerHTML = `
${renderCoverageBar(p)}${p.toFixed(2)}%
`; + covCell.className = `${covCell.className.replace(/green|yellow|red/g, '').trim()} ${cls}`; + } + if (numEl) numEl.textContent = fmtNum(covered) + '/'; + if (denEl) denEl.textContent = fmtNum(total); +} + +// --- Source file rendering (on demand) ------------------------- function materializeSourceFile(sourceFileId: string): HTMLElement | null { const existing = document.getElementById(sourceFileId); if (existing) return existing; - const tmpl = document.getElementById('tmpl-' + sourceFileId) as HTMLTemplateElement | null; - if (!tmpl) return null; + const idMap = (window as any)._simplecovIdMap as Record; + const coverage = (window as any)._simplecovFiles as Record; + const branchCov = (window as any)._simplecovBranchCoverage as boolean; + const methodCov = (window as any)._simplecovMethodCoverage as boolean; - const clone = document.importNode(tmpl.content, true); - document.querySelector('.source_files')!.appendChild(clone); + const targetFilename = idMap[sourceFileId]; + if (!targetFilename) return null; - const el = document.getElementById(sourceFileId); - if (el) { - $$('pre code', el).forEach(e => { hljs.highlightElement(e as HTMLElement); }); - } + const html = renderSourceFile(targetFilename, coverage[targetFilename], branchCov, methodCov); + const container = document.querySelector('.source_files')!; + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const el = wrapper.firstElementChild as HTMLElement; + container.appendChild(el); + + $$('pre code', el).forEach(e => { hljs.highlightElement(e as HTMLElement); }); return el; } @@ -328,9 +776,6 @@ function equalizeBarWidths(): void { wrapper.style.visibility = 'hidden'; - // Binary search for the largest bar width that fits without scrolling, - // scaling gradually from MAX_BAR_WIDTH down to MIN_BAR_WIDTH. - // If the table overflows even at MIN_BAR_WIDTH, use MIN_BAR_WIDTH anyway. let lo = MIN_BAR_WIDTH, hi = MAX_BAR_WIDTH; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); @@ -559,16 +1004,44 @@ function initDarkMode(): void { // --- Initialization ------------------------------------------- -document.addEventListener('DOMContentLoaded', function () { - // Timeago - $$('abbr.timeago').forEach(el => { - const date = new Date(el.getAttribute('title') || ''); - if (!isNaN(date.getTime())) el.textContent = timeago(date); - }); +// Wait for coverage data to be available, then render +async function init(): Promise { + if (!window.SIMPLECOV_DATA) { + // Data not loaded yet - the coverage_data.js script tag is at the end of body, + // so if DOMContentLoaded fires first, wait for it + window.addEventListener('load', init); + return; + } + + const data = window.SIMPLECOV_DATA; + + // Show loading indicator + const loadingEl = document.getElementById('loading'); + if (loadingEl) loadingEl.style.display = ''; + + // Web Crypto's digest API is async, so resolve every file's id up front; + // every renderer downstream looks them up synchronously. + await precomputeFileIds(Object.keys(data.coverage)); + + // Render all content from data + renderPage(data); + + // Timeago — schedule the next update for exactly when the text would change + function scheduleTimeago(): void { + let minDelay = Infinity; + $$('abbr.timeago').forEach(el => { + const date = new Date(el.getAttribute('title') || ''); + if (isNaN(date.getTime())) return; + el.textContent = timeago(date); + minDelay = Math.min(minDelay, timeagoNextTick(date)); + }); + if (minDelay < Infinity) setTimeout(scheduleTimeago, minDelay); + } + scheduleTimeago(); initDarkMode(); - // Table sorting — compute td index dynamically at click time + // Table sorting function thToTdIndex(table: Element, clickedTh: Element): number { let idx = 0; for (const th of $$('thead tr:first-child th', table)) { @@ -609,7 +1082,6 @@ document.addEventListener('DOMContentLoaded', function () { document.addEventListener('keydown', (e: KeyboardEvent) => { const inInput = (e.target as Element).matches('input, select, textarea'); - // "/" to focus search if (e.key === '/' && !inInput) { e.preventDefault(); const visible = $$('.file_list_container').filter(c => (c as HTMLElement).style.display !== 'none'); @@ -618,7 +1090,6 @@ document.addEventListener('DOMContentLoaded', function () { return; } - // Escape — close dialog or clear focus if (e.key === 'Escape') { if (dialog.open) { e.preventDefault(); @@ -633,14 +1104,12 @@ document.addEventListener('DOMContentLoaded', function () { if (inInput) return; - // Source view shortcuts (dialog open) if (dialog.open) { if (e.key === 'n' && !e.shiftKey) { e.preventDefault(); jumpToMissedLine(1); } if (e.key === 'N' || (e.key === 'n' && e.shiftKey) || e.key === 'p') { e.preventDefault(); jumpToMissedLine(-1); } return; } - // File list shortcuts (dialog closed) if (e.key === 'j') { e.preventDefault(); moveFocus(1); } if (e.key === 'k') { e.preventDefault(); moveFocus(-1); } if (e.key === 'Enter' && focusedRow) { e.preventDefault(); openFocusedRow(); } @@ -684,7 +1153,6 @@ document.addEventListener('DOMContentLoaded', function () { window.addEventListener('hashchange', navigateToHash); // Tab system - document.querySelector('.source_files')!.setAttribute('style', 'display:none'); $$('.file_list_container').forEach(c => (c as HTMLElement).style.display = 'none'); $$('.file_list_container').forEach(container => { @@ -707,17 +1175,13 @@ document.addEventListener('DOMContentLoaded', function () { window.location.hash = this.getAttribute('href')!.replace('#', '#_'); }); - // Equalize bar column widths within each table + // Equalize bar column widths window.addEventListener('resize', scheduleEqualizeBarWidths); // Initial state navigateToHash(); // Finalize loading - clearInterval((window as any)._simplecovLoadingTimer); - clearTimeout((window as any)._simplecovShowTimeout); - - const loadingEl = document.getElementById('loading'); if (loadingEl) { loadingEl.style.transition = 'opacity 0.3s'; loadingEl.style.opacity = '0'; @@ -727,7 +1191,7 @@ document.addEventListener('DOMContentLoaded', function () { const wrapperEl = document.getElementById('wrapper'); if (wrapperEl) wrapperEl.classList.remove('hide'); - // Equalize bar widths now that wrapper is visible equalizeBarWidths(); +} -}); +document.addEventListener('DOMContentLoaded', init); diff --git a/html_frontend/src/hash.ts b/html_frontend/src/hash.ts new file mode 100644 index 00000000..c82c0be9 --- /dev/null +++ b/html_frontend/src/hash.ts @@ -0,0 +1,20 @@ +// SHA-1 via Web Crypto, truncated to 8 hex characters. +// +// Used to derive stable, fixed-length, URL/HTML-safe IDs from source-file +// paths. Those IDs become HTML element ids and URL hash fragments +// (e.g. `#a1b2c3d4-L42`), where raw filenames would be unsafe (slashes, +// dots, spaces, non-ASCII characters) and unwieldy in URLs. +// +// SHA-1 is overkill in the security sense — collisions are not a threat +// model here — but Web Crypto is the only built-in browser hash and it +// happens to deliver one. Callers must await; resolve every id once +// upfront so the synchronous render path can look them up freely. +export async function hash(str: string): Promise { + const bytes = new TextEncoder().encode(str); + const buf = await crypto.subtle.digest('SHA-1', bytes); + let out = ''; + for (const b of new Uint8Array(buf, 0, 4)) { + out += ('0' + b.toString(16)).slice(-2); + } + return out; +} diff --git a/html_frontend/src/index.html b/html_frontend/src/index.html new file mode 100644 index 00000000..f3262ec2 --- /dev/null +++ b/html_frontend/src/index.html @@ -0,0 +1,55 @@ + + + + + + Code Coverage + + + + + + + + +
+
+
    + +
    + +
    + + + + +
    + + +
    +
    +
    + +
    +
    +
    + + + + diff --git a/lib/simplecov.rb b/lib/simplecov.rb index 0430b120..c408bc36 100644 --- a/lib/simplecov.rb +++ b/lib/simplecov.rb @@ -30,6 +30,11 @@ class << self attr_accessor :running, :pid + # When this process started tracking coverage. Captured by SimpleCov.start + # so JSONFormatter can detect when an existing coverage.json was written + # by a sibling process running concurrently. + attr_accessor :process_start_time + # Basically, should we take care of at_exit behavior or something else? # Used by the minitest plugin. See lib/minitest/simplecov_plugin.rb attr_accessor :external_at_exit @@ -62,6 +67,7 @@ def start(profile = nil, &block) @result = nil self.pid = Process.pid + self.process_start_time = Time.now start_coverage_measurement end diff --git a/lib/simplecov/default_formatter.rb b/lib/simplecov/default_formatter.rb deleted file mode 100644 index d81afe51..00000000 --- a/lib/simplecov/default_formatter.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require_relative "formatter/html_formatter" -module SimpleCov - module Formatter - class << self - def from_env(env) - formatters = [SimpleCov::Formatter::HTMLFormatter] - - # When running under a CI that uses CodeClimate, JSON output is expected - formatters.push(SimpleCov::Formatter::JSONFormatter) if env.fetch("CC_TEST_REPORTER_ID", nil) - - formatters - end - end - end -end diff --git a/lib/simplecov/defaults.rb b/lib/simplecov/defaults.rb index 2d82d952..3f0a948b 100644 --- a/lib/simplecov/defaults.rb +++ b/lib/simplecov/defaults.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true -# Load default formatter gem require "pathname" -require_relative "default_formatter" +require_relative "formatter/html_formatter" require_relative "profiles/root_filter" require_relative "profiles/test_frameworks" require_relative "profiles/bundler_filter" @@ -11,9 +10,7 @@ # Default configuration SimpleCov.configure do - formatter SimpleCov::Formatter::MultiFormatter.new( - SimpleCov::Formatter.from_env(ENV) - ) + formatter SimpleCov::Formatter::HTMLFormatter load_profile "bundler_filter" load_profile "hidden_filter" diff --git a/lib/simplecov/filter.rb b/lib/simplecov/filter.rb index 858771f9..969a69bc 100644 --- a/lib/simplecov/filter.rb +++ b/lib/simplecov/filter.rb @@ -65,18 +65,20 @@ def matches?(source_file) def segment_pattern @segment_pattern ||= begin normalized = filter_argument.delete_prefix("/") + escaped = Regexp.escape(normalized) + boundary = '(?:\A|/)' if normalized.include?(".") # Contains a dot — looks like a filename pattern. Allow substring # match within the last path segment (e.g. "test.rb" matches - # "faked_test.rb") while still anchoring to a "/" boundary. - %r{/[^/]*#{Regexp.escape(normalized)}} + # "faked_test.rb") while still anchoring to a segment boundary. + /#{boundary}[^\/]*#{escaped}/ elsif normalized.end_with?("/") # Trailing slash signals directory-only matching - %r{/#{Regexp.escape(normalized)}} + /#{boundary}#{escaped}/ else # No dot — looks like a directory or path. Require segment-boundary - # match so "lib" matches "/lib/" but not "/library/". - %r{/#{Regexp.escape(normalized)}(?=[/.]|$)} + # match so "lib" matches "lib/" but not "library/". + /#{boundary}#{escaped}(?=[\/.]|\z)/ end end end diff --git a/lib/simplecov/formatter/html_formatter.rb b/lib/simplecov/formatter/html_formatter.rb index 47517f40..0a6d5a5e 100644 --- a/lib/simplecov/formatter/html_formatter.rb +++ b/lib/simplecov/formatter/html_formatter.rb @@ -1,104 +1,60 @@ # frozen_string_literal: true -require "erb" require "fileutils" -require "time" -require_relative "html_formatter/view_helpers" +require "json" +require_relative "json_formatter" module SimpleCov module Formatter - # Generates an HTML coverage report from SimpleCov results. + # Generates an HTML coverage report by writing a coverage_data.js file + # alongside pre-compiled static assets (index.html, application.js/css). + # Uses JSONFormatter.build_hash to serialize the result, then writes both + # coverage.json and coverage_data.js from the same in-memory hash. class HTMLFormatter - VERSION = "0.13.2" + DATA_FILENAME = "coverage_data.js" - # Only have a few content types, just hardcode them - CONTENT_TYPES = { - ".js" => "text/javascript", - ".png" => "image/png", - ".gif" => "image/gif", - ".css" => "text/css" - }.freeze - - include ViewHelpers - - def initialize(silent: false, inline_assets: false) - @branch_coverage = SimpleCov.branch_coverage? - @method_coverage = SimpleCov.method_coverage? - @templates = {} - @inline_assets = inline_assets || ENV.key?("SIMPLECOV_INLINE_ASSETS") - @public_assets_dir = File.join(__dir__, "html_formatter/public/") + def initialize(silent: false) @silent = silent end def format(result) - unless @inline_assets - Dir[File.join(@public_assets_dir, "*")].each do |path| - FileUtils.cp_r(path, asset_output_path, remove_destination: true) - end - end + json = JSON.pretty_generate(JSONFormatter.build_hash(result)) - File.write(File.join(output_path, "index.html"), template("layout").result(binding), mode: "wb") + File.write(File.join(output_path, JSONFormatter::FILENAME), json) + File.write(File.join(output_path, DATA_FILENAME), "window.SIMPLECOV_DATA = #{json};\n", mode: "wb") + + copy_static_assets puts output_message(result) unless @silent end - private - - def branch_coverage? - @branch_coverage + # Generate HTML from a pre-existing coverage.json file without + # needing a live SimpleCov::Result or even a running test suite. + def format_from_json(json_path, output_dir) + FileUtils.mkdir_p(output_dir) + json = File.read(json_path) + File.write(File.join(output_dir, DATA_FILENAME), "window.SIMPLECOV_DATA = #{json};\n", mode: "wb") + copy_static_assets(output_dir) end - def method_coverage? - @method_coverage - end + private - def output_message(result) - lines = ["Coverage report generated for #{result.command_name} to #{output_path}"] - lines << "Line coverage: #{render_stats(result, :line)}" - lines << "Branch coverage: #{render_stats(result, :branch)}" if branch_coverage? - lines << "Method coverage: #{render_stats(result, :method)}" if method_coverage? - lines.join("\n") + def copy_static_assets(dest_dir = output_path) + Dir[File.join(public_dir, "*")].each do |path| + FileUtils.cp_r(path, dest_dir, remove_destination: true) + end end - def template(name) - @templates[name] ||= ERB.new(File.read(File.join(__dir__, "html_formatter/views/", "#{name}.erb")), trim_mode: "-") + def output_message(result) + "Coverage report generated for #{result.command_name} to #{output_path}. " \ + "#{result.covered_lines} / #{result.total_lines} LOC (#{result.covered_percent.round(2)}%) covered." end def output_path SimpleCov.coverage_path end - def asset_output_path - @asset_output_path ||= File.join(output_path, "assets", VERSION).tap do |path| - FileUtils.mkdir_p(path) - end - end - - def assets_path(name) - return asset_inline(name) if @inline_assets - - File.join("./assets", VERSION, name) - end - - def asset_inline(name) - path = File.join(@public_assets_dir, name) - base64_content = [File.read(path)].pack("m0") - "data:#{CONTENT_TYPES.fetch(File.extname(name))};base64,#{base64_content}" - end - - def formatted_source_file(source_file) - template("source_file").result(binding) - rescue Encoding::CompatibilityError => e - puts "Encoding problems with file #{source_file.filename}. Simplecov/ERB can't handle non ASCII characters in filenames. Error: #{e.message}." - %(

    Encoding Error

    #{ERB::Util.html_escape(e.message)}

    ) - end - - def formatted_file_list(title, source_files) - template("file_list").result(binding) - end - - def render_stats(result, criterion) - stats = result.coverage_statistics.fetch(criterion) - Kernel.format("%d / %d (%.2f%%)", covered: stats.covered, total: stats.total, percent: stats.percent) + def public_dir + File.join(__dir__, "html_formatter/public/") end end end diff --git a/lib/simplecov/formatter/html_formatter/coverage_helpers.rb b/lib/simplecov/formatter/html_formatter/coverage_helpers.rb deleted file mode 100644 index a2fc0dfe..00000000 --- a/lib/simplecov/formatter/html_formatter/coverage_helpers.rb +++ /dev/null @@ -1,130 +0,0 @@ -# frozen_string_literal: true - -module SimpleCov - module Formatter - class HTMLFormatter - # Helpers for rendering coverage bars, cells, and summaries in ERB templates. - module CoverageHelpers - def coverage_bar(pct) - css = coverage_css_class(pct) - width = Kernel.format("%.1f", pct.floor(1)) - fill = %(
    ) - %(
    #{fill}
    ) - end - - def coverage_cells(pct, covered, total, type:, totals: false) - cov_cls, num_cls, den_cls, order = coverage_cell_attrs(pct, type, totals) - pct_str = Kernel.format("%.2f", pct.floor(2)) - bar_and_pct = %(
    #{coverage_bar(pct)}#{pct_str}%
    ) - %(#{bar_and_pct}) + - %(#{fmt(covered)}/) + - %(#{fmt(total)}) - end - - def coverage_header_cells(label, type, covered_label, total_label) - <<~HTML - -
    - #{label} -
    - - -
    -
    - - #{covered_label} - #{total_label} - HTML - end - - def file_data_attrs(source_file) - build_data_attr_pairs(source_file).map { |k, v| %(data-#{k}="#{v}") }.join(" ") - end - - def coverage_type_summary(type, label, summary, enabled:, **opts) - return disabled_summary(type, label) unless enabled - - enabled_type_summary(type, label, summary.fetch(type.to_sym), opts) - end - - def coverage_summary(source_file, show_method_toggle: false) - stats = source_file.coverage_statistics - _summary = { - line: stats[:line], - branch: stats[:branch], - method: stats[:method], - show_method_toggle: show_method_toggle - } - template("coverage_summary").result(binding) - end - - private - - def totals_cell_attrs(type, css) - ["cell--coverage strong t-totals__#{type}-pct #{css}", - "cell--numerator strong t-totals__#{type}-num", - "cell--denominator strong t-totals__#{type}-den", ""] - end - - def regular_cell_attrs(pct, type, css) - ["cell--coverage cell--#{type}-pct #{css}", - "cell--numerator", "cell--denominator", - %( data-order="#{Kernel.format('%.2f', pct)}")] - end - - def coverage_cell_attrs(pct, type, totals) - css = coverage_css_class(pct) - totals ? totals_cell_attrs(type, css) : regular_cell_attrs(pct, type, css) - end - - def build_data_attr_pairs(source_file) - covered = source_file.covered_lines.count - pairs = {"covered-lines" => covered, "relevant-lines" => covered + source_file.missed_lines.count} - append_branch_attrs(pairs, source_file) - append_method_attrs(pairs, source_file) - pairs - end - - def append_branch_attrs(pairs, source_file) - return unless branch_coverage? - - pairs["covered-branches"] = source_file.covered_branches.count - pairs["total-branches"] = source_file.total_branches.count - end - - def append_method_attrs(pairs, source_file) - return unless method_coverage? - - pairs["covered-methods"] = source_file.covered_methods.count - pairs["total-methods"] = source_file.methods.count - end - - def enabled_type_summary(type, label, stats, opts) - css = coverage_css_class(stats.percent) - missed = stats.missed - parts = [ - %(
    \n #{label}: ), - %(#{Kernel.format('%.2f', stats.percent.floor(2))}%), - %( #{stats.covered}/#{stats.total} #{opts.fetch(:suffix, 'covered')}) - ] - parts << missed_summary_html(missed, opts.fetch(:missed_class, "red"), opts.fetch(:toggle, false)) if missed.positive? - parts << "\n
    " - parts.join - end - - def disabled_summary(type, label) - %(
    \n #{label}: disabled\n
    ) - end - - def missed_summary_html(count, missed_class, toggle) - missed = if toggle - %(#{count} missed) - else - %(#{count} missed) - end - %(,\n #{missed}) - end - end - end - end -end diff --git a/lib/simplecov/formatter/html_formatter/public/application.js b/lib/simplecov/formatter/html_formatter/public/application.js index 61154b69..ff9913ff 100644 --- a/lib/simplecov/formatter/html_formatter/public/application.js +++ b/lib/simplecov/formatter/html_formatter/public/application.js @@ -1,3 +1,18 @@ -"use strict";(()=>{var Ct=Object.create;var We=Object.defineProperty;var Ht=Object.getOwnPropertyDescriptor;var Dt=Object.getOwnPropertyNames;var Bt=Object.getPrototypeOf,Pt=Object.prototype.hasOwnProperty;var Ft=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var $t=(e,t,i,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let s of Dt(t))!Pt.call(e,s)&&s!==i&&We(e,s,{get:()=>t[s],enumerable:!(n=Ht(t,s))||n.enumerable});return e};var Ut=(e,t,i)=>(i=e!=null?Ct(Bt(e)):{},$t(t||!e||!e.__esModule?We(i,"default",{value:e,enumerable:!0}):i,e));var ct=Ft((Vn,lt)=>{function Ze(e){return e instanceof Map?e.clear=e.delete=e.set=function(){throw new Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=function(){throw new Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach(t=>{let i=e[t],n=typeof i;(n==="object"||n==="function")&&!Object.isFrozen(i)&&Ze(i)}),e}var oe=class{constructor(t){t.data===void 0&&(t.data={}),this.data=t.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}};function Ye(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function P(e,...t){let i=Object.create(null);for(let n in e)i[n]=e[n];return t.forEach(function(n){for(let s in n)i[s]=n[s]}),i}var Wt="",qe=e=>!!e.scope,qt=(e,{prefix:t})=>{if(e.startsWith("language:"))return e.replace("language:","language-");if(e.includes(".")){let i=e.split(".");return[`${t}${i.shift()}`,...i.map((n,s)=>`${n}${"_".repeat(s+1)}`)].join(" ")}return`${t}${e}`},_e=class{constructor(t,i){this.buffer="",this.classPrefix=i.classPrefix,t.walk(this)}addText(t){this.buffer+=Ye(t)}openNode(t){if(!qe(t))return;let i=qt(t.scope,{prefix:this.classPrefix});this.span(i)}closeNode(t){qe(t)&&(this.buffer+=Wt)}value(){return this.buffer}span(t){this.buffer+=``}},je=(e={})=>{let t={children:[]};return Object.assign(t,e),t},me=class e{constructor(){this.rootNode=je(),this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(t){this.top.children.push(t)}openNode(t){let i=je({scope:t});this.add(i),this.stack.push(i)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(t){return this.constructor._walk(t,this.rootNode)}static _walk(t,i){return typeof i=="string"?t.addText(i):i.children&&(t.openNode(i),i.children.forEach(n=>this._walk(t,n)),t.closeNode(i)),t}static _collapse(t){typeof t!="string"&&t.children&&(t.children.every(i=>typeof i=="string")?t.children=[t.children.join("")]:t.children.forEach(i=>{e._collapse(i)}))}},ye=class extends me{constructor(t){super(),this.options=t}addText(t){t!==""&&this.add(t)}startScope(t){this.openNode(t)}endScope(){this.closeNode()}__addSublanguage(t,i){let n=t.root;i&&(n.scope=`language:${i}`),this.add(n)}toHTML(){return new _e(this,this.options).value()}finalize(){return this.closeAllNodes(),!0}};function Z(e){return e?typeof e=="string"?e:e.source:null}function Ve(e){return W("(?=",e,")")}function jt(e){return W("(?:",e,")*")}function zt(e){return W("(?:",e,")?")}function W(...e){return e.map(i=>Z(i)).join("")}function Gt(e){let t=e[e.length-1];return typeof t=="object"&&t.constructor===Object?(e.splice(e.length-1,1),t):{}}function ve(...e){return"("+(Gt(e).capture?"":"?:")+e.map(n=>Z(n)).join("|")+")"}function Qe(e){return new RegExp(e.toString()+"|").exec("").length-1}function Kt(e,t){let i=e&&e.exec(t);return i&&i.index===0}var Xt=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;function we(e,{joinWith:t}){let i=0;return e.map(n=>{i+=1;let s=i,c=Z(n),r="";for(;c.length>0;){let o=Xt.exec(c);if(!o){r+=c;break}r+=c.substring(0,o.index),c=c.substring(o.index+o[0].length),o[0][0]==="\\"&&o[1]?r+="\\"+String(Number(o[1])+s):(r+=o[0],o[0]==="("&&i++)}return r}).map(n=>`(${n})`).join(t)}var Zt=/\b\B/,Je="[a-zA-Z]\\w*",Te="[a-zA-Z_]\\w*",et="\\b\\d+(\\.\\d+)?",tt="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",nt="\\b(0b[01]+)",Yt="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",Vt=(e={})=>{let t=/^#![ ]*\//;return e.binary&&(e.begin=W(t,/.*\b/,e.binary,/\b.*/)),P({scope:"meta",begin:t,end:/$/,relevance:0,"on:begin":(i,n)=>{i.index!==0&&n.ignoreMatch()}},e)},Y={begin:"\\\\[\\s\\S]",relevance:0},Qt={scope:"string",begin:"'",end:"'",illegal:"\\n",contains:[Y]},Jt={scope:"string",begin:'"',end:'"',illegal:"\\n",contains:[Y]},en={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},ce=function(e,t,i={}){let n=P({scope:"comment",begin:e,end:t,contains:[]},i);n.contains.push({scope:"doctag",begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0});let s=ve("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/);return n.contains.push({begin:W(/[ ]+/,"(",s,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),n},tn=ce("//","$"),nn=ce("/\\*","\\*/"),sn=ce("#","$"),rn={scope:"number",begin:et,relevance:0},on={scope:"number",begin:tt,relevance:0},ln={scope:"number",begin:nt,relevance:0},cn={scope:"regexp",begin:/\/(?=[^/\n]*\/)/,end:/\/[gimuy]*/,contains:[Y,{begin:/\[/,end:/\]/,relevance:0,contains:[Y]}]},an={scope:"title",begin:Je,relevance:0},un={scope:"title",begin:Te,relevance:0},fn={begin:"\\.\\s*"+Te,relevance:0},dn=function(e){return Object.assign(e,{"on:begin":(t,i)=>{i.data._beginMatch=t[1]},"on:end":(t,i)=>{i.data._beginMatch!==t[1]&&i.ignoreMatch()}})},re=Object.freeze({__proto__:null,APOS_STRING_MODE:Qt,BACKSLASH_ESCAPE:Y,BINARY_NUMBER_MODE:ln,BINARY_NUMBER_RE:nt,COMMENT:ce,C_BLOCK_COMMENT_MODE:nn,C_LINE_COMMENT_MODE:tn,C_NUMBER_MODE:on,C_NUMBER_RE:tt,END_SAME_AS_BEGIN:dn,HASH_COMMENT_MODE:sn,IDENT_RE:Je,MATCH_NOTHING_RE:Zt,METHOD_GUARD:fn,NUMBER_MODE:rn,NUMBER_RE:et,PHRASAL_WORDS_MODE:en,QUOTE_STRING_MODE:Jt,REGEXP_MODE:cn,RE_STARTERS_RE:Yt,SHEBANG:Vt,TITLE_MODE:an,UNDERSCORE_IDENT_RE:Te,UNDERSCORE_TITLE_MODE:un});function gn(e,t){e.input[e.index-1]==="."&&t.ignoreMatch()}function hn(e,t){e.className!==void 0&&(e.scope=e.className,delete e.className)}function pn(e,t){t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",e.__beforeBegin=gn,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,e.relevance===void 0&&(e.relevance=0))}function En(e,t){Array.isArray(e.illegal)&&(e.illegal=ve(...e.illegal))}function bn(e,t){if(e.match){if(e.begin||e.end)throw new Error("begin & end are not supported with match");e.begin=e.match,delete e.match}}function _n(e,t){e.relevance===void 0&&(e.relevance=1)}var mn=(e,t)=>{if(!e.beforeMatch)return;if(e.starts)throw new Error("beforeMatch cannot be used with starts");let i=Object.assign({},e);Object.keys(e).forEach(n=>{delete e[n]}),e.keywords=i.keywords,e.begin=W(i.beforeMatch,Ve(i.begin)),e.starts={relevance:0,contains:[Object.assign(i,{endsParent:!0})]},e.relevance=0,delete i.beforeMatch},yn=["of","and","for","in","not","or","if","then","parent","list","value"],Mn="keyword";function it(e,t,i=Mn){let n=Object.create(null);return typeof e=="string"?s(i,e.split(" ")):Array.isArray(e)?s(i,e):Object.keys(e).forEach(function(c){Object.assign(n,it(e[c],t,c))}),n;function s(c,r){t&&(r=r.map(o=>o.toLowerCase())),r.forEach(function(o){let u=o.split("|");n[u[0]]=[c,vn(u[0],u[1])]})}}function vn(e,t){return t?Number(t):wn(e)?0:1}function wn(e){return yn.includes(e.toLowerCase())}var ze={},U=e=>{console.error(e)},Ge=(e,...t)=>{console.log(`WARN: ${e}`,...t)},G=(e,t)=>{ze[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),ze[`${e}/${t}`]=!0)},le=new Error;function st(e,t,{key:i}){let n=0,s=e[i],c={},r={};for(let o=1;o<=t.length;o++)r[o+n]=s[o],c[o+n]=!0,n+=Qe(t[o-1]);e[i]=r,e[i]._emit=c,e[i]._multi=!0}function Tn(e){if(Array.isArray(e.begin)){if(e.skip||e.excludeBegin||e.returnBegin)throw U("skip, excludeBegin, returnBegin not compatible with beginScope: {}"),le;if(typeof e.beginScope!="object"||e.beginScope===null)throw U("beginScope must be object"),le;st(e,e.begin,{key:"beginScope"}),e.begin=we(e.begin,{joinWith:""})}}function Sn(e){if(Array.isArray(e.end)){if(e.skip||e.excludeEnd||e.returnEnd)throw U("skip, excludeEnd, returnEnd not compatible with endScope: {}"),le;if(typeof e.endScope!="object"||e.endScope===null)throw U("endScope must be object"),le;st(e,e.end,{key:"endScope"}),e.end=we(e.end,{joinWith:""})}}function Ln(e){e.scope&&typeof e.scope=="object"&&e.scope!==null&&(e.beginScope=e.scope,delete e.scope)}function An(e){Ln(e),typeof e.beginScope=="string"&&(e.beginScope={_wrap:e.beginScope}),typeof e.endScope=="string"&&(e.endScope={_wrap:e.endScope}),Tn(e),Sn(e)}function Nn(e){function t(r,o){return new RegExp(Z(r),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(o?"g":""))}class i{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(o,u){u.position=this.position++,this.matchIndexes[this.matchAt]=u,this.regexes.push([u,o]),this.matchAt+=Qe(o)+1}compile(){this.regexes.length===0&&(this.exec=()=>null);let o=this.regexes.map(u=>u[1]);this.matcherRe=t(we(o,{joinWith:"|"}),!0),this.lastIndex=0}exec(o){this.matcherRe.lastIndex=this.lastIndex;let u=this.matcherRe.exec(o);if(!u)return null;let b=u.findIndex((S,R)=>R>0&&S!==void 0),E=this.matchIndexes[b];return u.splice(0,b),Object.assign(u,E)}}class n{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(o){if(this.multiRegexes[o])return this.multiRegexes[o];let u=new i;return this.rules.slice(o).forEach(([b,E])=>u.addRule(b,E)),u.compile(),this.multiRegexes[o]=u,u}resumingScanAtSamePosition(){return this.regexIndex!==0}considerAll(){this.regexIndex=0}addRule(o,u){this.rules.push([o,u]),u.type==="begin"&&this.count++}exec(o){let u=this.getMatcher(this.regexIndex);u.lastIndex=this.lastIndex;let b=u.exec(o);if(this.resumingScanAtSamePosition()&&!(b&&b.index===this.lastIndex)){let E=this.getMatcher(0);E.lastIndex=this.lastIndex+1,b=E.exec(o)}return b&&(this.regexIndex+=b.position+1,this.regexIndex===this.count&&this.considerAll()),b}}function s(r){let o=new n;return r.contains.forEach(u=>o.addRule(u.begin,{rule:u,type:"begin"})),r.terminatorEnd&&o.addRule(r.terminatorEnd,{type:"end"}),r.illegal&&o.addRule(r.illegal,{type:"illegal"}),o}function c(r,o){let u=r;if(r.isCompiled)return u;[hn,bn,An,mn].forEach(E=>E(r,o)),e.compilerExtensions.forEach(E=>E(r,o)),r.__beforeBegin=null,[pn,En,_n].forEach(E=>E(r,o)),r.isCompiled=!0;let b=null;return typeof r.keywords=="object"&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords),b=r.keywords.$pattern,delete r.keywords.$pattern),b=b||/\w+/,r.keywords&&(r.keywords=it(r.keywords,e.case_insensitive)),u.keywordPatternRe=t(b,!0),o&&(r.begin||(r.begin=/\B|\b/),u.beginRe=t(u.begin),!r.end&&!r.endsWithParent&&(r.end=/\B|\b/),r.end&&(u.endRe=t(u.end)),u.terminatorEnd=Z(u.end)||"",r.endsWithParent&&o.terminatorEnd&&(u.terminatorEnd+=(r.end?"|":"")+o.terminatorEnd)),r.illegal&&(u.illegalRe=t(r.illegal)),r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map(function(E){return xn(E==="self"?r:E)})),r.contains.forEach(function(E){c(E,u)}),r.starts&&c(r.starts,o),u.matcher=s(u),u}if(e.compilerExtensions||(e.compilerExtensions=[]),e.contains&&e.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return e.classNameAliases=P(e.classNameAliases||{}),c(e)}function rt(e){return e?e.endsWithParent||rt(e.starts):!1}function xn(e){return e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map(function(t){return P(e,{variants:null},t)})),e.cachedVariants?e.cachedVariants:rt(e)?P(e,{starts:e.starts?P(e.starts):null}):Object.isFrozen(e)?P(e):e}var Rn="11.11.1",Me=class extends Error{constructor(t,i){super(t),this.name="HTMLInjectionError",this.html=i}},be=Ye,Ke=P,Xe=Symbol("nomatch"),On=7,ot=function(e){let t=Object.create(null),i=Object.create(null),n=[],s=!0,c="Could not find the language '{}', did you forget to load/include a language module?",r={disableAutodetect:!0,name:"Plain text",contains:[]},o={ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",cssSelector:"pre code",languages:null,__emitter:ye};function u(l){return o.noHighlightRe.test(l)}function b(l){let d=l.className+" ";d+=l.parentNode?l.parentNode.className:"";let p=o.languageDetectRe.exec(d);if(p){let m=H(p[1]);return m||(Ge(c.replace("{}",p[1])),Ge("Falling back to no-highlight mode for this block.",l)),m?p[1]:"no-highlight"}return d.split(/\s+/).find(m=>u(m)||H(m))}function E(l,d,p){let m="",v="";typeof d=="object"?(m=l,p=d.ignoreIllegals,v=d.language):(G("10.7.0","highlight(lang, code, ...args) has been deprecated."),G("10.7.0",`Please use highlight(code, options) instead. -https://github.com/highlightjs/highlight.js/issues/2277`),v=l,m=d),p===void 0&&(p=!0);let x={code:m,language:v};te("before:highlight",x);let B=x.result?x.result:S(x.language,x.code,p);return B.code=x.code,te("after:highlight",B),B}function S(l,d,p,m){let v=Object.create(null);function x(a,f){return a.keywords[f]}function B(){if(!g.keywords){w.addText(y);return}let a=0;g.keywordPatternRe.lastIndex=0;let f=g.keywordPatternRe.exec(y),h="";for(;f;){h+=y.substring(a,f.index);let _=k.case_insensitive?f[0].toLowerCase():f[0],T=x(g,_);if(T){let[D,kt]=T;if(w.addText(h),h="",v[_]=(v[_]||0)+1,v[_]<=On&&(se+=kt),D.startsWith("_"))h+=f[0];else{let It=k.classNameAliases[D]||D;O(f[0],It)}}else h+=f[0];a=g.keywordPatternRe.lastIndex,f=g.keywordPatternRe.exec(y)}h+=y.substring(a),w.addText(h)}function ne(){if(y==="")return;let a=null;if(typeof g.subLanguage=="string"){if(!t[g.subLanguage]){w.addText(y);return}a=S(g.subLanguage,y,!0,Ue[g.subLanguage]),Ue[g.subLanguage]=a._top}else a=j(y,g.subLanguage.length?g.subLanguage:null);g.relevance>0&&(se+=a.relevance),w.__addSublanguage(a._emitter,a.language)}function L(){g.subLanguage!=null?ne():B(),y=""}function O(a,f){a!==""&&(w.startScope(f),w.addText(a),w.endScope())}function Be(a,f){let h=1,_=f.length-1;for(;h<=_;){if(!a._emit[h]){h++;continue}let T=k.classNameAliases[a[h]]||a[h],D=f[h];T?O(D,T):(y=D,B(),y=""),h++}}function Pe(a,f){return a.scope&&typeof a.scope=="string"&&w.openNode(k.classNameAliases[a.scope]||a.scope),a.beginScope&&(a.beginScope._wrap?(O(y,k.classNameAliases[a.beginScope._wrap]||a.beginScope._wrap),y=""):a.beginScope._multi&&(Be(a.beginScope,f),y="")),g=Object.create(a,{parent:{value:g}}),g}function Fe(a,f,h){let _=Kt(a.endRe,h);if(_){if(a["on:end"]){let T=new oe(a);a["on:end"](f,T),T.isMatchIgnored&&(_=!1)}if(_){for(;a.endsParent&&a.parent;)a=a.parent;return a}}if(a.endsWithParent)return Fe(a.parent,f,h)}function At(a){return g.matcher.regexIndex===0?(y+=a[0],1):(Ee=!0,0)}function Nt(a){let f=a[0],h=a.rule,_=new oe(h),T=[h.__beforeBegin,h["on:begin"]];for(let D of T)if(D&&(D(a,_),_.isMatchIgnored))return At(f);return h.skip?y+=f:(h.excludeBegin&&(y+=f),L(),!h.returnBegin&&!h.excludeBegin&&(y=f)),Pe(h,a),h.returnBegin?0:f.length}function xt(a){let f=a[0],h=d.substring(a.index),_=Fe(g,a,h);if(!_)return Xe;let T=g;g.endScope&&g.endScope._wrap?(L(),O(f,g.endScope._wrap)):g.endScope&&g.endScope._multi?(L(),Be(g.endScope,a)):T.skip?y+=f:(T.returnEnd||T.excludeEnd||(y+=f),L(),T.excludeEnd&&(y=f));do g.scope&&w.closeNode(),!g.skip&&!g.subLanguage&&(se+=g.relevance),g=g.parent;while(g!==_.parent);return _.starts&&Pe(_.starts,a),T.returnEnd?0:f.length}function Rt(){let a=[];for(let f=g;f!==k;f=f.parent)f.scope&&a.unshift(f.scope);a.forEach(f=>w.openNode(f))}let ie={};function $e(a,f){let h=f&&f[0];if(y+=a,h==null)return L(),0;if(ie.type==="begin"&&f.type==="end"&&ie.index===f.index&&h===""){if(y+=d.slice(f.index,f.index+1),!s){let _=new Error(`0 width match regex (${l})`);throw _.languageName=l,_.badRule=ie.rule,_}return 1}if(ie=f,f.type==="begin")return Nt(f);if(f.type==="illegal"&&!p){let _=new Error('Illegal lexeme "'+h+'" for mode "'+(g.scope||"")+'"');throw _.mode=g,_}else if(f.type==="end"){let _=xt(f);if(_!==Xe)return _}if(f.type==="illegal"&&h==="")return y+=` -`,1;if(pe>1e5&&pe>f.index*3)throw new Error("potential infinite loop, way more iterations than matches");return y+=h,h.length}let k=H(l);if(!k)throw U(c.replace("{}",l)),new Error('Unknown language: "'+l+'"');let Ot=Nn(k),he="",g=m||Ot,Ue={},w=new o.__emitter(o);Rt();let y="",se=0,$=0,pe=0,Ee=!1;try{if(k.__emitTokens)k.__emitTokens(d,w);else{for(g.matcher.considerAll();;){pe++,Ee?Ee=!1:g.matcher.considerAll(),g.matcher.lastIndex=$;let a=g.matcher.exec(d);if(!a)break;let f=d.substring($,a.index),h=$e(f,a);$=a.index+h}$e(d.substring($))}return w.finalize(),he=w.toHTML(),{language:l,value:he,relevance:se,illegal:!1,_emitter:w,_top:g}}catch(a){if(a.message&&a.message.includes("Illegal"))return{language:l,value:be(d),illegal:!0,relevance:0,_illegalBy:{message:a.message,index:$,context:d.slice($-100,$+100),mode:a.mode,resultSoFar:he},_emitter:w};if(s)return{language:l,value:be(d),illegal:!1,relevance:0,errorRaised:a,_emitter:w,_top:g};throw a}}function R(l){let d={value:be(l),illegal:!1,relevance:0,_top:r,_emitter:new o.__emitter(o)};return d._emitter.addText(l),d}function j(l,d){d=d||o.languages||Object.keys(t);let p=R(l),m=d.filter(H).filter(ee).map(L=>S(L,l,!1));m.unshift(p);let v=m.sort((L,O)=>{if(L.relevance!==O.relevance)return O.relevance-L.relevance;if(L.language&&O.language){if(H(L.language).supersetOf===O.language)return 1;if(H(O.language).supersetOf===L.language)return-1}return 0}),[x,B]=v,ne=x;return ne.secondBest=B,ne}function fe(l,d,p){let m=d&&i[d]||p;l.classList.add("hljs"),l.classList.add(`language-${m}`)}function z(l){let d=null,p=b(l);if(u(p))return;if(te("before:highlightElement",{el:l,language:p}),l.dataset.highlighted){console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",l);return}if(l.children.length>0&&(o.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),console.warn("The element with unescaped HTML:"),console.warn(l)),o.throwUnescapedHTML))throw new Me("One of your code blocks includes unescaped HTML.",l.innerHTML);d=l;let m=d.textContent,v=p?E(m,{language:p,ignoreIllegals:!0}):j(m);l.innerHTML=v.value,l.dataset.highlighted="yes",fe(l,p,v.language),l.result={language:v.language,re:v.relevance,relevance:v.relevance},v.secondBest&&(l.secondBest={language:v.secondBest.language,relevance:v.secondBest.relevance}),te("after:highlightElement",{el:l,result:v,text:m})}function ke(l){o=Ke(o,l)}let Ie=()=>{X(),G("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")};function Ce(){X(),G("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.")}let de=!1;function X(){function l(){X()}if(document.readyState==="loading"){de||window.addEventListener("DOMContentLoaded",l,!1),de=!0;return}document.querySelectorAll(o.cssSelector).forEach(z)}function He(l,d){let p=null;try{p=d(e)}catch(m){if(U("Language definition for '{}' could not be registered.".replace("{}",l)),s)U(m);else throw m;p=r}p.name||(p.name=l),t[l]=p,p.rawDefinition=d.bind(null,e),p.aliases&&ge(p.aliases,{languageName:l})}function F(l){delete t[l];for(let d of Object.keys(i))i[d]===l&&delete i[d]}function De(){return Object.keys(t)}function H(l){return l=(l||"").toLowerCase(),t[l]||t[i[l]]}function ge(l,{languageName:d}){typeof l=="string"&&(l=[l]),l.forEach(p=>{i[p.toLowerCase()]=d})}function ee(l){let d=H(l);return d&&!d.disableAutodetect}function wt(l){l["before:highlightBlock"]&&!l["before:highlightElement"]&&(l["before:highlightElement"]=d=>{l["before:highlightBlock"](Object.assign({block:d.el},d))}),l["after:highlightBlock"]&&!l["after:highlightElement"]&&(l["after:highlightElement"]=d=>{l["after:highlightBlock"](Object.assign({block:d.el},d))})}function Tt(l){wt(l),n.push(l)}function St(l){let d=n.indexOf(l);d!==-1&&n.splice(d,1)}function te(l,d){let p=l;n.forEach(function(m){m[p]&&m[p](d)})}function Lt(l){return G("10.7.0","highlightBlock will be removed entirely in v12.0"),G("10.7.0","Please use highlightElement now."),z(l)}Object.assign(e,{highlight:E,highlightAuto:j,highlightAll:X,highlightElement:z,highlightBlock:Lt,configure:ke,initHighlighting:Ie,initHighlightingOnLoad:Ce,registerLanguage:He,unregisterLanguage:F,listLanguages:De,getLanguage:H,registerAliases:ge,autoDetection:ee,inherit:Ke,addPlugin:Tt,removePlugin:St}),e.debugMode=function(){s=!1},e.safeMode=function(){s=!0},e.versionString=Rn,e.regex={concat:W,lookahead:Ve,either:ve,optional:zt,anyNumberOfTimes:jt};for(let l in re)typeof re[l]=="object"&&Ze(re[l]);return Object.assign(e,re),e},K=ot({});K.newInstance=()=>ot({});lt.exports=K;K.HighlightJS=K;K.default=K});var at=Ut(ct(),1);var Se=at.default;function ut(e){let t=e.regex,i="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",n=t.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),s=t.concat(n,/(::\w+)*/),r={"variable.constant":["__FILE__","__LINE__","__ENCODING__"],"variable.language":["self","super"],keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield",...["include","extend","prepend","public","private","protected","raise","throw"]],built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"],literal:["true","false","nil"]},o={className:"doctag",begin:"@[A-Za-z]+"},u={begin:"#<",end:">"},b=[e.COMMENT("#","$",{contains:[o]}),e.COMMENT("^=begin","^=end",{contains:[o],relevance:10}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],E={className:"subst",begin:/#\{/,end:/\}/,keywords:r},S={className:"string",contains:[e.BACKSLASH_ESCAPE,E],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{begin:t.concat(/<<[-~]?'?/,t.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)),contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,contains:[e.BACKSLASH_ESCAPE,E]})]}]},R="[1-9](_?[0-9])*|0",j="[0-9](_?[0-9])*",fe={className:"number",relevance:0,variants:[{begin:`\\b(${R})(\\.(${j}))?([eE][+-]?(${j})|r)?i?\\b`},{begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{begin:"\\b0(_?[0-7])+r?i?\\b"}]},z={variants:[{match:/\(\)/},{className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0,keywords:r}]},F=[S,{variants:[{match:[/class\s+/,s,/\s+<\s+/,s]},{match:[/\b(class|module)\s+/,s]}],scope:{2:"title.class",4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,s],scope:{2:"title.class"},keywords:r},{relevance:0,match:[s,/\.new[. (]/],scope:{1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},{relevance:0,match:n,scope:"title.class"},{match:[/def/,/\s+/,i],scope:{1:"keyword",3:"title.function"},contains:[z]},{begin:e.IDENT_RE+"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[S,{begin:i}],relevance:0},fe,{className:"variable",begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0,relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,E],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(u,b),relevance:0}].concat(u,b);E.contains=F,z.contains=F;let ee=[{begin:/^\s*=>/,starts:{end:"$",contains:F}},{className:"meta.prompt",begin:"^("+"[>?]>"+"|"+"[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]"+"|"+"(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>"+")(?=[ ])",starts:{end:"$",keywords:r,contains:F}}];return b.unshift(u),{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/,contains:[e.SHEBANG({binary:"ruby"})].concat(ee).concat(b).concat(F)}}Se.registerLanguage("ruby",ut);var kn=240,In=160,Cn=90,Hn=75;function C(e,t){return(t||document).querySelector(e)}function M(e,t){return Array.from((t||document).querySelectorAll(e))}function q(e,t,i,n){typeof i=="function"?e.addEventListener(t,i):e.addEventListener(t,function(s){let c=s.target.closest(i);c&&e.contains(c)&&n&&n.call(c,s)})}function Dn(e){let t=Math.floor((Date.now()-e.getTime())/1e3),i=[[31536e3,"year"],[2592e3,"month"],[86400,"day"],[3600,"hour"],[60,"minute"],[1,"second"]];for(let[n,s]of i){let c=Math.floor(t/n);if(c>=1)return c===1?`about 1 ${s} ago`:`${c} ${s}s ago`}return"just now"}function Bn(e){return e>=Cn?"green":e>=Hn?"yellow":"red"}function J(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")}function Pn(e,t,i,n){let s=C(t+"-pct",e),c=C(t+"-num",e),r=C(t+"-den",e);if(n===0){s&&(s.innerHTML="",s.className=s.className.replace(/green|yellow|red/g,"").trim()),c&&(c.textContent=""),r&&(r.textContent="");return}let o=i*100/n,u=Bn(o);s&&(s.innerHTML=`
    ${o.toFixed(2)}%
    `,s.className=`${s.className.replace(/green|yellow|red/g,"").trim()} ${u}`),c&&(c.textContent=J(i)+"/"),r&&(r.textContent=J(n))}var ft={};function dt(e,t){let i=0;for(let n=0;n{let E=gt(dt(u,t)),S=gt(dt(b,t)),R;return typeof E=="number"&&typeof S=="number"?R=E-S:R=String(E).localeCompare(String(S)),s==="asc"?R:-R}),c.append(...r);let o=0;M("thead tr:first-child th",e).forEach(u=>{let b=parseInt(u.getAttribute("colspan")||"1",10);u.classList.remove("sorting_asc","sorting_desc","sorting");let E=t>=o&&te>t,gte:(e,t)=>e>=t,eq:(e,t)=>e===t,lte:(e,t)=>e<=t,lt:(e,t)=>e!0))(t,i)}function Wn(e){return M(".col-filter__value",e).map(t=>{let i=t;if(!i.value)return null;let n=parseFloat(i.value);if(isNaN(n))return null;let s=i.dataset.type||"",c=C(`.col-filter__op[data-type="${s}"]`,e),r=c?c.value:"";if(!r)return null;let o=xe[s];return o?{attrs:o,op:r,threshold:n}:null}).filter(t=>t!==null)}function ht(e){let t=C("table.file_list",e);if(!t)return;let i=C(".col-filter--name",e),n=i?i.value:"",s=Wn(e);M("tbody tr.t-file",t).forEach(c=>{let r=c,o=!0;if(n&&((c.children[0].textContent||"").toLowerCase().includes(n.toLowerCase())||(o=!1)),o)for(let u of s){let b=parseInt(r.dataset[u.attrs.covered]||"0",10)||0,E=parseInt(r.dataset[u.attrs.total]||"0",10)||0,S=E>0?b*100/E:100;if(!Un(u.op,S,u.threshold)){o=!1;break}}r.style.display=o?"":"none"}),Mt(),qn(e),Re()}function Le(e){let t=parseFloat(e.value),i=e.closest(".col-filter__coverage"),n=i?i.querySelector(".col-filter__op"):null;if(!n)return;let s=n.querySelector('option[value="gt"]'),c=n.querySelector('option[value="lt"]');if(s&&(s.disabled=t>=100),c&&(c.disabled=t<=0),n.selectedOptions[0]&&n.selectedOptions[0].disabled){let r=n.querySelector("option:not(:disabled)");r&&(n.value=r.value)}}function qn(e){let t=M("tbody tr.t-file",e).filter(c=>c.style.display!=="none");function i(c){let r=0;return t.forEach(o=>{r+=parseInt(o.dataset[c]||"0",10)||0}),r}let n=C(".t-file-count",e),s=parseInt(e.getAttribute("data-total-files")||"0",10);if(n){let c=t.length===1?" file":" files";n.textContent=t.length===s?J(s)+c:J(t.length)+"/"+J(s)+c}for(let c of Object.keys(xe)){let r=xe[c],o=`.t-totals__${c}`;C(o+"-pct",e)&&Pn(e,o,i(r.covered),i(r.total))}}function jn(e){let t=document.getElementById(e);if(t)return t;let i=document.getElementById("tmpl-"+e);if(!i)return null;let n=document.importNode(i.content,!0);document.querySelector(".source_files").appendChild(n);let s=document.getElementById(e);return s&&M("pre code",s).forEach(c=>{Se.highlightElement(c)}),s}function pt(e,t){let i=t+"px";e.forEach(n=>{let s=n.style;s.width=i,s.minWidth=i,s.maxWidth=i})}function yt(){M(".file_list_container").forEach(e=>{if(e.style.display==="none"||e.offsetWidth===0)return;let t=C("table.file_list",e);if(!t)return;let i=M(".bar-sizer",t);if(i.length===0)return;let n=t.closest(".file_list--responsive");if(!n)return;n.style.visibility="hidden";let s=In,c=kn;for(;s{Ae=0,yt()}))}var A=null,V=null;function Mt(){V=null}function zn(){if(V)return V;let e=M(".file_list_container").filter(t=>t.style.display!=="none");return e.length?(V=M("tbody tr.t-file",e[0]).filter(t=>t.style.display!=="none"),V):[]}function ue(e){A&&A.classList.remove("keyboard-focus"),A=e,A&&(A.classList.add("keyboard-focus"),A.scrollIntoView({block:"nearest"}))}function Et(e){let t=zn();if(!t.length)return;if(!A||t.indexOf(A)===-1){ue(e===1?t[0]:t[t.length-1]);return}let i=t.indexOf(A)+e;i>=0&&ir.offsetTop>n)||t[0];N.scrollTop=c.offsetTop-N.clientHeight/3}else{let s=null;for(let r=t.length-1;r>=0;r--)if(t[r].offsetTops.classList.remove("active")),i.parentElement.classList.add("active"),M(".file_list_container").forEach(s=>s.style.display="none");let n=document.getElementById(e);n&&(n.style.display="")}}let t=document.getElementById("wrapper");t&&!t.classList.contains("hide")&&Re()}function mt(){let e=window.location.hash.substring(1);if(!e){let t=document.querySelector(".group_tabs a");t&&_t(t.getAttribute("href").replace("#",""));return}if(e.charAt(0)==="_")_t(e.substring(1));else{let t=e.split("-L");if(!document.querySelector(".group_tabs li.active")){let i=document.querySelector(".group_tabs li");i&&i.classList.add("active")}Xn(t[0],t[1])}}function Ne(){let e=document.querySelector(".group_tabs li.active a");e&&(window.location.hash=e.getAttribute("href").replace("#","#_"))}function Zn(){let e=document.getElementById("dark-mode-toggle");if(!e)return;let t=document.documentElement;function i(){return t.classList.contains("dark-mode")||!t.classList.contains("light-mode")&&window.matchMedia("(prefers-color-scheme: dark)").matches}function n(){e.textContent=i()?"\u2600\uFE0F Light":"\u{1F319} Dark"}n(),e.addEventListener("click",()=>{let s=i();t.classList.toggle("light-mode",s),t.classList.toggle("dark-mode",!s),localStorage.setItem("simplecov-dark-mode",s?"light":"dark"),n()}),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{localStorage.getItem("simplecov-dark-mode")||n()})}document.addEventListener("DOMContentLoaded",function(){M("abbr.timeago").forEach(n=>{let s=new Date(n.getAttribute("title")||"");isNaN(s.getTime())||(n.textContent=Dn(s))}),Zn();function e(n,s){let c=0;for(let r of M("thead tr:first-child th",n)){let o=parseInt(r.getAttribute("colspan")||"1",10);if(r===s)return c+o-1;c+=o}return c}M("table.file_list").forEach(n=>{M("thead tr:first-child th",n).forEach(s=>{s.classList.add("sorting"),s.style.cursor="pointer",s.addEventListener("click",()=>Fn(n,e(n,s)))})}),M(".col-filter__value").forEach(n=>Le(n)),M(".col-filter--name, .col-filter__op, .col-filter__value, .col-filter__coverage").forEach(n=>{n.addEventListener("click",s=>s.stopPropagation())}),q(document,"input",".col-filter--name, .col-filter__op, .col-filter__value",function(){this.classList.contains("col-filter__value")&&Le(this),ht(this.closest(".file_list_container"))}),q(document,"change",".col-filter__op, .col-filter__value",function(){this.classList.contains("col-filter__value")&&Le(this),ht(this.closest(".file_list_container"))}),document.addEventListener("keydown",n=>{let s=n.target.matches("input, select, textarea");if(n.key==="/"&&!s){n.preventDefault();let c=M(".file_list_container").filter(o=>o.style.display!=="none"),r=c.length?C(".col-filter--name",c[0]):null;r&&r.focus();return}if(n.key==="Escape"){I.open?(n.preventDefault(),Ne()):s?n.target.blur():A&&ue(null);return}if(!s){if(I.open){n.key==="n"&&!n.shiftKey&&(n.preventDefault(),bt(1)),(n.key==="N"||n.key==="n"&&n.shiftKey||n.key==="p")&&(n.preventDefault(),bt(-1));return}n.key==="j"&&(n.preventDefault(),Et(1)),n.key==="k"&&(n.preventDefault(),Et(-1)),n.key==="Enter"&&A&&(n.preventDefault(),Gn())}}),I=document.getElementById("source-dialog"),N=document.getElementById("source-dialog-body"),Oe=document.getElementById("source-dialog-title"),I.querySelector(".source-dialog__close").addEventListener("click",Ne),I.addEventListener("click",n=>{n.target===I&&Ne()}),q(document,"click",".t-missed-method-toggle",function(n){n.preventDefault();let s=this.closest(".header")||this.closest(".source-dialog__title")||this.closest(".source-dialog__header"),c=s?s.querySelector(".t-missed-method-list"):null;c&&(c.style.display=c.style.display==="none"?"":"none")}),q(document,"click","a.src_link",function(n){n.preventDefault(),window.location.hash=this.getAttribute("href").substring(1)}),q(document,"click","table.file_list tbody tr",function(n){if(n.target.closest("a"))return;let s=this.querySelector("a.src_link");s&&(window.location.hash=s.getAttribute("href").substring(1))}),q(document,"click",".source-dialog .source_table li[data-linenumber]",function(n){n.preventDefault(),N.scrollTop=this.offsetTop;let s=this.dataset.linenumber,c=window.location.hash.substring(1).replace(/-L.*/,"");window.location.replace(window.location.href.replace(/#.*/,"#"+c+"-L"+s))}),window.addEventListener("hashchange",mt),document.querySelector(".source_files").setAttribute("style","display:none"),M(".file_list_container").forEach(n=>n.style.display="none"),M(".file_list_container").forEach(n=>{let s=n.id,c=n.querySelector(".group_name"),r=n.querySelector(".covered_percent"),o=document.createElement("li");o.setAttribute("role","tab");let u=document.createElement("a");u.href="#"+s,u.className=s,u.innerHTML=(c?c.innerHTML:"")+" ("+(r?r.innerHTML:"")+")",o.appendChild(u),document.querySelector(".group_tabs").appendChild(o)}),q(document.querySelector(".group_tabs"),"click","a",function(n){n.preventDefault(),window.location.hash=this.getAttribute("href").replace("#","#_")}),window.addEventListener("resize",Re),mt(),clearInterval(window._simplecovLoadingTimer),clearTimeout(window._simplecovShowTimeout);let t=document.getElementById("loading");t&&(t.style.transition="opacity 0.3s",t.style.opacity="0",setTimeout(()=>{t.style.display="none"},300));let i=document.getElementById("wrapper");i&&i.classList.remove("hide"),yt()});})(); +"use strict";(()=>{var Xt=Object.create;var Ve=Object.defineProperty;var Zt=Object.getOwnPropertyDescriptor;var Vt=Object.getOwnPropertyNames;var Yt=Object.getPrototypeOf,Qt=Object.prototype.hasOwnProperty;var Jt=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var en=(e,t,n,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let c of Vt(t))!Qt.call(e,c)&&c!==n&&Ve(e,c,{get:()=>t[c],enumerable:!(s=Zt(t,c))||s.enumerable});return e};var tn=(e,t,n)=>(n=e!=null?Xt(Yt(e)):{},en(t||!e||!e.__esModule?Ve(n,"default",{value:e,enumerable:!0}):n,e));var oe=(e,t,n)=>new Promise((s,c)=>{var r=l=>{try{o(n.next(l))}catch(f){c(f)}},i=l=>{try{o(n.throw(l))}catch(f){c(f)}},o=l=>l.done?s(l.value):Promise.resolve(l.value).then(r,i);o((n=n.apply(e,t)).next())});var bt=Jt((Es,pt)=>{function st(e){return e instanceof Map?e.clear=e.delete=e.set=function(){throw new Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=function(){throw new Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach(t=>{let n=e[t],s=typeof n;(s==="object"||s==="function")&&!Object.isFrozen(n)&&st(n)}),e}var _e=class{constructor(t){t.data===void 0&&(t.data={}),this.data=t.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}};function it(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function G(e,...t){let n=Object.create(null);for(let s in e)n[s]=e[s];return t.forEach(function(s){for(let c in s)n[c]=s[c]}),n}var nn="
    ",Ye=e=>!!e.scope,sn=(e,{prefix:t})=>{if(e.startsWith("language:"))return e.replace("language:","language-");if(e.includes(".")){let n=e.split(".");return[`${t}${n.shift()}`,...n.map((s,c)=>`${s}${"_".repeat(c+1)}`)].join(" ")}return`${t}${e}`},xe=class{constructor(t,n){this.buffer="",this.classPrefix=n.classPrefix,t.walk(this)}addText(t){this.buffer+=it(t)}openNode(t){if(!Ye(t))return;let n=sn(t.scope,{prefix:this.classPrefix});this.span(n)}closeNode(t){Ye(t)&&(this.buffer+=nn)}value(){return this.buffer}span(t){this.buffer+=``}},Qe=(e={})=>{let t={children:[]};return Object.assign(t,e),t},Ne=class e{constructor(){this.rootNode=Qe(),this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(t){this.top.children.push(t)}openNode(t){let n=Qe({scope:t});this.add(n),this.stack.push(n)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(t){return this.constructor._walk(t,this.rootNode)}static _walk(t,n){return typeof n=="string"?t.addText(n):n.children&&(t.openNode(n),n.children.forEach(s=>this._walk(t,s)),t.closeNode(n)),t}static _collapse(t){typeof t!="string"&&t.children&&(t.children.every(n=>typeof n=="string")?t.children=[t.children.join("")]:t.children.forEach(n=>{e._collapse(n)}))}},Re=class extends Ne{constructor(t){super(),this.options=t}addText(t){t!==""&&this.add(t)}startScope(t){this.openNode(t)}endScope(){this.closeNode()}__addSublanguage(t,n){let s=t.root;n&&(s.scope=`language:${n}`),this.add(s)}toHTML(){return new xe(this,this.options).value()}finalize(){return this.closeAllNodes(),!0}};function ce(e){return e?typeof e=="string"?e:e.source:null}function rt(e){return V("(?=",e,")")}function rn(e){return V("(?:",e,")*")}function on(e){return V("(?:",e,")?")}function V(...e){return e.map(n=>ce(n)).join("")}function cn(e){let t=e[e.length-1];return typeof t=="object"&&t.constructor===Object?(e.splice(e.length-1,1),t):{}}function Ce(...e){return"("+(cn(e).capture?"":"?:")+e.map(s=>ce(s)).join("|")+")"}function ot(e){return new RegExp(e.toString()+"|").exec("").length-1}function ln(e,t){let n=e&&e.exec(t);return n&&n.index===0}var an=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;function Ie(e,{joinWith:t}){let n=0;return e.map(s=>{n+=1;let c=n,r=ce(s),i="";for(;r.length>0;){let o=an.exec(r);if(!o){i+=r;break}i+=r.substring(0,o.index),r=r.substring(o.index+o[0].length),o[0][0]==="\\"&&o[1]?i+="\\"+String(Number(o[1])+c):(i+=o[0],o[0]==="("&&n++)}return i}).map(s=>`(${s})`).join(t)}var un=/\b\B/,ct="[a-zA-Z]\\w*",ke="[a-zA-Z_]\\w*",lt="\\b\\d+(\\.\\d+)?",at="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",ut="\\b(0b[01]+)",dn="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",fn=(e={})=>{let t=/^#![ ]*\//;return e.binary&&(e.begin=V(t,/.*\b/,e.binary,/\b.*/)),G({scope:"meta",begin:t,end:/$/,relevance:0,"on:begin":(n,s)=>{n.index!==0&&s.ignoreMatch()}},e)},le={begin:"\\\\[\\s\\S]",relevance:0},gn={scope:"string",begin:"'",end:"'",illegal:"\\n",contains:[le]},hn={scope:"string",begin:'"',end:'"',illegal:"\\n",contains:[le]},pn={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},ve=function(e,t,n={}){let s=G({scope:"comment",begin:e,end:t,contains:[]},n);s.contains.push({scope:"doctag",begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0});let c=Ce("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/);return s.contains.push({begin:V(/[ ]+/,"(",c,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),s},bn=ve("//","$"),mn=ve("/\\*","\\*/"),_n=ve("#","$"),En={scope:"number",begin:lt,relevance:0},vn={scope:"number",begin:at,relevance:0},yn={scope:"number",begin:ut,relevance:0},Mn={scope:"regexp",begin:/\/(?=[^/\n]*\/)/,end:/\/[gimuy]*/,contains:[le,{begin:/\[/,end:/\]/,relevance:0,contains:[le]}]},wn={scope:"title",begin:ct,relevance:0},Tn={scope:"title",begin:ke,relevance:0},Sn={begin:"\\.\\s*"+ke,relevance:0},Ln=function(e){return Object.assign(e,{"on:begin":(t,n)=>{n.data._beginMatch=t[1]},"on:end":(t,n)=>{n.data._beginMatch!==t[1]&&n.ignoreMatch()}})},me=Object.freeze({__proto__:null,APOS_STRING_MODE:gn,BACKSLASH_ESCAPE:le,BINARY_NUMBER_MODE:yn,BINARY_NUMBER_RE:ut,COMMENT:ve,C_BLOCK_COMMENT_MODE:mn,C_LINE_COMMENT_MODE:bn,C_NUMBER_MODE:vn,C_NUMBER_RE:at,END_SAME_AS_BEGIN:Ln,HASH_COMMENT_MODE:_n,IDENT_RE:ct,MATCH_NOTHING_RE:un,METHOD_GUARD:Sn,NUMBER_MODE:En,NUMBER_RE:lt,PHRASAL_WORDS_MODE:pn,QUOTE_STRING_MODE:hn,REGEXP_MODE:Mn,RE_STARTERS_RE:dn,SHEBANG:fn,TITLE_MODE:wn,UNDERSCORE_IDENT_RE:ke,UNDERSCORE_TITLE_MODE:Tn});function An(e,t){e.input[e.index-1]==="."&&t.ignoreMatch()}function xn(e,t){e.className!==void 0&&(e.scope=e.className,delete e.className)}function Nn(e,t){t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",e.__beforeBegin=An,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,e.relevance===void 0&&(e.relevance=0))}function Rn(e,t){Array.isArray(e.illegal)&&(e.illegal=Ce(...e.illegal))}function On(e,t){if(e.match){if(e.begin||e.end)throw new Error("begin & end are not supported with match");e.begin=e.match,delete e.match}}function Cn(e,t){e.relevance===void 0&&(e.relevance=1)}var In=(e,t)=>{if(!e.beforeMatch)return;if(e.starts)throw new Error("beforeMatch cannot be used with starts");let n=Object.assign({},e);Object.keys(e).forEach(s=>{delete e[s]}),e.keywords=n.keywords,e.begin=V(n.beforeMatch,rt(n.begin)),e.starts={relevance:0,contains:[Object.assign(n,{endsParent:!0})]},e.relevance=0,delete n.beforeMatch},kn=["of","and","for","in","not","or","if","then","parent","list","value"],Hn="keyword";function dt(e,t,n=Hn){let s=Object.create(null);return typeof e=="string"?c(n,e.split(" ")):Array.isArray(e)?c(n,e):Object.keys(e).forEach(function(r){Object.assign(s,dt(e[r],t,r))}),s;function c(r,i){t&&(i=i.map(o=>o.toLowerCase())),i.forEach(function(o){let l=o.split("|");s[l[0]]=[r,Dn(l[0],l[1])]})}}function Dn(e,t){return t?Number(t):$n(e)?0:1}function $n(e){return kn.includes(e.toLowerCase())}var Je={},Z=e=>{console.error(e)},et=(e,...t)=>{console.log(`WARN: ${e}`,...t)},J=(e,t)=>{Je[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),Je[`${e}/${t}`]=!0)},Ee=new Error;function ft(e,t,{key:n}){let s=0,c=e[n],r={},i={};for(let o=1;o<=t.length;o++)i[o+s]=c[o],r[o+s]=!0,s+=ot(t[o-1]);e[n]=i,e[n]._emit=r,e[n]._multi=!0}function Bn(e){if(Array.isArray(e.begin)){if(e.skip||e.excludeBegin||e.returnBegin)throw Z("skip, excludeBegin, returnBegin not compatible with beginScope: {}"),Ee;if(typeof e.beginScope!="object"||e.beginScope===null)throw Z("beginScope must be object"),Ee;ft(e,e.begin,{key:"beginScope"}),e.begin=Ie(e.begin,{joinWith:""})}}function Pn(e){if(Array.isArray(e.end)){if(e.skip||e.excludeEnd||e.returnEnd)throw Z("skip, excludeEnd, returnEnd not compatible with endScope: {}"),Ee;if(typeof e.endScope!="object"||e.endScope===null)throw Z("endScope must be object"),Ee;ft(e,e.end,{key:"endScope"}),e.end=Ie(e.end,{joinWith:""})}}function Fn(e){e.scope&&typeof e.scope=="object"&&e.scope!==null&&(e.beginScope=e.scope,delete e.scope)}function Un(e){Fn(e),typeof e.beginScope=="string"&&(e.beginScope={_wrap:e.beginScope}),typeof e.endScope=="string"&&(e.endScope={_wrap:e.endScope}),Bn(e),Pn(e)}function Wn(e){function t(i,o){return new RegExp(ce(i),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(o?"g":""))}class n{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(o,l){l.position=this.position++,this.matchIndexes[this.matchAt]=l,this.regexes.push([l,o]),this.matchAt+=ot(o)+1}compile(){this.regexes.length===0&&(this.exec=()=>null);let o=this.regexes.map(l=>l[1]);this.matcherRe=t(Ie(o,{joinWith:"|"}),!0),this.lastIndex=0}exec(o){this.matcherRe.lastIndex=this.lastIndex;let l=this.matcherRe.exec(o);if(!l)return null;let f=l.findIndex((y,T)=>T>0&&y!==void 0),d=this.matchIndexes[f];return l.splice(0,f),Object.assign(l,d)}}class s{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(o){if(this.multiRegexes[o])return this.multiRegexes[o];let l=new n;return this.rules.slice(o).forEach(([f,d])=>l.addRule(f,d)),l.compile(),this.multiRegexes[o]=l,l}resumingScanAtSamePosition(){return this.regexIndex!==0}considerAll(){this.regexIndex=0}addRule(o,l){this.rules.push([o,l]),l.type==="begin"&&this.count++}exec(o){let l=this.getMatcher(this.regexIndex);l.lastIndex=this.lastIndex;let f=l.exec(o);if(this.resumingScanAtSamePosition()&&!(f&&f.index===this.lastIndex)){let d=this.getMatcher(0);d.lastIndex=this.lastIndex+1,f=d.exec(o)}return f&&(this.regexIndex+=f.position+1,this.regexIndex===this.count&&this.considerAll()),f}}function c(i){let o=new s;return i.contains.forEach(l=>o.addRule(l.begin,{rule:l,type:"begin"})),i.terminatorEnd&&o.addRule(i.terminatorEnd,{type:"end"}),i.illegal&&o.addRule(i.illegal,{type:"illegal"}),o}function r(i,o){let l=i;if(i.isCompiled)return l;[xn,On,Un,In].forEach(d=>d(i,o)),e.compilerExtensions.forEach(d=>d(i,o)),i.__beforeBegin=null,[Nn,Rn,Cn].forEach(d=>d(i,o)),i.isCompiled=!0;let f=null;return typeof i.keywords=="object"&&i.keywords.$pattern&&(i.keywords=Object.assign({},i.keywords),f=i.keywords.$pattern,delete i.keywords.$pattern),f=f||/\w+/,i.keywords&&(i.keywords=dt(i.keywords,e.case_insensitive)),l.keywordPatternRe=t(f,!0),o&&(i.begin||(i.begin=/\B|\b/),l.beginRe=t(l.begin),!i.end&&!i.endsWithParent&&(i.end=/\B|\b/),i.end&&(l.endRe=t(l.end)),l.terminatorEnd=ce(l.end)||"",i.endsWithParent&&o.terminatorEnd&&(l.terminatorEnd+=(i.end?"|":"")+o.terminatorEnd)),i.illegal&&(l.illegalRe=t(i.illegal)),i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map(function(d){return qn(d==="self"?i:d)})),i.contains.forEach(function(d){r(d,l)}),i.starts&&r(i.starts,o),l.matcher=c(l),l}if(e.compilerExtensions||(e.compilerExtensions=[]),e.contains&&e.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return e.classNameAliases=G(e.classNameAliases||{}),r(e)}function gt(e){return e?e.endsWithParent||gt(e.starts):!1}function qn(e){return e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map(function(t){return G(e,{variants:null},t)})),e.cachedVariants?e.cachedVariants:gt(e)?G(e,{starts:e.starts?G(e.starts):null}):Object.isFrozen(e)?G(e):e}var jn="11.11.1",Oe=class extends Error{constructor(t,n){super(t),this.name="HTMLInjectionError",this.html=n}},Ae=it,tt=G,nt=Symbol("nomatch"),Gn=7,ht=function(e){let t=Object.create(null),n=Object.create(null),s=[],c=!0,r="Could not find the language '{}', did you forget to load/include a language module?",i={disableAutodetect:!0,name:"Plain text",contains:[]},o={ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",cssSelector:"pre code",languages:null,__emitter:Re};function l(a){return o.noHighlightRe.test(a)}function f(a){let h=a.className+" ";h+=a.parentNode?a.parentNode.className:"";let m=o.languageDetectRe.exec(h);if(m){let M=W(m[1]);return M||(et(r.replace("{}",m[1])),et("Falling back to no-highlight mode for this block.",a)),M?m[1]:"no-highlight"}return h.split(/\s+/).find(M=>l(M)||W(M))}function d(a,h,m){let M="",L="";typeof h=="object"?(M=a,m=h.ignoreIllegals,L=h.language):(J("10.7.0","highlight(lang, code, ...args) has been deprecated."),J("10.7.0",`Please use highlight(code, options) instead. +https://github.com/highlightjs/highlight.js/issues/2277`),L=a,M=h),m===void 0&&(m=!0);let k={code:M,language:L};ge("before:highlight",k);let j=k.result?k.result:y(k.language,k.code,m);return j.code=k.code,ge("after:highlight",j),j}function y(a,h,m,M){let L=Object.create(null);function k(u,g){return u.keywords[g]}function j(){if(!p.keywords){A.addText(w);return}let u=0;p.keywordPatternRe.lastIndex=0;let g=p.keywordPatternRe.exec(w),b="";for(;g;){b+=w.substring(u,g.index);let E=B.case_insensitive?g[0].toLowerCase():g[0],x=k(p,E);if(x){let[q,zt]=x;if(A.addText(b),b="",L[E]=(L[E]||0)+1,L[E]<=Gn&&(be+=zt),q.startsWith("_"))b+=g[0];else{let Kt=B.classNameAliases[q]||q;$(g[0],Kt)}}else b+=g[0];u=p.keywordPatternRe.lastIndex,g=p.keywordPatternRe.exec(w)}b+=w.substring(u),A.addText(b)}function he(){if(w==="")return;let u=null;if(typeof p.subLanguage=="string"){if(!t[p.subLanguage]){A.addText(w);return}u=y(p.subLanguage,w,!0,Ze[p.subLanguage]),Ze[p.subLanguage]=u._top}else u=_(w,p.subLanguage.length?p.subLanguage:null);p.relevance>0&&(be+=u.relevance),A.__addSublanguage(u._emitter,u.language)}function R(){p.subLanguage!=null?he():j(),w=""}function $(u,g){u!==""&&(A.startScope(g),A.addText(u),A.endScope())}function Ge(u,g){let b=1,E=g.length-1;for(;b<=E;){if(!u._emit[b]){b++;continue}let x=B.classNameAliases[u[b]]||u[b],q=g[b];x?$(q,x):(w=q,j(),w=""),b++}}function ze(u,g){return u.scope&&typeof u.scope=="string"&&A.openNode(B.classNameAliases[u.scope]||u.scope),u.beginScope&&(u.beginScope._wrap?($(w,B.classNameAliases[u.beginScope._wrap]||u.beginScope._wrap),w=""):u.beginScope._multi&&(Ge(u.beginScope,g),w="")),p=Object.create(u,{parent:{value:p}}),p}function Ke(u,g,b){let E=ln(u.endRe,b);if(E){if(u["on:end"]){let x=new _e(u);u["on:end"](g,x),x.isMatchIgnored&&(E=!1)}if(E){for(;u.endsParent&&u.parent;)u=u.parent;return u}}if(u.endsWithParent)return Ke(u.parent,g,b)}function Ut(u){return p.matcher.regexIndex===0?(w+=u[0],1):(Le=!0,0)}function Wt(u){let g=u[0],b=u.rule,E=new _e(b),x=[b.__beforeBegin,b["on:begin"]];for(let q of x)if(q&&(q(u,E),E.isMatchIgnored))return Ut(g);return b.skip?w+=g:(b.excludeBegin&&(w+=g),R(),!b.returnBegin&&!b.excludeBegin&&(w=g)),ze(b,u),b.returnBegin?0:g.length}function qt(u){let g=u[0],b=h.substring(u.index),E=Ke(p,u,b);if(!E)return nt;let x=p;p.endScope&&p.endScope._wrap?(R(),$(g,p.endScope._wrap)):p.endScope&&p.endScope._multi?(R(),Ge(p.endScope,u)):x.skip?w+=g:(x.returnEnd||x.excludeEnd||(w+=g),R(),x.excludeEnd&&(w=g));do p.scope&&A.closeNode(),!p.skip&&!p.subLanguage&&(be+=p.relevance),p=p.parent;while(p!==E.parent);return E.starts&&ze(E.starts,u),x.returnEnd?0:g.length}function jt(){let u=[];for(let g=p;g!==B;g=g.parent)g.scope&&u.unshift(g.scope);u.forEach(g=>A.openNode(g))}let pe={};function Xe(u,g){let b=g&&g[0];if(w+=u,b==null)return R(),0;if(pe.type==="begin"&&g.type==="end"&&pe.index===g.index&&b===""){if(w+=h.slice(g.index,g.index+1),!c){let E=new Error(`0 width match regex (${a})`);throw E.languageName=a,E.badRule=pe.rule,E}return 1}if(pe=g,g.type==="begin")return Wt(g);if(g.type==="illegal"&&!m){let E=new Error('Illegal lexeme "'+b+'" for mode "'+(p.scope||"")+'"');throw E.mode=p,E}else if(g.type==="end"){let E=qt(g);if(E!==nt)return E}if(g.type==="illegal"&&b==="")return w+=` +`,1;if(Se>1e5&&Se>g.index*3)throw new Error("potential infinite loop, way more iterations than matches");return w+=b,b.length}let B=W(a);if(!B)throw Z(r.replace("{}",a)),new Error('Unknown language: "'+a+'"');let Gt=Wn(B),Te="",p=M||Gt,Ze={},A=new o.__emitter(o);jt();let w="",be=0,X=0,Se=0,Le=!1;try{if(B.__emitTokens)B.__emitTokens(h,A);else{for(p.matcher.considerAll();;){Se++,Le?Le=!1:p.matcher.considerAll(),p.matcher.lastIndex=X;let u=p.matcher.exec(h);if(!u)break;let g=h.substring(X,u.index),b=Xe(g,u);X=u.index+b}Xe(h.substring(X))}return A.finalize(),Te=A.toHTML(),{language:a,value:Te,relevance:be,illegal:!1,_emitter:A,_top:p}}catch(u){if(u.message&&u.message.includes("Illegal"))return{language:a,value:Ae(h),illegal:!0,relevance:0,_illegalBy:{message:u.message,index:X,context:h.slice(X-100,X+100),mode:u.mode,resultSoFar:Te},_emitter:A};if(c)return{language:a,value:Ae(h),illegal:!1,relevance:0,errorRaised:u,_emitter:A,_top:p};throw u}}function T(a){let h={value:Ae(a),illegal:!1,relevance:0,_top:i,_emitter:new o.__emitter(o)};return h._emitter.addText(a),h}function _(a,h){h=h||o.languages||Object.keys(t);let m=T(a),M=h.filter(W).filter(fe).map(R=>y(R,a,!1));M.unshift(m);let L=M.sort((R,$)=>{if(R.relevance!==$.relevance)return $.relevance-R.relevance;if(R.language&&$.language){if(W(R.language).supersetOf===$.language)return 1;if(W($.language).supersetOf===R.language)return-1}return 0}),[k,j]=L,he=k;return he.secondBest=j,he}function H(a,h,m){let M=h&&n[h]||m;a.classList.add("hljs"),a.classList.add(`language-${M}`)}function v(a){let h=null,m=f(a);if(l(m))return;if(ge("before:highlightElement",{el:a,language:m}),a.dataset.highlighted){console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",a);return}if(a.children.length>0&&(o.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),console.warn("The element with unescaped HTML:"),console.warn(a)),o.throwUnescapedHTML))throw new Oe("One of your code blocks includes unescaped HTML.",a.innerHTML);h=a;let M=h.textContent,L=m?d(M,{language:m,ignoreIllegals:!0}):_(M);a.innerHTML=L.value,a.dataset.highlighted="yes",H(a,m,L.language),a.result={language:L.language,re:L.relevance,relevance:L.relevance},L.secondBest&&(a.secondBest={language:L.secondBest.language,relevance:L.secondBest.relevance}),ge("after:highlightElement",{el:a,result:L,text:M})}function N(a){o=tt(o,a)}let I=()=>{K(),J("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")};function se(){K(),J("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.")}let Q=!1;function K(){function a(){K()}if(document.readyState==="loading"){Q||window.addEventListener("DOMContentLoaded",a,!1),Q=!0;return}document.querySelectorAll(o.cssSelector).forEach(v)}function ie(a,h){let m=null;try{m=h(e)}catch(M){if(Z("Language definition for '{}' could not be registered.".replace("{}",a)),c)Z(M);else throw M;m=i}m.name||(m.name=a),t[a]=m,m.rawDefinition=h.bind(null,e),m.aliases&&we(m.aliases,{languageName:a})}function D(a){delete t[a];for(let h of Object.keys(n))n[h]===a&&delete n[h]}function re(){return Object.keys(t)}function W(a){return a=(a||"").toLowerCase(),t[a]||t[n[a]]}function we(a,{languageName:h}){typeof a=="string"&&(a=[a]),a.forEach(m=>{n[m.toLowerCase()]=h})}function fe(a){let h=W(a);return h&&!h.disableAutodetect}function $t(a){a["before:highlightBlock"]&&!a["before:highlightElement"]&&(a["before:highlightElement"]=h=>{a["before:highlightBlock"](Object.assign({block:h.el},h))}),a["after:highlightBlock"]&&!a["after:highlightElement"]&&(a["after:highlightElement"]=h=>{a["after:highlightBlock"](Object.assign({block:h.el},h))})}function Bt(a){$t(a),s.push(a)}function Pt(a){let h=s.indexOf(a);h!==-1&&s.splice(h,1)}function ge(a,h){let m=a;s.forEach(function(M){M[m]&&M[m](h)})}function Ft(a){return J("10.7.0","highlightBlock will be removed entirely in v12.0"),J("10.7.0","Please use highlightElement now."),v(a)}Object.assign(e,{highlight:d,highlightAuto:_,highlightAll:K,highlightElement:v,highlightBlock:Ft,configure:N,initHighlighting:I,initHighlightingOnLoad:se,registerLanguage:ie,unregisterLanguage:D,listLanguages:re,getLanguage:W,registerAliases:we,autoDetection:fe,inherit:tt,addPlugin:Bt,removePlugin:Pt}),e.debugMode=function(){c=!1},e.safeMode=function(){c=!0},e.versionString=jn,e.regex={concat:V,lookahead:rt,either:Ce,optional:on,anyNumberOfTimes:rn};for(let a in me)typeof me[a]=="object"&&st(me[a]);return Object.assign(e,me),e},ee=ht({});ee.newInstance=()=>ht({});pt.exports=ee;ee.HighlightJS=ee;ee.default=ee});var mt=tn(bt(),1);var He=mt.default;function _t(e){let t=e.regex,n="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",s=t.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),c=t.concat(s,/(::\w+)*/),i={"variable.constant":["__FILE__","__LINE__","__ENCODING__"],"variable.language":["self","super"],keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield",...["include","extend","prepend","public","private","protected","raise","throw"]],built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"],literal:["true","false","nil"]},o={className:"doctag",begin:"@[A-Za-z]+"},l={begin:"#<",end:">"},f=[e.COMMENT("#","$",{contains:[o]}),e.COMMENT("^=begin","^=end",{contains:[o],relevance:10}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],d={className:"subst",begin:/#\{/,end:/\}/,keywords:i},y={className:"string",contains:[e.BACKSLASH_ESCAPE,d],variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{begin:t.concat(/<<[-~]?'?/,t.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)),contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,contains:[e.BACKSLASH_ESCAPE,d]})]}]},T="[1-9](_?[0-9])*|0",_="[0-9](_?[0-9])*",H={className:"number",relevance:0,variants:[{begin:`\\b(${T})(\\.(${_}))?([eE][+-]?(${_})|r)?i?\\b`},{begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{begin:"\\b0(_?[0-7])+r?i?\\b"}]},v={variants:[{match:/\(\)/},{className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0,keywords:i}]},D=[y,{variants:[{match:[/class\s+/,c,/\s+<\s+/,c]},{match:[/\b(class|module)\s+/,c]}],scope:{2:"title.class",4:"title.class.inherited"},keywords:i},{match:[/(include|extend)\s+/,c],scope:{2:"title.class"},keywords:i},{relevance:0,match:[c,/\.new[. (]/],scope:{1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},{relevance:0,match:s,scope:"title.class"},{match:[/def/,/\s+/,n],scope:{1:"keyword",3:"title.function"},contains:[v]},{begin:e.IDENT_RE+"::"},{className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol",begin:":(?!\\s)",contains:[y,{begin:n}],relevance:0},H,{className:"variable",begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{className:"params",begin:/\|(?!=)/,end:/\|/,excludeBegin:!0,excludeEnd:!0,relevance:0,keywords:i},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,d],illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}]}].concat(l,f),relevance:0}].concat(l,f);d.contains=D,v.contains=D;let fe=[{begin:/^\s*=>/,starts:{end:"$",contains:D}},{className:"meta.prompt",begin:"^("+"[>?]>"+"|"+"[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]"+"|"+"(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>"+")(?=[ ])",starts:{end:"$",keywords:i,contains:D}}];return f.unshift(l),{name:"Ruby",aliases:["rb","gemspec","podspec","thor","irb"],keywords:i,illegal:/\/\*/,contains:[e.SHEBANG({binary:"ruby"})].concat(fe).concat(f).concat(D)}}function Et(e){return oe(this,null,function*(){let t=new TextEncoder().encode(e),n=yield crypto.subtle.digest("SHA-1",t),s="";for(let c of new Uint8Array(n,0,4))s+=("0"+c.toString(16)).slice(-2);return s})}He.registerLanguage("ruby",_t);var zn=240,Kn=160,Xn=90,Zn=75;function U(e,t){return(t||document).querySelector(e)}function S(e,t){return Array.from((t||document).querySelectorAll(e))}function Y(e,t,n,s){typeof n=="function"?e.addEventListener(t,n):e.addEventListener(t,function(c){let r=c.target.closest(n);r&&e.contains(r)&&s&&s.call(r,c)})}function z(e){let t=document.createElement("div");return t.appendChild(document.createTextNode(e)),t.innerHTML}var Rt=[[31536e3,"year"],[2592e3,"month"],[86400,"day"],[3600,"hour"],[60,"minute"],[1,"second"]];function Vn(e){let t=Math.floor((Date.now()-e.getTime())/1e3);for(let[n,s]of Rt){let c=Math.floor(t/n);if(c>=1)return c===1?`about 1 ${s} ago`:`${c} ${s}s ago`}return"just now"}function Yn(e){let t=(Date.now()-e.getTime())/1e3;for(let[n]of Rt){let s=Math.floor(t/n);if(s>=1){let c=(s+1)*n;return Math.max((c-t)*1e3+500,1e3)}}return 1e3}function ne(e){return e>=Xn?"green":e>=Zn?"yellow":"red"}function F(e){return e.toString().replace(/\B(?=(\d{3})+(?!\d))/g,",")}function de(e){return(Math.floor(e*100)/100).toFixed(2)}var Ot={};function We(e){return Ot[e]}function Qn(e){return oe(this,null,function*(){let t=yield Promise.all(e.map(Et));e.forEach((n,s)=>{Ot[n]=t[s]})})}function Jn(e){return e.replace(/^[^a-zA-Z]+/,"").replace(/[^a-zA-Z0-9\-_]/g,"")}function Ct(e){let t=ne(e),n=de(e);return`
    `}function te(e,t,n,s,c){let r=ne(e),i=de(e),o=`
    ${Ct(e)}${i}%
    `;if(c)return`${o}${F(t)}/${F(n)}`;let l=` data-order="${de(e)}"`;return`${o}${F(t)}/${F(n)}`}function De(e,t,n,s){return` +
    + ${e} +
    + + +
    +
    + + ${n} + ${s}`}function $e(e,t,n,s,c,r={}){if(!c)return`
    + ${t}: disabled +
    `;let i=s-n,o=s>0?n*100/s:100,l=ne(o),f=r.suffix||"covered",d=r.missedClass||"red",y=`
    + ${t}: ${de(o)}% ${n}/${s} ${f}`;if(i>0){let T=r.toggle?`${i} missed`:`${i} missed`;y+=`, + ${T}`}return y+=` +
    `,y}function es(e,t,n,s,c,r,i,o,l){return'
    '+$e("line","Line coverage",e,t,!0,{suffix:"relevant lines covered"})+$e("branch","Branch coverage",n,s,i,{missedClass:"missed-branch-text"})+$e("method","Method coverage",c,r,o,{missedClass:"missed-method-text-color",toggle:l})+"
    "}function ts(e,t,n,s,c,r){let i=e+1;if(t==="ignored")return"skipped";if(c){let o=n[i];if(o&&o.some(([,l])=>l===0))return"missed-branch"}return r&&s.has(i)?"missed-method":t===null?"never":t===0?"missed":"covered"}function ns(e){let t={};if(!e)return t;for(let n of e)n.coverage!=="ignored"&&(t[n.report_line]||(t[n.report_line]=[]),t[n.report_line].push([n.type,n.coverage]));return t}function ss(e){let t=new Set;if(!e)return t;for(let n of e)if(n.coverage===0&&n.start_line&&n.end_line)for(let s=n.start_line;s<=n.end_line;s++)t.add(s);return t}function is(e,t,n,s){let c=We(e),r=t.covered_lines,i=t.total_lines,o=n&&t.covered_branches||0,l=n&&t.total_branches||0,f=s&&t.covered_methods||0,d=s&&t.total_methods||0,y=(t.methods||[]).filter(N=>N.coverage===0),T=s&&y.length>0,_=ns(t.branches),H=ss(t.methods),v=`
    `;if(v+='
    ',v+=`

    ${z(e)}

    `,v+=es(r,i,o,l,f,d,n,s,T),T){v+='"}v+="
    ",v+="
      ";for(let N=0;N`,se==="covered"||I!==null&&I!=="ignored"&&I!==0?v+=``:I==="ignored"&&(v+=''),n){let ie=_[Q];if(ie)for(let[D,re]of ie)v+=``}v+=`${z(t.source[N])}`}return v+="
    ",v}function vt(e,t,n,s,c,r){let i=Jn(e),o=n.lines,l=c?n.branches:void 0,f=r?n.methods:void 0,d=`
    `;d+=`${z(e)}`,d+=`${de(o.percent)}%`,d+='
    ',d+='',d+=De("Line Coverage","line","Covered","Lines"),c&&(d+=De("Branch Coverage","branch","Covered","Branches")),r&&(d+=De("Method Coverage","method","Covered","Methods")),d+="";let y=t.length===1?"file":"files";d+=``,d+=te(o.percent,o.covered,o.total,"line",!0),l&&(d+=te(l.percent,l.covered,l.total,"branch",!0)),f&&(d+=te(f.percent,f.covered,f.total,"method",!0)),d+="";for(let T of t){let _=s[T];if(!_)continue;let H=We(T),v=`data-covered-lines="${_.covered_lines}" data-relevant-lines="${_.total_lines}"`;c&&(v+=` data-covered-branches="${_.covered_branches||0}" data-total-branches="${_.total_branches||0}"`),r&&(v+=` data-covered-methods="${_.covered_methods||0}" data-total-methods="${_.total_methods||0}"`),d+=``,d+=``,d+=te(_.lines_covered_percent,_.covered_lines,_.total_lines,"line",!1),c&&(d+=te(_.branches_covered_percent||100,_.covered_branches||0,_.total_branches||0,"branch",!1)),r&&(d+=te(_.methods_covered_percent||100,_.covered_methods||0,_.total_methods||0,"method",!1)),d+=""}return d+="
    File Name
    ${F(t.length)} ${y}
    ${z(T)}
    ",d}function rs(e){let t=e.meta,n=t.branch_coverage,s=t.method_coverage;document.title=`Code coverage for ${t.project_name}`;let c=Object.keys(e.coverage),r=e.total.lines.total>0?e.total.lines.percent:100,i=document.createElement("link");i.rel="icon",i.type="image/png",i.href=`favicon_${ne(r)}.png`,document.head.appendChild(i),n&&document.body.setAttribute("data-branch-coverage","true");let o=document.getElementById("content");o.innerHTML=vt("All Files",c,e.total,e.coverage,n,s);for(let _ of Object.keys(e.groups)){let H=e.groups[_],v=H.files||[];o.innerHTML+=vt(_,v,H,e.coverage,n,s)}let l={};for(let _ of c)l[We(_)]=_;window._simplecovIdMap=l,window._simplecovFiles=e.coverage,window._simplecovBranchCoverage=n,window._simplecovMethodCoverage=s;let f=new Date(t.timestamp),d=document.getElementById("footer");d.innerHTML=`Generated ${f.toISOString()} by simplecov v${z(t.simplecov_version)} using ${z(t.command_name)}`;let y=document.getElementById("source-legend"),T='CoveredSkippedMissed line';n&&(T+='Missed branch'),s&&(T+='Missed method'),y.innerHTML=T}var yt={};function Mt(e,t){let n=0;for(let s=0;s{let d=wt(Mt(l,t)),y=wt(Mt(f,t)),T;return typeof d=="number"&&typeof y=="number"?T=d-y:T=String(d).localeCompare(String(y)),c==="asc"?T:-T}),r.append(...i);let o=0;S("thead tr:first-child th",e).forEach(l=>{let f=parseInt(l.getAttribute("colspan")||"1",10);l.classList.remove("sorting_asc","sorting_desc","sorting");let d=t>=o&&te>t,gte:(e,t)=>e>=t,eq:(e,t)=>e===t,lte:(e,t)=>e<=t,lt:(e,t)=>e!0))(t,n)}function as(e){return S(".col-filter__value",e).map(t=>{let n=t;if(!n.value)return null;let s=parseFloat(n.value);if(isNaN(s))return null;let c=n.dataset.type||"",r=U(`.col-filter__op[data-type="${c}"]`,e),i=r?r.value:"";if(!i)return null;let o=Ue[c];return o?{attrs:o,op:i,threshold:s}:null}).filter(t=>t!==null)}function Tt(e){let t=U("table.file_list",e);if(!t)return;let n=U(".col-filter--name",e),s=n?n.value:"",c=as(e);S("tbody tr.t-file",t).forEach(r=>{let i=r,o=!0;if(s&&((r.children[0].textContent||"").toLowerCase().includes(s.toLowerCase())||(o=!1)),o)for(let l of c){let f=parseInt(i.dataset[l.attrs.covered]||"0",10)||0,d=parseInt(i.dataset[l.attrs.total]||"0",10)||0,y=d>0?f*100/d:100;if(!ls(l.op,y,l.threshold)){o=!1;break}}i.style.display=o?"":"none"}),kt(),us(e),qe()}function Be(e){let t=parseFloat(e.value),n=e.closest(".col-filter__coverage"),s=n?n.querySelector(".col-filter__op"):null;if(!s)return;let c=s.querySelector('option[value="gt"]'),r=s.querySelector('option[value="lt"]');if(c&&(c.disabled=t>=100),r&&(r.disabled=t<=0),s.selectedOptions[0]&&s.selectedOptions[0].disabled){let i=s.querySelector("option:not(:disabled)");i&&(s.value=i.value)}}function us(e){let t=S("tbody tr.t-file",e).filter(r=>r.style.display!=="none");function n(r){let i=0;return t.forEach(o=>{i+=parseInt(o.dataset[r]||"0",10)||0}),i}let s=U(".t-file-count",e),c=parseInt(e.getAttribute("data-total-files")||"0",10);if(s){let r=t.length===1?" file":" files";s.textContent=t.length===c?F(c)+r:F(t.length)+"/"+F(c)+r}for(let r of Object.keys(Ue)){let i=Ue[r],o=`.t-totals__${r}`;U(o+"-pct",e)&&ds(e,o,n(i.covered),n(i.total))}}function ds(e,t,n,s){let c=U(t+"-pct",e),r=U(t+"-num",e),i=U(t+"-den",e);if(s===0){c&&(c.innerHTML="",c.className=c.className.replace(/green|yellow|red/g,"").trim()),r&&(r.textContent=""),i&&(i.textContent="");return}let o=n*100/s,l=ne(o);c&&(c.innerHTML=`
    ${Ct(o)}${o.toFixed(2)}%
    `,c.className=`${c.className.replace(/green|yellow|red/g,"").trim()} ${l}`),r&&(r.textContent=F(n)+"/"),i&&(i.textContent=F(s))}function fs(e){let t=document.getElementById(e);if(t)return t;let n=window._simplecovIdMap,s=window._simplecovFiles,c=window._simplecovBranchCoverage,r=window._simplecovMethodCoverage,i=n[e];if(!i)return null;let o=is(i,s[i],c,r),l=document.querySelector(".source_files"),f=document.createElement("div");f.innerHTML=o;let d=f.firstElementChild;return l.appendChild(d),S("pre code",d).forEach(y=>{He.highlightElement(y)}),d}function St(e,t){let n=t+"px";e.forEach(s=>{let c=s.style;c.width=n,c.minWidth=n,c.maxWidth=n})}function It(){S(".file_list_container").forEach(e=>{if(e.style.display==="none"||e.offsetWidth===0)return;let t=U("table.file_list",e);if(!t)return;let n=S(".bar-sizer",t);if(n.length===0)return;let s=t.closest(".file_list--responsive");if(!s)return;s.style.visibility="hidden";let c=Kn,r=zn;for(;c{Pe=0,It()}))}var O=null,ae=null;function kt(){ae=null}function gs(){if(ae)return ae;let e=S(".file_list_container").filter(t=>t.style.display!=="none");return e.length?(ae=S("tbody tr.t-file",e[0]).filter(t=>t.style.display!=="none"),ae):[]}function Me(e){O&&O.classList.remove("keyboard-focus"),O=e,O&&(O.classList.add("keyboard-focus"),O.scrollIntoView({block:"nearest"}))}function Lt(e){let t=gs();if(!t.length)return;if(!O||t.indexOf(O)===-1){Me(e===1?t[0]:t[t.length-1]);return}let n=t.indexOf(O)+e;n>=0&&ni.offsetTop>s)||t[0];C.scrollTop=r.offsetTop-C.clientHeight/3}else{let c=null;for(let i=t.length-1;i>=0;i--)if(t[i].offsetTopc.classList.remove("active")),n.parentElement.classList.add("active"),S(".file_list_container").forEach(c=>c.style.display="none");let s=document.getElementById(e);s&&(s.style.display="")}}let t=document.getElementById("wrapper");t&&!t.classList.contains("hide")&&qe()}function Nt(){let e=window.location.hash.substring(1);if(!e){let t=document.querySelector(".group_tabs a");t&&xt(t.getAttribute("href").replace("#",""));return}if(e.charAt(0)==="_")xt(e.substring(1));else{let t=e.split("-L");if(!document.querySelector(".group_tabs li.active")){let n=document.querySelector(".group_tabs li");n&&n.classList.add("active")}bs(t[0],t[1])}}function Fe(){let e=document.querySelector(".group_tabs li.active a");e&&(window.location.hash=e.getAttribute("href").replace("#","#_"))}function ms(){let e=document.getElementById("dark-mode-toggle");if(!e)return;let t=document.documentElement;function n(){return t.classList.contains("dark-mode")||!t.classList.contains("light-mode")&&window.matchMedia("(prefers-color-scheme: dark)").matches}function s(){e.textContent=n()?"\u2600\uFE0F Light":"\u{1F319} Dark"}s(),e.addEventListener("click",()=>{let c=n();t.classList.toggle("light-mode",c),t.classList.toggle("dark-mode",!c),localStorage.setItem("simplecov-dark-mode",c?"light":"dark"),s()}),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{localStorage.getItem("simplecov-dark-mode")||s()})}function Dt(){return oe(this,null,function*(){if(!window.SIMPLECOV_DATA){window.addEventListener("load",Dt);return}let e=window.SIMPLECOV_DATA,t=document.getElementById("loading");t&&(t.style.display=""),yield Qn(Object.keys(e.coverage)),rs(e);function n(){let r=1/0;S("abbr.timeago").forEach(i=>{let o=new Date(i.getAttribute("title")||"");isNaN(o.getTime())||(i.textContent=Vn(o),r=Math.min(r,Yn(o)))}),r<1/0&&setTimeout(n,r)}n(),ms();function s(r,i){let o=0;for(let l of S("thead tr:first-child th",r)){let f=parseInt(l.getAttribute("colspan")||"1",10);if(l===i)return o+f-1;o+=f}return o}S("table.file_list").forEach(r=>{S("thead tr:first-child th",r).forEach(i=>{i.classList.add("sorting"),i.style.cursor="pointer",i.addEventListener("click",()=>os(r,s(r,i)))})}),S(".col-filter__value").forEach(r=>Be(r)),S(".col-filter--name, .col-filter__op, .col-filter__value, .col-filter__coverage").forEach(r=>{r.addEventListener("click",i=>i.stopPropagation())}),Y(document,"input",".col-filter--name, .col-filter__op, .col-filter__value",function(){this.classList.contains("col-filter__value")&&Be(this),Tt(this.closest(".file_list_container"))}),Y(document,"change",".col-filter__op, .col-filter__value",function(){this.classList.contains("col-filter__value")&&Be(this),Tt(this.closest(".file_list_container"))}),document.addEventListener("keydown",r=>{let i=r.target.matches("input, select, textarea");if(r.key==="/"&&!i){r.preventDefault();let o=S(".file_list_container").filter(f=>f.style.display!=="none"),l=o.length?U(".col-filter--name",o[0]):null;l&&l.focus();return}if(r.key==="Escape"){P.open?(r.preventDefault(),Fe()):i?r.target.blur():O&&Me(null);return}if(!i){if(P.open){r.key==="n"&&!r.shiftKey&&(r.preventDefault(),At(1)),(r.key==="N"||r.key==="n"&&r.shiftKey||r.key==="p")&&(r.preventDefault(),At(-1));return}r.key==="j"&&(r.preventDefault(),Lt(1)),r.key==="k"&&(r.preventDefault(),Lt(-1)),r.key==="Enter"&&O&&(r.preventDefault(),hs())}}),P=document.getElementById("source-dialog"),C=document.getElementById("source-dialog-body"),je=document.getElementById("source-dialog-title"),P.querySelector(".source-dialog__close").addEventListener("click",Fe),P.addEventListener("click",r=>{r.target===P&&Fe()}),Y(document,"click",".t-missed-method-toggle",function(r){r.preventDefault();let i=this.closest(".header")||this.closest(".source-dialog__title")||this.closest(".source-dialog__header"),o=i?i.querySelector(".t-missed-method-list"):null;o&&(o.style.display=o.style.display==="none"?"":"none")}),Y(document,"click","a.src_link",function(r){r.preventDefault(),window.location.hash=this.getAttribute("href").substring(1)}),Y(document,"click","table.file_list tbody tr",function(r){if(r.target.closest("a"))return;let i=this.querySelector("a.src_link");i&&(window.location.hash=i.getAttribute("href").substring(1))}),Y(document,"click",".source-dialog .source_table li[data-linenumber]",function(r){r.preventDefault(),C.scrollTop=this.offsetTop;let i=this.dataset.linenumber,o=window.location.hash.substring(1).replace(/-L.*/,"");window.location.replace(window.location.href.replace(/#.*/,"#"+o+"-L"+i))}),window.addEventListener("hashchange",Nt),S(".file_list_container").forEach(r=>r.style.display="none"),S(".file_list_container").forEach(r=>{let i=r.id,o=r.querySelector(".group_name"),l=r.querySelector(".covered_percent"),f=document.createElement("li");f.setAttribute("role","tab");let d=document.createElement("a");d.href="#"+i,d.className=i,d.innerHTML=(o?o.innerHTML:"")+" ("+(l?l.innerHTML:"")+")",f.appendChild(d),document.querySelector(".group_tabs").appendChild(f)}),Y(document.querySelector(".group_tabs"),"click","a",function(r){r.preventDefault(),window.location.hash=this.getAttribute("href").replace("#","#_")}),window.addEventListener("resize",qe),Nt(),t&&(t.style.transition="opacity 0.3s",t.style.opacity="0",setTimeout(()=>{t.style.display="none"},300));let c=document.getElementById("wrapper");c&&c.classList.remove("hide"),It()})}document.addEventListener("DOMContentLoaded",Dt);})(); diff --git a/lib/simplecov/formatter/html_formatter/public/index.html b/lib/simplecov/formatter/html_formatter/public/index.html new file mode 100644 index 00000000..f3262ec2 --- /dev/null +++ b/lib/simplecov/formatter/html_formatter/public/index.html @@ -0,0 +1,55 @@ + + + + + + Code Coverage + + + + + + + + +
    +
    +
      + +
      + +
      + + + + +
      + + +
      +
      +
      + +
      +
      +
      + + + + diff --git a/lib/simplecov/formatter/html_formatter/view_helpers.rb b/lib/simplecov/formatter/html_formatter/view_helpers.rb deleted file mode 100644 index 25ec221d..00000000 --- a/lib/simplecov/formatter/html_formatter/view_helpers.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -require "digest/md5" -require "set" -require_relative "coverage_helpers" - -module SimpleCov - module Formatter - class HTMLFormatter - # Helper methods used by ERB templates for rendering coverage data. - module ViewHelpers - include CoverageHelpers - - def line_status?(source_file, line) - if branch_coverage? && source_file.line_with_missed_branch?(line.number) - "missed-branch" - elsif method_coverage? && missed_method_lines(source_file).include?(line.number) - "missed-method" - else - line.status - end - end - - def missed_method_lines(source_file) - @missed_method_lines ||= {} - @missed_method_lines[source_file.filename] ||= missed_method_line_set(source_file) - end - - def missed_method_line_set(source_file) - source_file.missed_methods - .select { |m| m.start_line && m.end_line } - .flat_map { |m| (m.start_line..m.end_line).to_a } - .to_set - end - - def coverage_css_class(covered_percent) - if covered_percent >= 90 - "green" - elsif covered_percent >= 75 - "yellow" - else - "red" - end - end - - def id(source_file) - Digest::MD5.hexdigest(source_file.filename) - end - - def timeago(time) - "#{time.iso8601}" - end - - def shortened_filename(source_file) - source_file.filename.sub(SimpleCov.root, ".").delete_prefix("./") - end - - def link_to_source_file(source_file) - name = shortened_filename(source_file) - %(#{name}) - end - - def covered_percent(percent) - template("covered_percent").result(binding) - end - - def to_id(value) - value.sub(/\A[^a-zA-Z]+/, "").gsub(/[^a-zA-Z0-9\-_]/, "") - end - - def fmt(number) - number.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,') - end - end - end - end -end diff --git a/lib/simplecov/formatter/html_formatter/views/coverage_summary.erb b/lib/simplecov/formatter/html_formatter/views/coverage_summary.erb deleted file mode 100644 index cea5fe7d..00000000 --- a/lib/simplecov/formatter/html_formatter/views/coverage_summary.erb +++ /dev/null @@ -1,5 +0,0 @@ -
      - <%= coverage_type_summary("line", "Line coverage", _summary, enabled: true, suffix: "relevant lines covered") %> - <%= coverage_type_summary("branch", "Branch coverage", _summary, enabled: branch_coverage?, missed_class: "missed-branch-text") %> - <%= coverage_type_summary("method", "Method coverage", _summary, enabled: method_coverage?, missed_class: "missed-method-text-color", toggle: _summary[:show_method_toggle]) %> -
      diff --git a/lib/simplecov/formatter/html_formatter/views/covered_percent.erb b/lib/simplecov/formatter/html_formatter/views/covered_percent.erb deleted file mode 100644 index fddaede2..00000000 --- a/lib/simplecov/formatter/html_formatter/views/covered_percent.erb +++ /dev/null @@ -1 +0,0 @@ -<%= sprintf("%.2f", percent.floor(2)) %>% diff --git a/lib/simplecov/formatter/html_formatter/views/file_list.erb b/lib/simplecov/formatter/html_formatter/views/file_list.erb deleted file mode 100644 index e85b34b1..00000000 --- a/lib/simplecov/formatter/html_formatter/views/file_list.erb +++ /dev/null @@ -1,55 +0,0 @@ -
      - <%= title %> - <%= covered_percent(source_files.covered_percent) %> - -
      - - - - - <%= coverage_header_cells("Line Coverage", "line", "Covered", "Lines") %> -<%- if branch_coverage? -%> - <%= coverage_header_cells("Branch Coverage", "branch", "Covered", "Branches") %> -<%- end -%> -<%- if method_coverage? -%> - <%= coverage_header_cells("Method Coverage", "method", "Covered", "Methods") %> -<%- end -%> - -<%- line_pct = source_files.lines_of_code > 0 ? source_files.covered_percent : 100.0 -%> -<%- branch_pct = branch_coverage? && source_files.total_branches > 0 ? source_files.branch_covered_percent : 100.0 -%> -<%- method_pct = method_coverage? && source_files.total_methods > 0 ? source_files.method_covered_percent : 100.0 -%> - - - <%= coverage_cells(line_pct, source_files.covered_lines, source_files.lines_of_code, type: "line", totals: true) %> -<%- if branch_coverage? -%> - <%= coverage_cells(branch_pct, source_files.covered_branches, source_files.total_branches, type: "branch", totals: true) %> -<%- end -%> -<%- if method_coverage? -%> - <%= coverage_cells(method_pct, source_files.covered_methods, source_files.total_methods, type: "method", totals: true) %> -<%- end -%> - - - -<%- source_files.each do |source_file| -%> -<%- covered_lines = source_file.covered_lines.count - relevant_lines = covered_lines + source_file.missed_lines.count -%> - > - - <%= coverage_cells(source_file.covered_percent, covered_lines, relevant_lines, type: "line") %> -<%- if branch_coverage? -%> - <%= coverage_cells(source_file.branches_coverage_percent, source_file.covered_branches.count, source_file.total_branches.count, type: "branch") %> -<%- end -%> -<%- if method_coverage? -%> - <%= coverage_cells(source_file.methods_coverage_percent, source_file.covered_methods.count, source_file.methods.count, type: "method") %> -<%- end -%> - -<%- end -%> - -
      -
      - File Name - -
      -
      <%= fmt(source_files.length) %> <%= source_files.length == 1 ? "file" : "files" %>
      <%= link_to_source_file(source_file) %>
      -
      -
      diff --git a/lib/simplecov/formatter/html_formatter/views/layout.erb b/lib/simplecov/formatter/html_formatter/views/layout.erb deleted file mode 100644 index ab9e02f1..00000000 --- a/lib/simplecov/formatter/html_formatter/views/layout.erb +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - Code coverage for <%= SimpleCov.project_name %> - - ' media='screen, projection, print' rel='stylesheet' type='text/css' /> - " /> - - - - > - - -
      -
      -
        - -
        - -
        - <%= formatted_file_list("All Files", result.source_files) %> -<%- result.groups.each do |name, files| -%> - <%= formatted_file_list(name, files) %> -<%- end -%> -
        - - - -
        -<%- result.source_files.each do |source_file| -%> - -<%- end -%> -
        -
        - - -
        -
        -
        - Covered - Skipped - Missed line -<%- if branch_coverage? -%> - Missed branch -<%- end -%> -<%- if method_coverage? -%> - Missed method -<%- end -%> -
        - -
        -
        -
        - - diff --git a/lib/simplecov/formatter/html_formatter/views/source_file.erb b/lib/simplecov/formatter/html_formatter/views/source_file.erb deleted file mode 100644 index 94a3f7f5..00000000 --- a/lib/simplecov/formatter/html_formatter/views/source_file.erb +++ /dev/null @@ -1,34 +0,0 @@ -
        -
        -

        <%= shortened_filename source_file %>

        - <%= coverage_summary(source_file, show_method_toggle: method_coverage? && source_file.missed_methods.any?) %> -<%- if method_coverage? && source_file.missed_methods.any? -%> - -<%- end -%> -
        -
        -    
          -<%- source_file.lines.each do |line| -%> -
        1. data-linenumber="<%= line.number %>"> -<%- if line.covered? -%> - -<%- elsif line.skipped? -%> - -<%- end -%> -<%- if branch_coverage? -%> -<%- source_file.branches_for_line(line.number).each do |branch_type, hit_count| -%> - -<%- end -%> -<%- end -%> - <%= ERB::Util.html_escape(line.src.chomp) %> -
        2. -<%- end -%> -
        -
        -
        diff --git a/lib/simplecov/formatter/json_formatter.rb b/lib/simplecov/formatter/json_formatter.rb index cc0370db..4dac5641 100644 --- a/lib/simplecov/formatter/json_formatter.rb +++ b/lib/simplecov/formatter/json_formatter.rb @@ -1,34 +1,54 @@ # frozen_string_literal: true require_relative "json_formatter/result_hash_formatter" -require_relative "json_formatter/result_exporter" require "json" +require "time" module SimpleCov module Formatter class JSONFormatter + FILENAME = "coverage.json" + def initialize(silent: false) @silent = silent end - def format(result) - result_hash = format_result(result) - - export_formatted_result(result_hash) + def self.build_hash(result) + ResultHashFormatter.new(result).format + end + def format(result) + path = File.join(SimpleCov.coverage_path, FILENAME) + warn_if_concurrent_overwrite(path) + File.write(path, JSON.pretty_generate(self.class.build_hash(result))) puts output_message(result) unless @silent end private - def format_result(result) - result_hash_formater = ResultHashFormatter.new(result) - result_hash_formater.format + # Warns when the existing coverage.json has a timestamp newer than this + # process's start time — a strong signal that a sibling test process + # (e.g., parallel_tests) wrote it while we were running, and that our + # write is about to clobber their data. + def warn_if_concurrent_overwrite(path) + start_time = SimpleCov.process_start_time or return + existing_ts = existing_timestamp(path) or return + return unless existing_ts > start_time + + warn "simplecov: #{path} was written at #{existing_ts.iso8601} — after " \ + "this process started at #{start_time.iso8601}. Overwriting " \ + "likely loses coverage data from a concurrent test run. For " \ + "parallel test setups, use SimpleCov::ResultMerger or run a single " \ + "collation step after all workers finish." end - def export_formatted_result(result_hash) - result_exporter = ResultExporter.new(result_hash) - result_exporter.export + def existing_timestamp(path) + return nil unless File.exist?(path) + + timestamp = JSON.parse(File.read(path), symbolize_names: true).dig(:meta, :timestamp) + timestamp && Time.iso8601(timestamp) + rescue JSON::ParserError, ArgumentError + nil end def output_message(result) diff --git a/lib/simplecov/formatter/json_formatter/result_exporter.rb b/lib/simplecov/formatter/json_formatter/result_exporter.rb deleted file mode 100644 index 7e98e954..00000000 --- a/lib/simplecov/formatter/json_formatter/result_exporter.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -module SimpleCov - module Formatter - class JSONFormatter - class ResultExporter - FILENAME = "coverage.json" - - def initialize(result_hash) - @result = result_hash - end - - def export - File.open(export_path, "w") do |file| - file << json_result - end - end - - private - - def json_result - JSON.pretty_generate(@result) - end - - def export_path - File.join(SimpleCov.coverage_path, FILENAME) - end - end - end - end -end diff --git a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb index 698fc23a..ca555325 100644 --- a/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb +++ b/lib/simplecov/formatter/json_formatter/result_hash_formatter.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "source_file_formatter" +require "time" module SimpleCov module Formatter @@ -27,14 +27,16 @@ def format_total def format_files @result.files.each do |source_file| - formatted_result[:coverage][source_file.filename] = + formatted_result[:coverage][source_file.project_filename] = format_source_file(source_file) end end def format_groups @result.groups.each do |name, file_list| - formatted_result[:groups][name] = format_coverage_statistics(file_list.coverage_statistics) + group_data = format_coverage_statistics(file_list.coverage_statistics) + group_data[:files] = file_list.map(&:project_filename) + formatted_result[:groups][name] = group_data end end @@ -61,7 +63,7 @@ def format_minimum_coverage_by_file_errors key = CRITERION_KEYS.fetch(violation.fetch(:criterion)) bucket = formatted_result[:errors][:minimum_coverage_by_file] ||= {} criterion_errors = bucket[key] ||= {} - criterion_errors[violation.fetch(:filename)] = {expected: violation.fetch(:expected), actual: violation.fetch(:actual)} + criterion_errors[violation.fetch(:project_filename)] = {expected: violation.fetch(:expected), actual: violation.fetch(:actual)} end end @@ -83,20 +85,93 @@ def format_maximum_coverage_drop_errors end def formatted_result - @formatted_result ||= { - meta: { - simplecov_version: SimpleCov::VERSION - }, - total: {}, - coverage: {}, - groups: {}, - errors: {} + @formatted_result ||= {meta: format_meta, total: {}, coverage: {}, groups: {}, errors: {}} + end + + def format_meta + { + simplecov_version: SimpleCov::VERSION, + command_name: @result.command_name, + project_name: SimpleCov.project_name, + timestamp: @result.created_at.iso8601(3), + root: SimpleCov.root, + branch_coverage: SimpleCov.branch_coverage?, + method_coverage: SimpleCov.method_coverage? } end def format_source_file(source_file) - source_file_formatter = SourceFileFormatter.new(source_file) - source_file_formatter.format + result = format_line_coverage(source_file) + result.merge!(format_source_code(source_file)) + result.merge!(format_branch_coverage(source_file)) if SimpleCov.branch_coverage? + result.merge!(format_method_coverage(source_file)) if SimpleCov.method_coverage? + result + end + + def format_source_code(source_file) + {source: source_file.lines.map { |line| ensure_utf8(line.src.chomp) }} + end + + def ensure_utf8(str) + str.encode("UTF-8", invalid: :replace, undef: :replace) + end + + def format_line_coverage(source_file) + covered = source_file.covered_lines.count + missed = source_file.missed_lines.count + { + lines: source_file.lines.map { |line| format_line(line) }, + lines_covered_percent: source_file.covered_percent, + covered_lines: covered, + missed_lines: missed, + total_lines: covered + missed + } + end + + def format_branch_coverage(source_file) + { + branches: source_file.branches.map { |branch| format_branch(branch) }, + branches_covered_percent: source_file.branches_coverage_percent, + covered_branches: source_file.covered_branches.count, + missed_branches: source_file.missed_branches.count, + total_branches: source_file.total_branches.count + } + end + + def format_method_coverage(source_file) + { + methods: source_file.methods.map { |method| format_method(method) }, + methods_covered_percent: source_file.methods_coverage_percent, + covered_methods: source_file.covered_methods.count, + missed_methods: source_file.missed_methods.count, + total_methods: source_file.methods.count + } + end + + def format_line(line) + return line.coverage unless line.skipped? + + "ignored" + end + + def format_branch(branch) + { + type: branch.type, + start_line: branch.start_line, + end_line: branch.end_line, + coverage: format_line(branch), + inline: branch.inline?, + report_line: branch.report_line + } + end + + def format_method(method) + { + name: method.to_s, + start_line: method.start_line, + end_line: method.end_line, + coverage: format_line(method) + } end def format_coverage_statistics(statistics) diff --git a/lib/simplecov/formatter/json_formatter/source_file_formatter.rb b/lib/simplecov/formatter/json_formatter/source_file_formatter.rb deleted file mode 100644 index 246eca3e..00000000 --- a/lib/simplecov/formatter/json_formatter/source_file_formatter.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module SimpleCov - module Formatter - class JSONFormatter - class SourceFileFormatter - def initialize(source_file) - @source_file = source_file - @line_coverage = nil - end - - def format - result = line_coverage - result.merge!(branch_coverage) if SimpleCov.branch_coverage? - result.merge!(method_coverage) if SimpleCov.method_coverage? - result - end - - private - - def line_coverage - @line_coverage ||= { - lines: lines, - lines_covered_percent: @source_file.covered_percent - } - end - - def branch_coverage - { - branches: branches, - branches_covered_percent: @source_file.branches_coverage_percent - } - end - - def method_coverage - { - methods: format_methods, - methods_covered_percent: @source_file.methods_coverage_percent - } - end - - def lines - @source_file.lines.collect do |line| - parse_line(line) - end - end - - def branches - @source_file.branches.collect do |branch| - parse_branch(branch) - end - end - - def format_methods - @source_file.methods.collect do |method| - parse_method(method) - end - end - - def parse_line(line) - return line.coverage unless line.skipped? - - "ignored" - end - - def parse_branch(branch) - { - type: branch.type, - start_line: branch.start_line, - end_line: branch.end_line, - coverage: parse_line(branch) - } - end - - def parse_method(method) - { - name: method.to_s, - start_line: method.start_line, - end_line: method.end_line, - coverage: parse_line(method) - } - end - end - end - end -end diff --git a/lib/simplecov/profiles/hidden_filter.rb b/lib/simplecov/profiles/hidden_filter.rb index 0525fad0..9823b531 100644 --- a/lib/simplecov/profiles/hidden_filter.rb +++ b/lib/simplecov/profiles/hidden_filter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true SimpleCov.profiles.define "hidden_filter" do - add_filter %r{^/\..*} + add_filter %r{\A\..*} end diff --git a/lib/simplecov/profiles/rails.rb b/lib/simplecov/profiles/rails.rb index a0f989e0..6aad89af 100644 --- a/lib/simplecov/profiles/rails.rb +++ b/lib/simplecov/profiles/rails.rb @@ -3,8 +3,8 @@ SimpleCov.profiles.define "rails" do load_profile "test_frameworks" - add_filter %r{^/config/} - add_filter %r{^/db/} + add_filter %r{\Aconfig/} + add_filter %r{\Adb/} add_group "Controllers", "app/controllers" add_group "Channels", "app/channels" diff --git a/lib/simplecov/profiles/test_frameworks.rb b/lib/simplecov/profiles/test_frameworks.rb index c92b5d1c..7914cafa 100644 --- a/lib/simplecov/profiles/test_frameworks.rb +++ b/lib/simplecov/profiles/test_frameworks.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true SimpleCov.profiles.define "test_frameworks" do - add_filter %r{^/(test|features|spec|autotest)/} + add_filter %r{\A(test|features|spec|autotest)/} end diff --git a/lib/simplecov/source_file.rb b/lib/simplecov/source_file.rb index 8a2818e4..7796ff8c 100644 --- a/lib/simplecov/source_file.rb +++ b/lib/simplecov/source_file.rb @@ -24,7 +24,7 @@ def initialize(filename, coverage_data, loaded: true) # The path to this source file relative to the projects directory def project_filename - @filename.delete_prefix(SimpleCov.root) + @filename.delete_prefix(SimpleCov.root).sub(%r{\A[/\\]}, "") end # The source code for this file. Aliased as :source diff --git a/spec/coverage_for_eval_spec.rb b/spec/coverage_for_eval_spec.rb index 37a20f03..1cff071e 100644 --- a/spec/coverage_for_eval_spec.rb +++ b/spec/coverage_for_eval_spec.rb @@ -19,7 +19,7 @@ let(:command) { "bundle e ruby eval_test.rb" } it "records coverage for erb" do - expect(@stdout).to include("Line coverage: 2 / 3 (66.67%)") + expect(@stdout).to include("Coverage report generated") end end end diff --git a/spec/default_formatter_spec.rb b/spec/default_formatter_spec.rb deleted file mode 100644 index 0a6e005e..00000000 --- a/spec/default_formatter_spec.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -require "helper" - -describe SimpleCov::Formatter do - describe ".from_env" do - let(:env) { {"CC_TEST_REPORTER_ID" => "4c9f1de6193f30799e9a5d5c082692abecc1fd2c6aa62c621af7b2a910761970"} } - - context "when CC_TEST_REPORTER_ID environment variable is set" do - it "returns an array containing the HTML and JSON formatters" do - expect(described_class.from_env(env)).to eq([ - SimpleCov::Formatter::HTMLFormatter, - SimpleCov::Formatter::JSONFormatter - ]) - end - end - - context "when CC_TEST_REPORTER_ID environment variable isn't set" do - let(:env) { {} } - - it "returns an array containing only the HTML formatter" do - expect(described_class.from_env(env)).to eq([ - SimpleCov::Formatter::HTMLFormatter - ]) - end - end - end -end diff --git a/spec/exit_codes/minimum_coverage_by_file_check_spec.rb b/spec/exit_codes/minimum_coverage_by_file_check_spec.rb index ffad42c4..f6d89ee7 100644 --- a/spec/exit_codes/minimum_coverage_by_file_check_spec.rb +++ b/spec/exit_codes/minimum_coverage_by_file_check_spec.rb @@ -11,7 +11,7 @@ let(:coverage_statistics) { {line: SimpleCov::CoverageStatistics.new(covered: 8, missed: 2)} } let(:files) do [ - instance_double(SimpleCov::SourceFile, coverage_statistics: coverage_statistics, filename: "/abs/lib/foo.rb", project_filename: "/lib/foo.rb") + instance_double(SimpleCov::SourceFile, coverage_statistics: coverage_statistics, filename: "/abs/lib/foo.rb", project_filename: "lib/foo.rb") ] end diff --git a/spec/filters_spec.rb b/spec/filters_spec.rb index 2f2e5ba8..9f8138c8 100644 --- a/spec/filters_spec.rb +++ b/spec/filters_spec.rb @@ -52,12 +52,12 @@ expect(SimpleCov::RegexFilter.new(/\/fixtures\//)).to be_matches subject end - it "doesn't match a new SimpleCov::RegexFilter /^/fixtures//" do - expect(SimpleCov::RegexFilter.new(/^\/fixtures\//)).not_to be_matches subject + it "doesn't match a new SimpleCov::RegexFilter /^fixtures//" do + expect(SimpleCov::RegexFilter.new(/^fixtures\//)).not_to be_matches subject end - it "matches a new SimpleCov::RegexFilter /^/spec//" do - expect(SimpleCov::RegexFilter.new(/^\/spec\//)).to be_matches subject + it "matches a new SimpleCov::RegexFilter /^spec//" do + expect(SimpleCov::RegexFilter.new(/^spec\//)).to be_matches subject end it "doesn't match a new SimpleCov::BlockFilter that is not applicable" do diff --git a/spec/fixtures/json/sample.json b/spec/fixtures/json/sample.json index 35e6929d..d76b26fb 100644 --- a/spec/fixtures/json/sample.json +++ b/spec/fixtures/json/sample.json @@ -1,6 +1,12 @@ { "meta": { - "simplecov_version": "0.22.0" + "simplecov_version": "0.22.0", + "command_name": "STUB_COMMAND_NAME", + "project_name": "STUB_PROJECT_NAME", + "timestamp": "2024-01-01T00:00:00.000+00:00", + "root": "/STUB_WORKING_DIRECTORY", + "branch_coverage": false, + "method_coverage": false }, "total": { "lines": { @@ -13,7 +19,7 @@ } }, "coverage": { - "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { + "spec/fixtures/json/sample.rb": { "lines": [ null, 1, @@ -41,7 +47,37 @@ "ignored", null ], - "lines_covered_percent": 90.0 + "source": [ + "# Foo class", + "class Foo", + " def initialize", + " @foo = \"bar\"", + " @bar = \"foo\"", + " end", + "", + " def bar", + " @foo", + " end", + "", + " def foo(param)", + " if param", + " @bar", + " else", + " @foo", + " end", + " end", + "", + " # :nocov:", + " def skipped", + " @foo * 2", + " end", + " # :nocov:", + "end" + ], + "lines_covered_percent": 90.0, + "covered_lines": 9, + "missed_lines": 1, + "total_lines": 10 } }, "groups": {}, diff --git a/spec/fixtures/json/sample_groups.json b/spec/fixtures/json/sample_groups.json index bf632dcc..0c5ed5c3 100644 --- a/spec/fixtures/json/sample_groups.json +++ b/spec/fixtures/json/sample_groups.json @@ -1,6 +1,12 @@ { "meta": { - "simplecov_version": "0.22.0" + "simplecov_version": "0.22.0", + "command_name": "STUB_COMMAND_NAME", + "project_name": "STUB_PROJECT_NAME", + "timestamp": "2024-01-01T00:00:00.000+00:00", + "root": "/STUB_WORKING_DIRECTORY", + "branch_coverage": false, + "method_coverage": false }, "total": { "lines": { @@ -13,7 +19,7 @@ } }, "coverage": { - "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { + "spec/fixtures/json/sample.rb": { "lines": [ null, 1, @@ -41,7 +47,37 @@ "ignored", null ], - "lines_covered_percent": 90.0 + "source": [ + "# Foo class", + "class Foo", + " def initialize", + " @foo = \"bar\"", + " @bar = \"foo\"", + " end", + "", + " def bar", + " @foo", + " end", + "", + " def foo(param)", + " if param", + " @bar", + " else", + " @foo", + " end", + " end", + "", + " # :nocov:", + " def skipped", + " @foo * 2", + " end", + " # :nocov:", + "end" + ], + "lines_covered_percent": 90.0, + "covered_lines": 9, + "missed_lines": 1, + "total_lines": 10 } }, "groups": { @@ -53,7 +89,10 @@ "total": 10, "percent": 80.0, "strength": 0.0 - } + }, + "files": [ + "spec/fixtures/json/sample.rb" + ] } }, "errors": {} diff --git a/spec/fixtures/json/sample_with_branch.json b/spec/fixtures/json/sample_with_branch.json index 903ebb0d..f7a32472 100644 --- a/spec/fixtures/json/sample_with_branch.json +++ b/spec/fixtures/json/sample_with_branch.json @@ -1,6 +1,12 @@ { "meta": { - "simplecov_version": "0.22.0" + "simplecov_version": "0.22.0", + "command_name": "STUB_COMMAND_NAME", + "project_name": "STUB_PROJECT_NAME", + "timestamp": "2024-01-01T00:00:00.000+00:00", + "root": "/STUB_WORKING_DIRECTORY", + "branch_coverage": true, + "method_coverage": false }, "total": { "lines": { @@ -20,7 +26,7 @@ } }, "coverage": { - "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { + "spec/fixtures/json/sample.rb": { "lines": [ null, 1, @@ -48,22 +54,59 @@ "ignored", null ], + "source": [ + "# Foo class", + "class Foo", + " def initialize", + " @foo = \"bar\"", + " @bar = \"foo\"", + " end", + "", + " def bar", + " @foo", + " end", + "", + " def foo(param)", + " if param", + " @bar", + " else", + " @foo", + " end", + " end", + "", + " # :nocov:", + " def skipped", + " @foo * 2", + " end", + " # :nocov:", + "end" + ], "lines_covered_percent": 90.0, + "covered_lines": 9, + "missed_lines": 1, + "total_lines": 10, "branches": [ { "type": "then", "start_line": 14, "end_line": 14, - "coverage": 0 + "coverage": 0, + "inline": false, + "report_line": 13 }, { "type": "else", "start_line": 16, "end_line": 16, - "coverage": 1 + "coverage": 1, + "inline": false, + "report_line": 15 } ], - "branches_covered_percent": 50.0 + "branches_covered_percent": 50.0, + "covered_branches": 1, + "missed_branches": 1, + "total_branches": 2 } }, "groups": {}, diff --git a/spec/fixtures/json/sample_with_method.json b/spec/fixtures/json/sample_with_method.json index 743d4055..65cad936 100644 --- a/spec/fixtures/json/sample_with_method.json +++ b/spec/fixtures/json/sample_with_method.json @@ -1,6 +1,12 @@ { "meta": { - "simplecov_version": "0.22.0" + "simplecov_version": "0.22.0", + "command_name": "STUB_COMMAND_NAME", + "project_name": "STUB_PROJECT_NAME", + "timestamp": "2024-01-01T00:00:00.000+00:00", + "root": "/STUB_WORKING_DIRECTORY", + "branch_coverage": false, + "method_coverage": true }, "total": { "lines": { @@ -20,7 +26,7 @@ } }, "coverage": { - "/STUB_WORKING_DIRECTORY/spec/fixtures/json/sample.rb": { + "spec/fixtures/json/sample.rb": { "lines": [ null, 1, @@ -48,7 +54,37 @@ "ignored", null ], + "source": [ + "# Foo class", + "class Foo", + " def initialize", + " @foo = \"bar\"", + " @bar = \"foo\"", + " end", + "", + " def bar", + " @foo", + " end", + "", + " def foo(param)", + " if param", + " @bar", + " else", + " @foo", + " end", + " end", + "", + " # :nocov:", + " def skipped", + " @foo * 2", + " end", + " # :nocov:", + "end" + ], "lines_covered_percent": 90.0, + "covered_lines": 9, + "missed_lines": 1, + "total_lines": 10, "methods": [ { "name": "Foo#initialize", @@ -75,7 +111,10 @@ "coverage": "ignored" } ], - "methods_covered_percent": 100.0 + "methods_covered_percent": 100.0, + "covered_methods": 3, + "missed_methods": 0, + "total_methods": 4 } }, "groups": {}, diff --git a/spec/json_formatter_spec.rb b/spec/json_formatter_spec.rb index cc31ab7c..70c10c95 100644 --- a/spec/json_formatter_spec.rb +++ b/spec/json_formatter_spec.rb @@ -1,17 +1,31 @@ # frozen_string_literal: true require "helper" +require "fileutils" describe SimpleCov::Formatter::JSONFormatter do subject { described_class.new(silent: true) } + let(:fixed_time) { Time.new(2024, 1, 1, 0, 0, 0, "+00:00") } + + # Prevent stale coverage.json from prior tests from triggering the + # concurrent-overwrite warning. + before { FileUtils.rm_f("tmp/coverage/coverage.json") } + + # Outside SimpleCov.start, process_start_time is nil. Anchor it so the + # concurrent-overwrite checks have a reference point. + before { SimpleCov.process_start_time = Time.now } + after { SimpleCov.process_start_time = nil } + let(:result) do - SimpleCov::Result.new({ - source_fixture("json/sample.rb") => {"lines" => [ - nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, - 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil - ]} - }) + res = SimpleCov::Result.new({ + source_fixture("json/sample.rb") => {"lines" => [ + nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, + 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil + ]} + }) + res.created_at = fixed_time + res end describe "format" do @@ -30,7 +44,7 @@ "percent" => 66.66666666666667, "strength" => 0.6666666666666666 ) - expect(json_output.fetch("coverage").fetch(source_fixture("json/sample.rb"))).to include( + expect(json_output.fetch("coverage").fetch(project_fixture_filename("json/sample.rb"))).to include( "lines_covered_percent" => 66.66666666666667 ) end @@ -53,12 +67,14 @@ end let(:result) do - SimpleCov::Result.new({ - source_fixture("json/sample.rb") => { - "lines" => original_lines, - "branches" => original_branches - } - }) + res = SimpleCov::Result.new({ + source_fixture("json/sample.rb") => { + "lines" => original_lines, + "branches" => original_branches + } + }) + res.created_at = fixed_time + res end before do @@ -88,12 +104,14 @@ end let(:result) do - SimpleCov::Result.new({ - source_fixture("json/sample.rb") => { - "lines" => original_lines, - "methods" => original_methods - } - }) + res = SimpleCov::Result.new({ + source_fixture("json/sample.rb") => { + "lines" => original_lines, + "methods" => original_methods + } + }) + res.created_at = fixed_time + res end before do @@ -142,7 +160,7 @@ errors = json_output.fetch("errors") expect(errors).to eq( "minimum_coverage_by_file" => { - "lines" => {source_fixture("json/sample.rb") => {"expected" => 95, "actual" => 90.0}} + "lines" => {project_fixture_filename("json/sample.rb") => {"expected" => 95, "actual" => 90.0}} } ) end @@ -174,7 +192,7 @@ errors = json_output.fetch("errors") expect(errors).to eq( "minimum_coverage_by_file" => { - "branches" => {source_fixture("json/sample.rb") => {"expected" => 75, "actual" => 50.0}} + "branches" => {project_fixture_filename("json/sample.rb") => {"expected" => 75, "actual" => 50.0}} } ) end @@ -192,19 +210,21 @@ end context "with minimum_coverage_by_group below threshold" do + let(:sample_filename) { source_fixture("json/sample.rb") } let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 7, missed: 3) } let(:result) do res = SimpleCov::Result.new({ - source_fixture("json/sample.rb") => {"lines" => [ + sample_filename => {"lines" => [ nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil ]} }) - allow(res).to receive_messages( - groups: {"Models" => double("File List", coverage_statistics: {line: line_stats})} - ) + mock_file_list = double("File List", + coverage_statistics: {line: line_stats}, + map: [sample_filename]) + allow(res).to receive_messages(groups: {"Models" => mock_file_list}) res end @@ -265,20 +285,27 @@ end context "with groups" do + let(:sample_filename) { source_fixture("json/sample.rb") } + let(:line_stats) { SimpleCov::CoverageStatistics.new(covered: 8, missed: 2) } let(:result) do res = SimpleCov::Result.new({ - source_fixture("json/sample.rb") => {"lines" => [ + sample_filename => {"lines" => [ nil, 1, 1, 1, 1, nil, nil, 1, 1, nil, nil, 1, 1, 0, nil, 1, nil, nil, nil, nil, 1, 0, nil, nil, nil ]} }) + res.created_at = fixed_time # right now SimpleCov works mostly on global state, hence setting the groups that way - # would be global state --> Mocking is better here + # would be global state --> Mocking is better here. `map` ignores the block + # and returns the stubbed value — so stub it to the project-relative path directly. + mock_file_list = double("File List", + coverage_statistics: {line: line_stats}, + map: [project_fixture_filename("json/sample.rb")]) allow(res).to receive_messages( - groups: {"My Group" => double("File List", coverage_statistics: {line: line_stats})} + groups: {"My Group" => mock_file_list} ) res end @@ -288,6 +315,57 @@ expect(json_output).to eq(json_result("sample_groups")) end end + + context "when an existing coverage.json was written after this process started" do + let(:coverage_path) { "tmp/coverage/coverage.json" } + let(:future_timestamp) { (Time.now + 3600).iso8601 } + + before do + FileUtils.mkdir_p("tmp/coverage") + File.write(coverage_path, JSON.generate(meta: {timestamp: future_timestamp})) + end + + it "warns that a concurrent process may have written it" do + stderr = capture_stderr { subject.format(result) } + + expect(stderr).to include("simplecov:") + expect(stderr).to include(future_timestamp) + expect(stderr).to include("concurrent test run") + end + + it "still writes the new file" do + capture_stderr { subject.format(result) } + + expect(json_output.fetch("meta").fetch("timestamp")).to eq(fixed_time.iso8601(3)) + end + end + + context "when an existing coverage.json predates this process" do + before do + FileUtils.mkdir_p("tmp/coverage") + past_timestamp = (Time.now - 3600).iso8601 + File.write("tmp/coverage/coverage.json", JSON.generate(meta: {timestamp: past_timestamp})) + end + + it "does not warn" do + stderr = capture_stderr { subject.format(result) } + + expect(stderr).to be_empty + end + end + + context "when the existing coverage.json is malformed" do + before do + FileUtils.mkdir_p("tmp/coverage") + File.write("tmp/coverage/coverage.json", "not-json") + end + + it "does not warn or raise" do + stderr = capture_stderr { subject.format(result) } + + expect(stderr).to be_empty + end + end end def enable_branch_coverage @@ -304,13 +382,24 @@ def json_output def json_result(filename) file = File.read(source_fixture("json/#{filename}.json")) - file = use_current_working_directory(file) + file = replace_stubs(file) JSON.parse(file) end + def project_fixture_filename(path) + SimpleCov::SourceFile.new(source_fixture(path), []).project_filename + end + STUB_WORKING_DIRECTORY = "STUB_WORKING_DIRECTORY" - def use_current_working_directory(file) + STUB_COMMAND_NAME = "STUB_COMMAND_NAME" + STUB_PROJECT_NAME = "STUB_PROJECT_NAME" + + def replace_stubs(file) current_working_directory = File.expand_path("..", File.dirname(__FILE__)) - file.gsub("/#{STUB_WORKING_DIRECTORY}/", "#{current_working_directory}/") + file + .gsub("/#{STUB_WORKING_DIRECTORY}/", "#{current_working_directory}/") + .gsub("\"/#{STUB_WORKING_DIRECTORY}\"", "\"#{current_working_directory}\"") + .gsub("\"#{STUB_COMMAND_NAME}\"", "\"#{SimpleCov.command_name}\"") + .gsub("\"#{STUB_PROJECT_NAME}\"", "\"#{SimpleCov.project_name}\"") end end diff --git a/spec/source_file_spec.rb b/spec/source_file_spec.rb index 30aefcfc..38ca5d2d 100644 --- a/spec/source_file_spec.rb +++ b/spec/source_file_spec.rb @@ -26,7 +26,7 @@ end it "has a project filename which removes the project directory" do - expect(subject.project_filename).to eq("/spec/fixtures/sample.rb") + expect(subject.project_filename).to eq("spec/fixtures/sample.rb") end it "has source_lines equal to lines" do diff --git a/test/html_formatter/test_formatter.rb b/test/html_formatter/test_formatter.rb index ba3f7023..3d4babf3 100644 --- a/test/html_formatter/test_formatter.rb +++ b/test/html_formatter/test_formatter.rb @@ -4,143 +4,91 @@ require "helper" require "coverage_fixtures" require "tmpdir" +require "json" class TestFormatter < Minitest::Test cover "SimpleCov::Formatter::HTMLFormatter#initialize" if respond_to?(:cover) - def test_initialize_sets_branch_coverage_from_simplecov + def test_initialize_silent_default_false f = SimpleCov::Formatter::HTMLFormatter.new - assert_equal SimpleCov.branch_coverage?, f.instance_variable_get(:@branch_coverage) + refute f.instance_variable_get(:@silent) end - def test_initialize_branch_coverage_false_when_disabled - with_coverage_criteria_cleared do - f = SimpleCov::Formatter::HTMLFormatter.new + def test_initialize_silent_true + f = SimpleCov::Formatter::HTMLFormatter.new(silent: true) - refute f.instance_variable_get(:@branch_coverage) - end + assert f.instance_variable_get(:@silent) end - def test_initialize_branch_coverage_true_when_enabled - skip "Branch coverage not supported on JRuby" if RUBY_ENGINE == "jruby" + cover "SimpleCov::Formatter::HTMLFormatter#format" if respond_to?(:cover) - with_coverage_criteria_cleared do - SimpleCov.enable_coverage(:branch) - f = SimpleCov::Formatter::HTMLFormatter.new + def test_format_writes_coverage_data_js + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert f.instance_variable_get(:@branch_coverage) + assert_path_exists File.join(dir, "coverage_data.js") end end - def test_initialize_sets_method_coverage_based_on_simplecov - f = SimpleCov::Formatter::HTMLFormatter.new + def test_format_coverage_data_js_contains_valid_json + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert_equal SimpleCov.method_coverage?, f.instance_variable_get(:@method_coverage) - end + content = File.read(File.join(dir, "coverage_data.js")) - def test_initialize_sets_method_coverage_false_when_disabled - with_coverage_criteria_cleared do - f = SimpleCov::Formatter::HTMLFormatter.new + assert content.start_with?("window.SIMPLECOV_DATA = ") + assert content.end_with?(";\n") - refute f.instance_variable_get(:@method_coverage) - end - end - - def test_initialize_sets_method_coverage_true_when_enabled - skip "Method coverage not supported" unless SimpleCov.respond_to?(:method_coverage_supported?) && SimpleCov.method_coverage_supported? - with_coverage_criteria_cleared do - SimpleCov.enable_coverage(:method) - f = SimpleCov::Formatter::HTMLFormatter.new + json_str = content.sub("window.SIMPLECOV_DATA = ", "").chomp(";\n") + data = JSON.parse(json_str) - assert f.instance_variable_get(:@method_coverage) + assert_kind_of Hash, data + assert data.key?("meta") + assert data.key?("coverage") + assert data.key?("total") end end - def test_initialize_method_coverage_reflects_simplecov - f = SimpleCov::Formatter::HTMLFormatter.new - expected = SimpleCov.respond_to?(:method_coverage?) && SimpleCov.method_coverage? - - assert_equal expected, f.instance_variable_get(:@method_coverage) - end - - def test_initialize_creates_empty_templates_hash - f = SimpleCov::Formatter::HTMLFormatter.new - - assert_equal({}, f.instance_variable_get(:@templates)) - end - - def test_initialize_inline_assets_default_false - ENV.delete("SIMPLECOV_INLINE_ASSETS") - f = SimpleCov::Formatter::HTMLFormatter.new - - refute f.instance_variable_get(:@inline_assets) - end - - def test_initialize_inline_assets_from_kwarg - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: true) - - assert f.instance_variable_get(:@inline_assets) - end - - def test_initialize_inline_assets_from_env - ENV["SIMPLECOV_INLINE_ASSETS"] = "1" - f = SimpleCov::Formatter::HTMLFormatter.new - - assert f.instance_variable_get(:@inline_assets) - ensure - ENV.delete("SIMPLECOV_INLINE_ASSETS") - end - - def test_initialize_silent_default_false - f = SimpleCov::Formatter::HTMLFormatter.new - - refute f.instance_variable_get(:@silent) - end - - def test_initialize_silent_true - f = SimpleCov::Formatter::HTMLFormatter.new(silent: true) + def test_format_copies_index_html + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert f.instance_variable_get(:@silent) + assert_path_exists File.join(dir, "index.html") + end end - def test_initialize_public_assets_dir_points_to_public - f = SimpleCov::Formatter::HTMLFormatter.new - dir = f.instance_variable_get(:@public_assets_dir) + def test_format_copies_application_js + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert dir.end_with?("/public/"), "Expected public/ dir, got: #{dir}" - assert File.directory?(dir), "Expected #{dir} to exist" + assert_path_exists File.join(dir, "application.js") + end end - cover "SimpleCov::Formatter::HTMLFormatter#format" if respond_to?(:cover) - - def test_format_writes_index_html + def test_format_copies_application_css with_coverage_dir do |dir| - f = silent_formatter - f.format(make_result) - html = File.read(File.join(dir, "index.html")) + silent_formatter.format(make_result) - assert_includes html, "" + assert_path_exists File.join(dir, "application.css") end end - def test_format_copies_assets_when_not_inline + def test_format_copies_favicons with_coverage_dir do |dir| silent_formatter.format(make_result) - asset_dir = versioned_asset_dir(dir) - assert File.directory?(asset_dir), "Expected assets directory at #{asset_dir}" - assert_path_exists File.join(asset_dir, "application.js") - assert_path_exists File.join(asset_dir, "application.css") + assert_path_exists File.join(dir, "favicon_green.png") + assert_path_exists File.join(dir, "favicon_red.png") + assert_path_exists File.join(dir, "favicon_yellow.png") end end - def test_format_does_not_copy_assets_when_inline + def test_format_also_writes_coverage_json with_coverage_dir do |dir| - f = SimpleCov::Formatter::HTMLFormatter.new(silent: true, inline_assets: true) - f.format(make_result) + silent_formatter.format(make_result) - refute File.directory?(File.join(dir, "assets")), "Expected no assets directory when inline" + assert_path_exists File.join(dir, "coverage.json") end end @@ -161,531 +109,117 @@ def test_format_does_not_print_when_silent end end - def test_format_writes_in_binary_mode - with_coverage_dir do |_dir| - write_calls = spy_on_file_write do - silent_formatter.format(make_result) - end - - assert_equal [{mode: "wb"}], write_calls - end - end + def test_format_writes_coverage_data_in_binary_mode + with_coverage_dir do |dir| + silent_formatter.format(make_result) - def test_format_copies_assets_with_remove_destination - with_coverage_dir do |_dir| - cp_r_calls = spy_on_cp_r do - silent_formatter.format(make_result) - end + content = File.read(File.join(dir, "coverage_data.js")) - refute_empty cp_r_calls, "Expected at least one cp_r call" - cp_r_calls.each do |opts| - assert opts[:remove_destination], "Expected remove_destination: true, got #{opts.inspect}" - end + assert content.start_with?("window.SIMPLECOV_DATA = ") end end - cover "SimpleCov::Formatter::HTMLFormatter#output_message" if respond_to?(:cover) - - def test_output_message_includes_command_name - msg = output_message_for("RSpec", line: stat(80, 100), branch: stat(10, 20)) - - assert_includes msg, "RSpec" - end - - def test_output_message_includes_output_path - msg = output_message_for("Test", line: stat(80, 100), branch: stat(10, 20)) - - assert_includes msg, SimpleCov.coverage_path - end - - def test_output_message_includes_line_coverage - msg = output_message_for("Test", line: stat(80, 100), branch: stat(10, 20)) - - assert_includes msg, "Line coverage:" - assert_includes msg, "80 / 100" - end - - def test_output_message_includes_branch_coverage_when_enabled - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@branch_coverage, true) - stats = {line: stat(80, 100), branch: stat(15, 20)} - stats[:method] = stat(0, 0) if f.instance_variable_get(:@method_coverage) - msg = f.send(:output_message, stub_result("Test", stats)) - - assert_includes msg, "Branch coverage:" - assert_includes msg, "15 / 20" - end - - def test_output_message_excludes_branch_coverage_when_disabled - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@branch_coverage, false) - stats = {line: stat(80, 100)} - stats[:method] = stat(0, 0) if f.instance_variable_get(:@method_coverage) - msg = f.send(:output_message, stub_result("Test", stats)) - - refute_includes msg, "Branch coverage:" - end - - def test_output_message_includes_method_coverage_when_enabled - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@method_coverage, true) - f.instance_variable_set(:@branch_coverage, false) - msg = f.send(:output_message, stub_result("Test", line: stat(80, 100), method: stat(5, 10))) - - assert_includes msg, "Method coverage:" - assert_includes msg, "5 / 10" - end - - def test_output_message_excludes_method_coverage_when_disabled - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@method_coverage, false) - f.instance_variable_set(:@branch_coverage, false) - msg = f.send(:output_message, stub_result("Test", line: stat(80, 100))) - - refute_includes msg, "Method coverage:" - end - - def test_output_message_starts_with_coverage_report_generated - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@branch_coverage, false) - f.instance_variable_set(:@method_coverage, false) - msg = f.send(:output_message, stub_result("MyTest", line: stat(50, 50))) - - assert msg.start_with?("Coverage report generated for MyTest to ") - end - - def test_output_message_lines_are_joined_with_newlines - f = SimpleCov::Formatter::HTMLFormatter.new - f.instance_variable_set(:@branch_coverage, true) - f.instance_variable_set(:@method_coverage, true) - result = stub_result("Test", line: stat(80, 100), branch: stat(10, 20), method: stat(5, 10)) - lines = f.send(:output_message, result).split("\n") - - assert_equal 4, lines.length - end - - cover "SimpleCov::Formatter::HTMLFormatter#template" if respond_to?(:cover) - - def test_template_returns_erb_object - f = SimpleCov::Formatter::HTMLFormatter.new - - assert_instance_of ERB, f.send(:template, "layout") - end - - def test_template_caches_result - f = SimpleCov::Formatter::HTMLFormatter.new - tmpl1 = f.send(:template, "layout") - tmpl2 = f.send(:template, "layout") - - assert_same tmpl1, tmpl2 - end - - def test_template_loads_different_templates - f = SimpleCov::Formatter::HTMLFormatter.new - layout = f.send(:template, "layout") - file_list = f.send(:template, "file_list") - - refute_same layout, file_list - end - - def test_template_stores_in_templates_hash - f = SimpleCov::Formatter::HTMLFormatter.new - f.send(:template, "layout") - templates = f.instance_variable_get(:@templates) - - assert templates.key?("layout") - assert_instance_of ERB, templates["layout"] - end - - def test_template_reads_from_views_directory - f = SimpleCov::Formatter::HTMLFormatter.new - tmpl = f.send(:template, "covered_percent") - - refute_nil tmpl - assert_instance_of ERB, tmpl - end - - cover "SimpleCov::Formatter::HTMLFormatter#output_path" if respond_to?(:cover) - - def test_output_path_delegates_to_simplecov_coverage_path - f = SimpleCov::Formatter::HTMLFormatter.new - - assert_equal SimpleCov.coverage_path, f.send(:output_path) - end - - def test_output_path_returns_string - f = SimpleCov::Formatter::HTMLFormatter.new - - assert_kind_of String, f.send(:output_path) - end - - cover "SimpleCov::Formatter::HTMLFormatter#asset_output_path" if respond_to?(:cover) + def test_format_coverage_data_includes_source_code + with_coverage_dir do |dir| + silent_formatter.format(make_result) - def test_asset_output_path_creates_directory - with_coverage_dir do |_dir| - f = SimpleCov::Formatter::HTMLFormatter.new + data = parse_coverage_data(dir) + file_data = data["coverage"].values.first - assert File.directory?(f.send(:asset_output_path)), "Expected directory to exist" + assert file_data.key?("source"), "Expected source code in coverage data" + assert_kind_of Array, file_data["source"] + refute_empty file_data["source"] end end - def test_asset_output_path_includes_version - with_coverage_dir do - path = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_output_path) - - assert_includes path, SimpleCov::Formatter::HTMLFormatter::VERSION - end - end + def test_format_coverage_data_includes_meta + with_coverage_dir do |dir| + silent_formatter.format(make_result) - def test_asset_output_path_includes_assets_subdir - with_coverage_dir do - path = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_output_path) + data = parse_coverage_data(dir) + meta = data["meta"] - assert_includes path, "/assets/" + assert meta.key?("simplecov_version") + assert meta.key?("command_name") + assert meta.key?("project_name") + assert meta.key?("timestamp") + assert meta.key?("root") + assert [true, false].include?(meta["branch_coverage"]) + assert [true, false].include?(meta["method_coverage"]) end end - def test_asset_output_path_is_cached - with_coverage_dir do - f = SimpleCov::Formatter::HTMLFormatter.new - path1 = f.send(:asset_output_path) - path2 = f.send(:asset_output_path) - - assert_same path1, path2 - end - end + cover "SimpleCov::Formatter::HTMLFormatter#format_from_json" if respond_to?(:cover) - def test_asset_output_path_is_under_output_path + def test_format_from_json_writes_coverage_data_js with_coverage_dir do |dir| - path = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_output_path) - - assert path.start_with?(dir), "Expected #{path} to start with #{dir}" - end - end + # First generate coverage.json + silent_formatter.format(make_result) - def test_asset_output_path_joins_output_path_assets_version - with_coverage_dir do |dir| - expected = File.join(dir, "assets", SimpleCov::Formatter::HTMLFormatter::VERSION) + # Now use format_from_json to generate in a different directory + output_dir = File.join(dir, "standalone") + json_path = File.join(dir, "coverage.json") + SimpleCov::Formatter::HTMLFormatter.new.format_from_json(json_path, output_dir) - assert_equal expected, SimpleCov::Formatter::HTMLFormatter.new.send(:asset_output_path) + assert_path_exists File.join(output_dir, "coverage_data.js") + assert_path_exists File.join(output_dir, "index.html") + assert_path_exists File.join(output_dir, "application.js") end end - cover "SimpleCov::Formatter::HTMLFormatter#assets_path" if respond_to?(:cover) - - def test_assets_path_returns_relative_path_when_not_inline - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: false) - ENV.delete("SIMPLECOV_INLINE_ASSETS") - expected = File.join("./assets", SimpleCov::Formatter::HTMLFormatter::VERSION, "application.js") - - assert_equal expected, f.send(:assets_path, "application.js") - end - - def test_assets_path_includes_version - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: false) - ENV.delete("SIMPLECOV_INLINE_ASSETS") - - assert_includes f.send(:assets_path, "application.css"), SimpleCov::Formatter::HTMLFormatter::VERSION - end - - def test_assets_path_returns_data_uri_when_inline - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: true) - result = f.send(:assets_path, "application.js") - - assert result.start_with?("data:"), "Expected data URI, got: #{result[0..30]}" - end - - def test_assets_path_starts_with_dot_slash_assets_when_not_inline - f = SimpleCov::Formatter::HTMLFormatter.new(inline_assets: false) - ENV.delete("SIMPLECOV_INLINE_ASSETS") - result = f.send(:assets_path, "application.css") - - assert result.start_with?("./assets/"), "Expected ./assets/ prefix, got: #{result}" - end - - cover "SimpleCov::Formatter::HTMLFormatter#asset_inline" if respond_to?(:cover) - - def test_asset_inline_returns_data_uri_for_js - result = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_inline, "application.js") - - assert result.start_with?("data:text/javascript;base64,") - end - - def test_asset_inline_returns_data_uri_for_css - result = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_inline, "application.css") - - assert result.start_with?("data:text/css;base64,") - end - - def test_asset_inline_returns_data_uri_for_png - result = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_inline, "favicon_green.png") - - assert result.start_with?("data:image/png;base64,") - end - - def test_asset_inline_encodes_content_as_base64 - f = SimpleCov::Formatter::HTMLFormatter.new - result = f.send(:asset_inline, "application.js") - decoded = result.sub("data:text/javascript;base64,", "").unpack1("m0") - public_dir = f.instance_variable_get(:@public_assets_dir) - - assert_equal File.read(File.join(public_dir, "application.js")), decoded - end - - def test_asset_inline_uses_m0_pack_no_newlines - result = SimpleCov::Formatter::HTMLFormatter.new.send(:asset_inline, "application.js") - base64_part = result.sub("data:text/javascript;base64,", "") - - refute_includes base64_part, "\n" - end - - def test_asset_inline_uses_correct_content_type_from_extension - f = SimpleCov::Formatter::HTMLFormatter.new - js_result = f.send(:asset_inline, "application.js") - css_result = f.send(:asset_inline, "application.css") - - assert_includes js_result, "text/javascript" - assert_includes css_result, "text/css" - refute_includes js_result, "text/css" - refute_includes css_result, "text/javascript" - end - - def test_asset_inline_reads_from_public_assets_dir - f = SimpleCov::Formatter::HTMLFormatter.new - public_dir = f.instance_variable_get(:@public_assets_dir) - expected_base64 = [File.read(File.join(public_dir, "application.css"))].pack("m0") - - assert_equal "data:text/css;base64,#{expected_base64}", f.send(:asset_inline, "application.css") - end - - cover "SimpleCov::Formatter::HTMLFormatter#formatted_source_file" if respond_to?(:cover) - - def test_formatted_source_file_returns_html - with_coverage_dir do - f = SimpleCov::Formatter::HTMLFormatter.new - result = f.send(:formatted_source_file, sample_source_file) + def test_format_from_json_produces_valid_data + with_coverage_dir do |dir| + silent_formatter.format(make_result) - assert_includes result, "source_table" - assert_includes result, "Foo" - end - end + output_dir = File.join(dir, "standalone") + json_path = File.join(dir, "coverage.json") + SimpleCov::Formatter::HTMLFormatter.new.format_from_json(json_path, output_dir) - def test_formatted_source_file_uses_source_file_template - with_coverage_dir do - f = SimpleCov::Formatter::HTMLFormatter.new - result = f.send(:formatted_source_file, sample_source_file) + data = parse_coverage_data(output_dir) - assert_includes result, shortened_name("sample.rb") + assert data.key?("meta") + assert data.key?("coverage") end end - def test_formatted_source_file_handles_encoding_error - f = formatter_with_bad_template("bad encoding") - stdout, = capture_io { f.send(:formatted_source_file, sample_source_file) } - - assert_includes stdout, "Encoding problems with file" - assert_includes stdout, sample_source_file.filename - end - - def test_formatted_source_file_encoding_error_prints_error_message - error = Encoding::CompatibilityError.new("incompatible character") - error.define_singleton_method(:message) { "the_real_message" } - f = formatter_with_bad_template_error(error) - stdout, = capture_io { f.send(:formatted_source_file, sample_source_file) } - - assert_includes stdout, "the_real_message" - refute_includes stdout, "incompatible character" - end - - def test_formatted_source_file_encoding_error_returns_placeholder - f = formatter_with_bad_template("bad") - capture_io { @encoding_result = f.send(:formatted_source_file, sample_source_file) } - - assert_includes @encoding_result, "source_table" - assert_includes @encoding_result, "Encoding Error" - end - - def test_formatted_source_file_encoding_error_contains_correct_id - f = formatter_with_bad_template("bad") - result = nil - capture_io { result = f.send(:formatted_source_file, sample_source_file) } - expected_id = Digest::MD5.hexdigest(sample_source_file.filename) - - assert_includes result, %(id="#{expected_id}") - end - - def test_formatted_source_file_encoding_error_html_escapes_message - error = Encoding::CompatibilityError.new("dummy") - error.define_singleton_method(:message) { "" } - f = formatter_with_bad_template_error(error) - result = nil - capture_io { result = f.send(:formatted_source_file, sample_source_file) } - - assert_includes result, ERB::Util.html_escape("") - refute_includes result, "