featureparity 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4b98c13a1f3911211fc2d07f23ad5917f6d6326563abda2b31bdb52840750f26
4
- data.tar.gz: ec5cb53697d89763e41735deea90ba9342905b5622d5d3c43187782bef3205b5
3
+ metadata.gz: 7fc65a33008ccbd43f9b8ec10f7e3c013ab3ed502980f97b64c55e14ff1d2eb5
4
+ data.tar.gz: a841a639236c27ea6bf7c30ba1a7f4a5710176bc0fd4bb153daac009d89e90db
5
5
  SHA512:
6
- metadata.gz: 404d64805e0a04b939f920ca3ecd5fbc6765ad4f0e11e62f396ad3794eea8b4c500168a474f06f6df914d088a517a735b0cc801b3bfb73b2733722ac0e6eae0e
7
- data.tar.gz: a80c7661ab55355e9d9ab13ab2430ea2b83d52c41f3185793cf4a1b39671507f5d4f3227d1540d5be305194ad42ea99c0292e651283562b7ac71487d0baf5768
6
+ metadata.gz: df0c381690d9fa55e189e3a057eddd35c979bb970fac56b9294ad426d758613374c483c5f4317829cedd5a85d3ff3443a543e8dbcfdff99a146a82c7de831c53
7
+ data.tar.gz: f2d8dd3a182c74e6e7ef5e4395803eaf86a62b510532227c2b7ec4c3bf4a3fe06db62c8866280d016c7cb4d1cb6a786b2b5c2dcb731f5f0ada518de288eb3714
data/lib/fp/client.rb CHANGED
@@ -151,7 +151,8 @@ module Fp
151
151
  # server can fall back to resolving markers itself for older clients — but it can
152
152
  # only do that by fetching the test files from GitHub, which fails silently on a
153
153
  # private repo, so resolved results are always sent when we have them.
154
- def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil, results: nil)
154
+ def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil, results: nil,
155
+ unmarked_failures: nil)
155
156
  body = {
156
157
  ci_url: ci_url,
157
158
  surface: surface,
@@ -160,6 +161,9 @@ module Fp
160
161
  # Only send pr_url when set, so a merged-branch run stays a plain ingest.
161
162
  body[:pr_url] = pr_url if pr_url && !pr_url.empty?
162
163
  body[:results] = results if results && !results.empty?
164
+ # Loose ends: failing tests with no fp:<slug> marker. The server stores them so the
165
+ # matrix can surface a red suite whose failures aren't bound to any requirement.
166
+ body[:unmarked_failures] = unmarked_failures if unmarked_failures && !unmarked_failures.empty?
163
167
 
164
168
  post("/api/projects/#{project_id}/ci_results", body)
165
169
  end
@@ -82,7 +82,7 @@ module Fp
82
82
  known = known_slugs(project_id)
83
83
 
84
84
  totals = { created: 0, updated: 0, missing_failing: 0, reports: [], resolved: 0,
85
- unmatched: 0, unknown: [] }
85
+ unmatched: 0, unknown: [], unmarked_failures: [] }
86
86
 
87
87
  junit_paths.each do |path|
88
88
  xml = File.read(path)
@@ -92,6 +92,7 @@ module Fp
92
92
  results, unknown = partition_known(resolved[:results], known)
93
93
  totals[:resolved] += results.length
94
94
  totals[:unknown].concat(unknown)
95
+ totals[:unmarked_failures].concat(resolved[:unmarked_failures])
95
96
 
96
97
  result = client.ingest_ci_results(
97
98
  project_id,
@@ -99,7 +100,8 @@ module Fp
99
100
  surface: opts[:surface],
100
101
  junit_xml: xml,
101
102
  pr_url: opts[:pr],
102
- results: results
103
+ results: results,
104
+ unmarked_failures: resolved[:unmarked_failures]
103
105
  )
104
106
 
105
107
  unless result[:ok]
@@ -154,6 +156,19 @@ module Fp
154
156
  "#{orphans.join(', ')}. Propose the requirement (fp propose) or fix the slug.")
155
157
  end
