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 +4 -4
- data/lib/fp/client.rb +5 -1
- data/lib/fp/commands/ci_report.rb +34 -3
- data/lib/fp/junit.rb +100 -8
- data/lib/fp/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7fc65a33008ccbd43f9b8ec10f7e3c013ab3ed502980f97b64c55e14ff1d2eb5
|
|
4
|
+
data.tar.gz: a841a639236c27ea6bf7c30ba1a7f4a5710176bc0fd4bb153daac009d89e90db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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 |
|
|
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