156
158
 
159
+ # Loose ends: tests that failed but carry no fp:<slug> marker. Not a gate failure
160
+ # (they bind to no requirement, so nothing can go red), but a red test nobody is
161
+ # tracking is worth a human's eyes — surfaced the same way orphan markers are, and
162
+ # persisted so it shows on the matrix's "loose ends" panel.
163
+ unless totals[:unmarked_failures].empty?
164
+ count = totals[:unmarked_failures].length
165
+ names = totals[:unmarked_failures].map { |f| f[:name] }.compact.take(10).join(', ')
166
+ annotate("#{count} failing test(s) on surface '#{opts[:surface]}' carry no fp:<slug> " \
167
+ "marker, so no requirement tracks them: #{names}#{'…' if count > 10}. " \
168
+ 'Mark them (fp:<slug>) to bind them to a requirement, or leave them — ' \
169
+ 'they show on the matrix as loose ends either way.')
170
+ end
171
+
157
172
  emit_summary(opts, totals)
158
173
  end
159
174
 
@@ -214,7 +229,18 @@ module Fp
214
229
  failure_count: r.failure_count
215
230
  }
216
231
  end,
217
- unmatched: resolved[:unmatched].length
232
+ unmatched: resolved[:unmatched].length,
233
+ # Loose ends: failing tests with no fp:<slug> marker. Shipped to the server so a
234
+ # human can see them on the matrix; a red test nobody tracks against a
235
+ # requirement is a real gap, even though it isn't a requirement cell.
236
+ unmarked_failures: Fp::JUnit.unmarked_failures(resolved[:unmatched]).map do |f|
237
+ {
238
+ name: f.name,
239
+ classname: f.classname,
240
+ source_file: f.file,
241
+ source_line: f.line
242
+ }
243
+ end
218
244
  }
219
245
  rescue ArgumentError => e
220
246
  output.error(e.message)
@@ -234,6 +260,7 @@ module Fp
234
260
  missing_marked_failing: totals[:missing_failing],
235
261
  markers_resolved: totals[:resolved],
236
262
  unmatched_testcases: totals[:unmatched],
263
+ unmarked_failures: totals[:unmarked_failures],
237
264
  skipped_unknown: totals[:unknown].uniq,
238
265
  reports: totals[:reports]
239
266
  }
@@ -253,6 +280,10 @@ module Fp
253
280
  puts " skipped #{totals[:unknown].uniq.length} marker(s) with no matching " \
254
281
  "requirement: #{totals[:unknown].uniq.join(', ')}"
255
282
  end
283
+ unless totals[:unmarked_failures].empty?
284
+ puts " loose ends: #{totals[:unmarked_failures].length} failing test(s) with no " \
285
+ 'fp:<slug> marker (shown on the matrix, not a gate failure)'
286
+ end
256
287
  puts " created: #{totals[:created]} updated: #{totals[:updated]} marked failing (missing): #{totals[:missing_failing]}"
257
288
  end
258
289
  end
data/lib/fp/junit.rb CHANGED
@@ -35,11 +35,21 @@ module Fp
35
35
  CiResult = Struct.new(:slug, :surface, :status, :file, :line, :test_count, :failure_count,
36
36
  keyword_init: true)
37
37
 
38
+ # A failing testcase that carries no fp:<slug> marker at all — a "loose end". Not a
39
+ # cell on the parity grid (nothing binds it to a requirement) and not an orphan
40
+ # marker (there is no marker); it's a red test nobody is tracking against a
41
+ # requirement. CI ships these to the server so a human can see, on the matrix, that
42
+ # the suite has failures no requirement claims. Green unmarked tests are ignored on
43
+ # purpose — not everything needs a requirement; only a *failing* one is a signal.
44
+ UnmarkedFailure = Struct.new(:name, :classname, :file, :line, keyword_init: true)
45
+
38
46
  # A testcase paired with the marker that annotates it. `precise` records whether we
39
47
  # know *which* test the marker sits above (matched by line or by test name) or only
40
48
  # that the marker is somewhere in the same file — a title is only worth reporting in
41
- # the first case.
42
- Match = Struct.new(:testcase, :marker, :precise, keyword_init: true)
49
+ # the first case. `repo_file` is the testcase's source path made relative to the git
50
+ # repo root (see repo_relative_file), so evidence links resolve regardless of the
51
+ # base_dir the runner reported paths against.
52
+ Match = Struct.new(:testcase, :marker, :precise, :repo_file, keyword_init: true)
43
53
 
44
54
  # Evidence discovered by binding a testcase to a marker.
45
55
  # `line` is the 1-based line number of the test in its source file: the
@@ -183,7 +193,7 @@ module Fp
183
193
  if surface.nil? || surface.empty?
184
194
  missing_surface << { slug: marker[:slug], file: tc.file }
185
195
  else
186
- record_binding(bindings, marker[:slug], surface, tc, marker, titles_reliable: match.precise)
196
+ record_binding(bindings, marker[:slug], surface, tc, marker, match.repo_file, titles_reliable: match.precise)
187
197
  end
188
198
  end
189
199
  end
@@ -224,7 +234,7 @@ module Fp
224
234
  end
225
235
 
226
236
  key = [marker[:slug], surface]
227
- agg = aggregates[key] ||= { file: tc.file, line: tc.line || marker[:line], ran: 0, failed: 0, skipped: 0 }
237
+ agg = aggregates[key] ||= { file: match.repo_file || tc.file, line: tc.line || marker[:line], ran: 0, failed: 0, skipped: 0 }
228
238
  if tc.stub?
229
239
  agg[:skipped] += 1
230
240
  else
@@ -249,22 +259,40 @@ module Fp
249
259
  { results: results, unmatched: unmatched, missing_surface: missing_surface.uniq }
250
260
  end
251
261
 
262
+ # The loose ends in a set of unmatched testcases: the ones that *failed* but carry no
263
+ # fp:<slug> marker. Pass the `unmatched` list from `ci_results` (or `bind_markers`).
264
+ #
265
+ # Only failures are returned. A green unmarked test is fine — not everything needs a
266
+ # requirement — but a red one is a failure nobody is tracking, which is exactly the
267
+ # signal a human should see. Skipped tests are not failures, so they're excluded too.
268
+ def unmarked_failures(unmatched)
269
+ unmatched.select(&:failed?).map do |tc|
270
+ UnmarkedFailure.new(name: tc.name, classname: tc.classname, file: tc.file, line: tc.line)
271
+ end
272
+ end
273
+
252
274
  # Walk testcases and yield a Match for every one we can tie to a marker.
253
275
  # Shared by bind_markers (agent evidence) and ci_results (CI verdicts) so the two
254
276
  # can never disagree about which test a marker annotates.
255
277
  def walk_matches(testcases, base_dir)
256
278
  marker_cache = {}
279
+ prefix_cache = {}
257
280
 
258
281
  # Group testcases by their source file so we can reason about ordering.
259
- testcases.group_by(&:file).each do |_file, cases|
282
+ testcases.group_by(&:file).each do |file, cases|
260
283
  markers = markers_for(cases.first, base_dir, marker_cache)
261
284
  next if markers.empty?
262
285
 
286
+ # The path we'll store as evidence: relative to the git root, not to base_dir,
287
+ # so a GitHub blob link resolves. base_dir's offset below the root is cached, so
288
+ # this is one shell-out per run, not per file.
289
+ repo_file = repo_relative_file(file, base_dir, prefix_cache)
290
+
263
291
  cases.each do |tc|
264
292
  marker, precise = marker_for(tc, cases, markers)
265
293
  next unless marker
266
294
 
267
- yield Match.new(testcase: tc, marker: marker, precise: precise)
295
+ yield Match.new(testcase: tc, marker: marker, precise: precise, repo_file: repo_file)
268
296
  end
269
297
  end
270
298
  end
@@ -343,8 +371,8 @@ module Fp
343
371
  candidate
344
372
  end
345
373
 
346
- def record_binding(bindings, slug, surface, testcase, marker, titles_reliable: true)
347
- rel_file = testcase.file
374
+ def record_binding(bindings, slug, surface, testcase, marker, repo_file, titles_reliable: true)
375
+ rel_file = repo_file || testcase.file
348
376
  key = [slug, surface, rel_file]
349
377
  state = testcase.stub? ? 'stub' : 'present'
350
378
 
@@ -380,6 +408,70 @@ module Fp
380
408
  cache[file] = markers
381
409
  end
382
410
 
411
+ # Rewrite a report-relative test-file path so it is relative to the git repo root.
412
+ #
413
+ # Runners report paths relative to wherever they ran: RSpec/minitest from the repo
414
+ # root, but vitest from its own package (frontend/), which is why the web job passes
415
+ # `--base-dir frontend`. That base_dir is enough to *find* the markers, but the path
416
+ # we then store as `source_file` was the raw report path (`src/pages/Matrix.test.jsx`),
417
+ # not the repo-root path (`frontend/src/pages/Matrix.test.jsx`). The web app builds a
418
+ # GitHub link as `blob/<sha>/<source_file>`, and a blob path is always relative to the
419
+ # git root — so the missing `frontend/` prefix produced a 404 on every web test link.
420
+ #
421
+ # The fix is purely additive: find how far base_dir sits below the git root and
422
+ # prepend that. When base_dir *is* the git root (the Ruby suites, run from the repo
423
+ # root), the prefix is empty and the original path is returned byte-for-byte — so this
424
+ # only ever corrects a subdirectory base_dir and never rewrites a path that was
425
+ # already right. Falls back to the raw path when there's no git checkout (an agent
426
+ # running from a tarball) or base_dir sits outside one.
427
+ def repo_relative_file(file, base_dir, cache = {})
428
+ return file if file.nil? || file.empty?
429
+
430
+ prefix = base_dir_prefix(base_dir, cache)
431
+ return file if prefix.nil? || prefix.empty?
432
+
433
+ File.join(prefix, file)
434
+ end
435
+
436
+ # base_dir's path relative to the git root that contains it: '' when base_dir is the
437
+ # root, 'frontend' when it's a package below it, nil when base_dir isn't in a checkout
438
+ # (or sits above the root). Cached per base_dir so a report shells out once.
439
+ def base_dir_prefix(base_dir, cache)
440
+ absolute = File.expand_path(base_dir)
441
+ return cache[absolute] if cache.key?(absolute)
442
+
443
+ root = git_toplevel_for(absolute)
444
+ prefix =
445
+ if root.nil?
446
+ nil
447
+ else
448
+ rel = relative_path(absolute, root)
449
+ rel == '.' || rel.start_with?('..') ? '' : rel
450
+ end
451
+ cache[absolute] = prefix
452
+ end
453
+
454
+ # The git repo root containing `dir`, or nil if `dir` isn't inside a checkout.
455
+ def git_toplevel_for(dir)
456
+ out = `git -C #{quote(dir)} rev-parse --show-toplevel 2>/dev/null`
457
+ root = out.strip
458
+ ($?&.success? && !root.empty?) ? File.expand_path(root) : nil
459
+ end
460
+
461
+ def relative_path(absolute, root)
462
+ require 'pathname'
463
+ Pathname.new(absolute).relative_path_from(Pathname.new(root)).to_s
464
+ rescue ArgumentError
465
+ # Different roots on Windows, or otherwise not relative — treat as outside.
466
+ '..'
467
+ end
468
+
469
+ # Minimal shell-quote for a path passed to `git -C`. The directory comes from
470
+ # --base-dir, not arbitrary report content, but quoting keeps spaces safe.
471
+ def quote(str)
472
+ "'#{str.gsub("'", "'\\\\''")}'"
473
+ end
474
+
383
475
  # Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
384
476
  # [{ slug:, surfaces: [..], line:, title:, title_kind: }, ...] (1-based line numbers).
385
477
  # `surfaces` is [] when the marker pins none (use the default surface).
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.12'
4
+ VERSION = '0.0.14'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.12
4
+ version: 0.0.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla