featureparity 0.0.9 → 0.0.10
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 +15 -2
- data/lib/fp/commands/check.rb +47 -3
- data/lib/fp/commands/ci_report.rb +142 -19
- data/lib/fp/commands/help.rb +9 -0
- data/lib/fp/commands/init.rb +4 -0
- data/lib/fp/junit.rb +217 -33
- 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: e4c131ba204a5f2e3b872551963af559ee1cc2ba941eb192228cd261a12c6f48
|
|
4
|
+
data.tar.gz: 4919cdca3946beec35921271be6998938150085879a3b6c9ed4d8cd4c84aa622
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8977bbdb59e48dd3d19878858ebc0e5363ec9be1313b8352054f8227e9a84a99c8b3e7343ae74cb034bc96f2f45fc5700656a5218f46db61abcaa5a32f9fa146
|
|
7
|
+
data.tar.gz: e37abf6853447467b7b91937f26447d4e2c7b4de5d52bcc3b17ac6c348ce01fbd996e806e0a10add88f04b8c4823af292302a81b6cb81b7eae5b3cb306846caf
|
data/lib/fp/client.rb
CHANGED
|
@@ -144,7 +144,14 @@ module Fp
|
|
|
144
144
|
# writes passing/failing evidence per surface. It also marks previously
|
|
145
145
|
# passing slugs that have vanished from the results as failing. This is the
|
|
146
146
|
# half of the loop only CI is allowed to close (#1222).
|
|
147
|
-
|
|
147
|
+
# POST /projects/:project_id/ci_results — CI's real pass/fail.
|
|
148
|
+
#
|
|
149
|
+
# `results` are the run's outcomes already bound to their fp:<slug> markers by the
|
|
150
|
+
# CLI, in the checkout CI is holding. The raw `junit_xml` still goes along so the
|
|
151
|
+
# server can fall back to resolving markers itself for older clients — but it can
|
|
152
|
+
# only do that by fetching the test files from GitHub, which fails silently on a
|
|
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)
|
|
148
155
|
body = {
|
|
149
156
|
ci_url: ci_url,
|
|
150
157
|
surface: surface,
|
|
@@ -152,6 +159,7 @@ module Fp
|
|
|
152
159
|
}
|
|
153
160
|
# Only send pr_url when set, so a merged-branch run stays a plain ingest.
|
|
154
161
|
body[:pr_url] = pr_url if pr_url && !pr_url.empty?
|
|
162
|
+
body[:results] = results if results && !results.empty?
|
|
155
163
|
|
|
156
164
|
post("/api/projects/#{project_id}/ci_results", body)
|
|
157
165
|
end
|
|
@@ -180,8 +188,13 @@ module Fp
|
|
|
180
188
|
# what has already been recorded, which on a PR is the default branch's state; this
|
|
181
189
|
# is how a branch gets gated on its own results without writing evidence that would
|
|
182
190
|
# churn the live matrix.
|
|
183
|
-
|
|
191
|
+
#
|
|
192
|
+
# `results` are the run's outcomes already bound to their markers locally (see
|
|
193
|
+
# ingest_ci_results). Sending them is what lets a surface whose repo isn't registered
|
|
194
|
+
# — or a private one the server can't read — be previewed at all.
|
|
195
|
+
def preview_parity(project_id, surface:, junit_xmls:, level: nil, results: nil)
|
|
184
196
|
body = { surface: surface, junit_xmls: Array(junit_xmls) }
|
|
197
|
+
body[:results] = results if results && !results.empty?
|
|
185
198
|
body[:level] = level if level
|
|
186
199
|
post("/api/projects/#{project_id}/parity/preview", body)
|
|
187
200
|
end
|
data/lib/fp/commands/check.rb
CHANGED
|
@@ -41,6 +41,7 @@ module Fp
|
|
|
41
41
|
surface: :string,
|
|
42
42
|
level: :string,
|
|
43
43
|
junit: :string,
|
|
44
|
+
base_dir: :string,
|
|
44
45
|
warn_only: :boolean
|
|
45
46
|
}.freeze
|
|
46
47
|
|
|
@@ -62,7 +63,7 @@ module Fp
|
|
|
62
63
|
if junit_paths.empty?
|
|
63
64
|
client.get_parity(project_id, level: level, surface: surface)
|
|
64
65
|
else
|
|
65
|
-
preview(project_id, junit_paths, surface, level)
|
|
66
|
+
preview(project_id, junit_paths, surface, level, opts)
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
unless result[:ok]
|
|
@@ -86,7 +87,14 @@ module Fp
|
|
|
86
87
|
# Preview mode needs to know which surface the results belong to, because the
|
|
87
88
|
# server uses it to decide which cells the run had a chance to cover — and
|
|
88
89
|
# therefore which vanished tests it may legitimately flag.
|
|
89
|
-
|
|
90
|
+
#
|
|
91
|
+
# The markers are resolved here, in the checkout, and sent with the request. The
|
|
92
|
+
# server can also resolve them itself by fetching the test files from GitHub, but
|
|
93
|
+
# that only works for a surface with a registered, readable repo — and it reads the
|
|
94
|
+
# *default branch*, which by definition can't see the tests the branch under review
|
|
95
|
+
# just added. Resolving locally is both more accurate and the only option for a
|
|
96
|
+
# private repo.
|
|
97
|
+
def preview(project_id, junit_paths, surface, level, opts)
|
|
90
98
|
unless surface
|
|
91
99
|
output.error(
|
|
92
100
|
'--surface is required with --junit (the surface these results are for). ' \
|
|
@@ -105,10 +113,46 @@ module Fp
|
|
|
105
113
|
project_id,
|
|
106
114
|
surface: surface,
|
|
107
115
|
junit_xmls: junit_paths.map { |p| File.read(p) },
|
|
108
|
-
level: level
|
|
116
|
+
level: level,
|
|
117
|
+
results: resolve_results(junit_paths, surface, opts)
|
|
109
118
|
)
|
|
110
119
|
end
|
|
111
120
|
|
|
121
|
+
# Bind every report's testcases to their fp markers locally. Returns nil (rather
|
|
122
|
+
# than an empty array) when nothing resolved, so the server falls back to its own
|
|
123
|
+
# resolution instead of reading "no results" as "every test vanished".
|
|
124
|
+
def resolve_results(junit_paths, surface, opts)
|
|
125
|
+
base_dir = opts[:base_dir] || Dir.pwd
|
|
126
|
+
|
|
127
|
+
results = junit_paths.flat_map do |path|
|
|
128
|
+
testcases = Fp::JUnit.parse_file(path)
|
|
129
|
+
Fp::JUnit.ci_results(testcases, base_dir: base_dir, default_surface: surface)[:results]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
if results.empty?
|
|
133
|
+
# Stderr, not output.error: this is advisory and must not corrupt --json's stdout.
|
|
134
|
+
warn "Warning: no fp:<slug> markers resolved from #{junit_paths.join(', ')} " \
|
|
135
|
+
"(base dir: #{base_dir}). Falling back to the server's own marker resolution, " \
|
|
136
|
+
'which can only read a registered public repo. Check --base-dir.'
|
|
137
|
+
return nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
results.map do |r|
|
|
141
|
+
{
|
|
142
|
+
slug: r.slug,
|
|
143
|
+
surface: r.surface,
|
|
144
|
+
status: r.status,
|
|
145
|
+
source_file: r.file,
|
|
146
|
+
source_line: r.line,
|
|
147
|
+
test_count: r.test_count,
|
|
148
|
+
failure_count: r.failure_count
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
rescue ArgumentError => e
|
|
152
|
+
output.error(e.message)
|
|
153
|
+
exit 1
|
|
154
|
+
end
|
|
155
|
+
|
|
112
156
|
def render(report, violations, passed, project_slug, opts)
|
|
113
157
|
payload = report.merge('project' => project_slug, 'warn_only' => !!opts[:warn_only])
|
|
114
158
|
|
|
@@ -1,31 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
3
5
|
module Fp
|
|
4
6
|
module Commands
|
|
5
7
|
# CI-only evidence reporting from a JUnit XML report.
|
|
6
8
|
#
|
|
7
9
|
# fp ci-report --junit <path> --project <slug> --surface X --ci-url URL
|
|
8
10
|
#
|
|
9
|
-
# This is the CI counterpart to `fp report --junit`. Where `fp report`
|
|
10
|
-
#
|
|
11
|
-
#
|
|
11
|
+
# This is the CI counterpart to `fp report --junit`. Where `fp report` writes agent
|
|
12
|
+
# evidence (present/stub) and never claims a test passed, this command resolves the
|
|
13
|
+
# run's results against the fp:<slug> markers in the checked-out test files and
|
|
14
|
+
# uploads the real verdict per requirement:
|
|
15
|
+
#
|
|
16
|
+
# - each testcase is bound to the marker that annotates it (same convention and
|
|
17
|
+
# the same code path as `fp report --junit`),
|
|
18
|
+
# - passing / failing evidence is written per requirement for the surface,
|
|
19
|
+
# - and any slug that was passing but has now vanished from the report is marked
|
|
20
|
+
# failing.
|
|
12
21
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
22
|
+
# Resolution happens here rather than server-side on purpose. The backend used to
|
|
23
|
+
# re-fetch the test files from GitHub to find the markers itself, which cannot work
|
|
24
|
+
# for a private repo (unauthenticated, the API answers 404): every run resolved zero
|
|
25
|
+
# markers and reported nothing at all, with no error. CI already has the branch
|
|
26
|
+
# checked out — including tests the branch just added, which the default branch's
|
|
27
|
+
# tree would not have shown. The raw XML still goes up alongside the results so the
|
|
28
|
+
# server can fall back to its own resolution for older clients.
|
|
18
29
|
#
|
|
19
|
-
# That last part is why the whole report goes up as one document rather
|
|
20
|
-
# per-slug: the server can only detect a disappeared test by diffing the full
|
|
21
|
-
# result set against what it had. Reporting passing/failing is a privilege of
|
|
22
|
-
#
|
|
23
|
-
#
|
|
30
|
+
# That last part of the ingest is why the whole report goes up as one document rather
|
|
31
|
+
# than per-slug: the server can only detect a disappeared test by diffing the full
|
|
32
|
+
# result set against what it had. Reporting passing/failing is a privilege of CI
|
|
33
|
+
# (#1222) — agents can't reach this path, they only ever report evidence via
|
|
34
|
+
# `fp report`.
|
|
24
35
|
#
|
|
25
36
|
# Multiple --junit flags may be passed to ingest several reports for the same
|
|
26
37
|
# surface in one invocation (e.g. a suite split across shards). Each is
|
|
27
38
|
# uploaded in turn.
|
|
28
39
|
#
|
|
40
|
+
# --base-dir tells the CLI where the report's relative test-file paths resolve from
|
|
41
|
+
# (vitest, for instance, reports paths relative to the frontend package, not the repo
|
|
42
|
+
# root). Defaults to the working directory.
|
|
43
|
+
#
|
|
29
44
|
# --pr <url> marks the run as a pull request's CI rather than the merged branch's.
|
|
30
45
|
# Passing tests are then recorded as `pr_passing` ("would pass once merged") instead
|
|
31
46
|
# of `passing`, and no merged evidence is demoted. Run it without --pr on pushes to
|
|
@@ -37,7 +52,9 @@ module Fp
|
|
|
37
52
|
surface: :string,
|
|
38
53
|
ci_url: :string,
|
|
39
54
|
pr: :string,
|
|
40
|
-
junit: :string
|
|
55
|
+
junit: :string,
|
|
56
|
+
base_dir: :string,
|
|
57
|
+
allow_empty: :boolean
|
|
41
58
|
}.freeze
|
|
42
59
|
|
|
43
60
|
def run(args)
|
|
@@ -62,17 +79,27 @@ module Fp
|
|
|
62
79
|
end
|
|
63
80
|
|
|
64
81
|
project_id = resolve_project_id(opts[:project])
|
|
82
|
+
known = known_slugs(project_id)
|
|
65
83
|
|
|
66
|
-
totals = { created: 0, updated: 0, missing_failing: 0, reports: []
|
|
84
|
+
totals = { created: 0, updated: 0, missing_failing: 0, reports: [], resolved: 0,
|
|
85
|
+
unmatched: 0, unknown: [] }
|
|
67
86
|
|
|
68
87
|
junit_paths.each do |path|
|
|
69
88
|
xml = File.read(path)
|
|
89
|
+
resolved = resolve_results(path, opts)
|
|
90
|
+
totals[:unmatched] += resolved[:unmatched]
|
|
91
|
+
|
|
92
|
+
results, unknown = partition_known(resolved[:results], known)
|
|
93
|
+
totals[:resolved] += results.length
|
|
94
|
+
totals[:unknown].concat(unknown)
|
|
95
|
+
|
|
70
96
|
result = client.ingest_ci_results(
|
|
71
97
|
project_id,
|
|
72
98
|
ci_url: opts[:ci_url],
|
|
73
99
|
surface: opts[:surface],
|
|
74
100
|
junit_xml: xml,
|
|
75
|
-
pr_url: opts[:pr]
|
|
101
|
+
pr_url: opts[:pr],
|
|
102
|
+
results: results
|
|
76
103
|
)
|
|
77
104
|
|
|
78
105
|
unless result[:ok]
|
|
@@ -87,14 +114,101 @@ module Fp
|
|
|
87
114
|
totals[:reports] << { path: path, results: data['results'] || [] }
|
|
88
115
|
end
|
|
89
116
|
|
|
117
|
+
# Reporting nothing is the failure this command exists to make visible: a run that
|
|
118
|
+
# resolves no markers has told the matrix nothing, and used to do so silently.
|
|
119
|
+
if totals[:resolved].zero? && !opts[:allow_empty]
|
|
120
|
+
if totals[:unknown].empty?
|
|
121
|
+
output.error('No fp:<slug> markers resolved from the report(s), so no evidence was uploaded.')
|
|
122
|
+
output.error("Check that --base-dir (#{opts[:base_dir] || Dir.pwd}) is where the report's " \
|
|
123
|
+
'test-file paths resolve from, and that the tests carry fp:<slug> markers.')
|
|
124
|
+
else
|
|
125
|
+
output.error('None of the fp:<slug> markers in the report(s) match a requirement in ' \
|
|
126
|
+
"this project, so no evidence was uploaded: #{totals[:unknown].uniq.join(', ')}")
|
|
127
|
+
output.error('Propose the requirement (fp propose) or fix the slug in the marker.')
|
|
128
|
+
end
|
|
129
|
+
output.error('Pass --allow-empty if a suite with no markers is expected here.')
|
|
130
|
+
exit 1
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Resolved markers but nothing recorded: the API accepted the report and stored
|
|
134
|
+
# none of it — a server that predates resolved results, or slugs/surfaces it
|
|
135
|
+
# dropped. Not the author's fault, so don't fail their build, but say it loudly
|
|
136
|
+
# rather than printing "created: 0" as though that were a result.
|
|
137
|
+
if totals[:resolved].positive? && (totals[:created] + totals[:updated]).zero?
|
|
138
|
+
annotate("fp ci-report resolved #{totals[:resolved]} marker(s) but the API recorded " \
|
|
139
|
+
"none of them for surface '#{opts[:surface]}'. The parity matrix has not " \
|
|
140
|
+
'been updated by this run.')
|
|
141
|
+
end
|
|
142
|
+
|
|
90
143
|
emit_summary(opts, totals)
|
|
91
144
|
end
|
|
92
145
|
|
|
93
146
|
private
|
|
94
147
|
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
148
|
+
# Every slug this project has a requirement for, in one call, so a marker that
|
|
149
|
+
# matches nothing can be skipped with a warning instead of filing evidence against a
|
|
150
|
+
# requirement that doesn't exist. (A few test suites legitimately contain
|
|
151
|
+
# marker-shaped *fixtures* — the tests for the marker feature itself.) Drafts count:
|
|
152
|
+
# a requirement awaiting review still collects evidence.
|
|
153
|
+
#
|
|
154
|
+
# Returns nil when the list can't be fetched, which means "don't filter" — losing a
|
|
155
|
+
# run's evidence to a hiccup on this call would be worse than the pollution.
|
|
156
|
+
def known_slugs(project_id)
|
|
157
|
+
result = client.list_requirements(project_id: project_id)
|
|
158
|
+
unless result[:ok]
|
|
159
|
+
warn "Warning: could not list requirements (#{result[:error]}); reporting every " \
|
|
160
|
+
'resolved marker without checking it against a requirement.'
|
|
161
|
+
return nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
(result[:data]['requirements'] || []).map { |r| r['slug'] }.compact.to_set
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def partition_known(results, known)
|
|
168
|
+
return [results, []] if known.nil?
|
|
169
|
+
|
|
170
|
+
kept, unknown = results.partition { |r| known.include?(r[:slug]) }
|
|
171
|
+
[kept, unknown.map { |r| r[:slug] }.uniq]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A warning that survives a CI log. Stderr always; a GitHub Actions annotation too
|
|
175
|
+
# when we're running in one, so it shows up in the run summary rather than scrolling
|
|
176
|
+
# past in a collapsed step.
|
|
177
|
+
def annotate(message)
|
|
178
|
+
warn "Warning: #{message}"
|
|
179
|
+
puts "::warning::#{message}" if ENV['GITHUB_ACTIONS'] == 'true'
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Bind this report's testcases to their fp markers, locally, in the checkout.
|
|
183
|
+
def resolve_results(path, opts)
|
|
184
|
+
testcases = Fp::JUnit.parse_file(path)
|
|
185
|
+
resolved = Fp::JUnit.ci_results(
|
|
186
|
+
testcases,
|
|
187
|
+
base_dir: opts[:base_dir] || Dir.pwd,
|
|
188
|
+
default_surface: opts[:surface]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
{
|
|
192
|
+
results: resolved[:results].map do |r|
|
|
193
|
+
{
|
|
194
|
+
slug: r.slug,
|
|
195
|
+
surface: r.surface,
|
|
196
|
+
status: r.status,
|
|
197
|
+
source_file: r.file,
|
|
198
|
+
source_line: r.line,
|
|
199
|
+
test_count: r.test_count,
|
|
200
|
+
failure_count: r.failure_count
|
|
201
|
+
}
|
|
202
|
+
end,
|
|
203
|
+
unmatched: resolved[:unmatched].length
|
|
204
|
+
}
|
|
205
|
+
rescue ArgumentError => e
|
|
206
|
+
output.error(e.message)
|
|
207
|
+
exit 1
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Pull the human-readable summary together. `markers_resolved` is worth printing
|
|
211
|
+
# even when everything worked: it's the number that used to silently be zero.
|
|
98
212
|
def emit_summary(opts, totals)
|
|
99
213
|
data = {
|
|
100
214
|
project: opts[:project],
|
|
@@ -104,6 +218,9 @@ module Fp
|
|
|
104
218
|
evidence_created: totals[:created],
|
|
105
219
|
evidence_updated: totals[:updated],
|
|
106
220
|
missing_marked_failing: totals[:missing_failing],
|
|
221
|
+
markers_resolved: totals[:resolved],
|
|
222
|
+
unmatched_testcases: totals[:unmatched],
|
|
223
|
+
skipped_unknown: totals[:unknown].uniq,
|
|
107
224
|
reports: totals[:reports]
|
|
108
225
|
}
|
|
109
226
|
|
|
@@ -116,6 +233,12 @@ module Fp
|
|
|
116
233
|
end
|
|
117
234
|
end
|
|
118
235
|
puts
|
|
236
|
+
puts " resolved: #{totals[:resolved]} marker(s) from the report(s); " \
|
|
237
|
+
"#{totals[:unmatched]} testcase(s) carried none."
|
|
238
|
+
unless totals[:unknown].empty?
|
|
239
|
+
puts " skipped #{totals[:unknown].uniq.length} marker(s) with no matching " \
|
|
240
|
+
"requirement: #{totals[:unknown].uniq.join(', ')}"
|
|
241
|
+
end
|
|
119
242
|
puts " created: #{totals[:created]} updated: #{totals[:updated]} marked failing (missing): #{totals[:missing_failing]}"
|
|
120
243
|
end
|
|
121
244
|
end
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -108,6 +108,10 @@ module Fp
|
|
|
108
108
|
fp check --warn-only # report violations but exit 0
|
|
109
109
|
fp check --json # machine-readable violations
|
|
110
110
|
|
|
111
|
+
# Gate a branch on its own results (nothing is saved). One call per surface.
|
|
112
|
+
fp check --surface api --junit junit.xml
|
|
113
|
+
fp check --surface web --junit web-junit.xml --base-dir frontend
|
|
114
|
+
|
|
111
115
|
# Show the resolved configuration (which profile/env/API URL/key is in effect)
|
|
112
116
|
fp whoami
|
|
113
117
|
fp whoami --json
|
|
@@ -223,6 +227,11 @@ module Fp
|
|
|
223
227
|
--surface api \\
|
|
224
228
|
--ci-url "$CI_RUN_URL"
|
|
225
229
|
# Pass --junit more than once to ingest several reports for one surface.
|
|
230
|
+
# Markers are resolved from the test files in the checkout, so run this from
|
|
231
|
+
# the repo (or pass --base-dir DIR if the report's paths are relative to a
|
|
232
|
+
# subdirectory, e.g. --base-dir frontend for vitest).
|
|
233
|
+
# A report that resolves no markers is an error, not a silent no-op; pass
|
|
234
|
+
# --allow-empty when a suite genuinely has none.
|
|
226
235
|
|
|
227
236
|
# Show the parity matrix
|
|
228
237
|
fp matrix --project stowzilla
|
data/lib/fp/commands/init.rb
CHANGED
|
@@ -246,6 +246,10 @@ module Fp
|
|
|
246
246
|
# real passing/failing evidence — without it, the matrix only ever shows the
|
|
247
247
|
# `present` markers agents filed, and the gate has nothing green to see.
|
|
248
248
|
#
|
|
249
|
+
# It reads the fp:<slug> markers out of the checkout, so run it in the repo
|
|
250
|
+
# and pass --base-dir when the report's test-file paths are relative to a
|
|
251
|
+
# subdirectory (vitest, for instance, reports them relative to its package).
|
|
252
|
+
#
|
|
249
253
|
# report:
|
|
250
254
|
# name: Report evidence
|
|
251
255
|
# if: github.event_name == 'push'
|
data/lib/fp/junit.rb
CHANGED
|
@@ -12,14 +12,35 @@ module Fp
|
|
|
12
12
|
# evidence for each requirement the suite covered.
|
|
13
13
|
module JUnit
|
|
14
14
|
# A single parsed testcase.
|
|
15
|
-
|
|
15
|
+
#
|
|
16
|
+
# `status` is the *agent* view (present/skipped) — what `fp report` files. `failed`
|
|
17
|
+
# is the runner's own verdict, which only CI is allowed to act on (`fp ci-report`).
|
|
18
|
+
# Both are kept because they answer different questions: a red test still proves the
|
|
19
|
+
# test exists (present), it just doesn't prove the requirement is met.
|
|
20
|
+
TestCase = Struct.new(:name, :classname, :file, :line, :status, :failed, keyword_init: true) do
|
|
16
21
|
# Agents only ever report present/stub — never passing/failing (that's CI).
|
|
17
22
|
# A skipped test is treated as a stub; anything else counts as present.
|
|
18
23
|
def stub?
|
|
19
24
|
status == :skipped
|
|
20
25
|
end
|
|
26
|
+
|
|
27
|
+
def failed?
|
|
28
|
+
!!failed
|
|
29
|
+
end
|
|
21
30
|
end
|
|
22
31
|
|
|
32
|
+
# One requirement's aggregated CI outcome for one surface, ready to hand to the
|
|
33
|
+
# backend's ingest endpoint. This is what lets CI upload *resolved* results rather
|
|
34
|
+
# than raw XML the server would have to re-scrape the repo to understand.
|
|
35
|
+
CiResult = Struct.new(:slug, :surface, :status, :file, :line, :test_count, :failure_count,
|
|
36
|
+
keyword_init: true)
|
|
37
|
+
|
|
38
|
+
# A testcase paired with the marker that annotates it. `precise` records whether we
|
|
39
|
+
# know *which* test the marker sits above (matched by line or by test name) or only
|
|
40
|
+
# 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)
|
|
43
|
+
|
|
23
44
|
# Evidence discovered by binding a testcase to a marker.
|
|
24
45
|
# `line` is the 1-based line number of the test in its source file: the
|
|
25
46
|
# testcase's own line when the runner reported one, otherwise the line of
|
|
@@ -75,7 +96,8 @@ module Fp
|
|
|
75
96
|
classname: classname,
|
|
76
97
|
file: resolve_file(el.attributes['file'], classname, suite_name),
|
|
77
98
|
line: (el.attributes['line'] && el.attributes['line'].to_i),
|
|
78
|
-
status: testcase_status(el)
|
|
99
|
+
status: testcase_status(el),
|
|
100
|
+
failed: testcase_failed?(el)
|
|
79
101
|
)
|
|
80
102
|
end
|
|
81
103
|
|
|
@@ -111,6 +133,12 @@ module Fp
|
|
|
111
133
|
:present
|
|
112
134
|
end
|
|
113
135
|
|
|
136
|
+
# Did the runner report this testcase as red? Only CI acts on this (`fp ci-report`);
|
|
137
|
+
# agent evidence deliberately ignores it.
|
|
138
|
+
def testcase_failed?(el)
|
|
139
|
+
el.get_elements('failure').any? || el.get_elements('error').any?
|
|
140
|
+
end
|
|
141
|
+
|
|
114
142
|
# Bind parsed testcases to fp:<slug> markers found in their source files.
|
|
115
143
|
#
|
|
116
144
|
# A marker binds to the test it annotates — the testcase whose line is the
|
|
@@ -139,57 +167,165 @@ module Fp
|
|
|
139
167
|
# missing_surface: [Binding-ish], # markers with no surface and no default
|
|
140
168
|
# }
|
|
141
169
|
def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil)
|
|
142
|
-
marker_cache = {}
|
|
143
170
|
bindings = {}
|
|
144
171
|
matched = {}
|
|
145
172
|
missing_surface = []
|
|
146
173
|
|
|
147
|
-
|
|
148
|
-
|
|
174
|
+
walk_matches(testcases, base_dir) do |match|
|
|
175
|
+
tc = match.testcase
|
|
176
|
+
marker = match.marker
|
|
177
|
+
matched[tc.object_id] = true
|
|
149
178
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
next if markers.empty?
|
|
179
|
+
surfaces = marker[:surfaces]
|
|
180
|
+
surfaces = [default_surface] if surfaces.empty?
|
|
153
181
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
182
|
+
surfaces.each do |surface|
|
|
183
|
+
if surface.nil? || surface.empty?
|
|
184
|
+
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
185
|
+
else
|
|
186
|
+
record_binding(bindings, marker[:slug], surface, tc, marker, titles_reliable: match.precise)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
159
190
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
191
|
+
unmatched = testcases.reject { |tc| matched[tc.object_id] }
|
|
192
|
+
{ bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Aggregate a run's results per (slug, surface) into the pass/fail verdict CI files.
|
|
196
|
+
#
|
|
197
|
+
# This is `fp ci-report`'s half of the marker convention. The backend used to
|
|
198
|
+
# re-resolve markers itself by fetching the test files from GitHub, which cannot work
|
|
199
|
+
# for a private repo (unauthenticated, the API answers 404) and silently resolved
|
|
200
|
+
# nothing — the run reported no evidence at all. CI already has the branch checked
|
|
201
|
+
# out, so the resolution happens here and the result is uploaded.
|
|
202
|
+
#
|
|
203
|
+
# A slug is `failing` if any test claiming it went red, `stub` if every test claiming
|
|
204
|
+
# it was skipped (a skipped test is not proof of anything), and `passing` otherwise.
|
|
205
|
+
#
|
|
206
|
+
# Returns { results: [CiResult, ...], unmatched: [TestCase], missing_surface: [...] }.
|
|
207
|
+
def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil)
|
|
208
|
+
aggregates = {}
|
|
209
|
+
matched = {}
|
|
210
|
+
missing_surface = []
|
|
211
|
+
|
|
212
|
+
walk_matches(testcases, base_dir) do |match|
|
|
213
|
+
tc = match.testcase
|
|
214
|
+
marker = match.marker
|
|
215
|
+
matched[tc.object_id] = true
|
|
163
216
|
|
|
164
|
-
|
|
217
|
+
surfaces = marker[:surfaces]
|
|
218
|
+
surfaces = [default_surface] if surfaces.empty?
|
|
165
219
|
|
|
166
|
-
|
|
167
|
-
|
|
220
|
+
surfaces.each do |surface|
|
|
221
|
+
if surface.nil? || surface.empty?
|
|
222
|
+
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
223
|
+
next
|
|
224
|
+
end
|
|
168
225
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
226
|
+
key = [marker[:slug], surface]
|
|
227
|
+
agg = aggregates[key] ||= { file: tc.file, line: tc.line || marker[:line], ran: 0, failed: 0, skipped: 0 }
|
|
228
|
+
if tc.stub?
|
|
229
|
+
agg[:skipped] += 1
|
|
230
|
+
else
|
|
231
|
+
agg[:ran] += 1
|
|
232
|
+
agg[:failed] += 1 if tc.failed?
|
|
175
233
|
end
|
|
176
234
|
end
|
|
177
235
|
end
|
|
178
236
|
|
|
237
|
+
results = aggregates.map do |(slug, surface), agg|
|
|
238
|
+
status =
|
|
239
|
+
if agg[:failed].positive? then 'failing'
|
|
240
|
+
elsif agg[:ran].zero? then 'stub'
|
|
241
|
+
else 'passing'
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
CiResult.new(slug: slug, surface: surface, status: status, file: agg[:file], line: agg[:line],
|
|
245
|
+
test_count: agg[:ran], failure_count: agg[:failed])
|
|
246
|
+
end
|
|
247
|
+
|
|
179
248
|
unmatched = testcases.reject { |tc| matched[tc.object_id] }
|
|
180
|
-
{
|
|
249
|
+
{ results: results, unmatched: unmatched, missing_surface: missing_surface.uniq }
|
|
181
250
|
end
|
|
182
251
|
|
|
183
|
-
#
|
|
252
|
+
# Walk testcases and yield a Match for every one we can tie to a marker.
|
|
253
|
+
# Shared by bind_markers (agent evidence) and ci_results (CI verdicts) so the two
|
|
254
|
+
# can never disagree about which test a marker annotates.
|
|
255
|
+
def walk_matches(testcases, base_dir)
|
|
256
|
+
marker_cache = {}
|
|
257
|
+
|
|
258
|
+
# Group testcases by their source file so we can reason about ordering.
|
|
259
|
+
testcases.group_by(&:file).each do |_file, cases|
|
|
260
|
+
markers = markers_for(cases.first, base_dir, marker_cache)
|
|
261
|
+
next if markers.empty?
|
|
262
|
+
|
|
263
|
+
cases.each do |tc|
|
|
264
|
+
marker, precise = marker_for(tc, cases, markers)
|
|
265
|
+
next unless marker
|
|
266
|
+
|
|
267
|
+
yield Match.new(testcase: tc, marker: marker, precise: precise)
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Determine the marker (slug + surfaces) that annotates a testcase.
|
|
273
|
+
# Returns [marker, precise] — or [nil, false] when nothing can be tied to it.
|
|
184
274
|
def marker_for(testcase, sibling_cases, markers)
|
|
185
275
|
if testcase.line
|
|
186
|
-
marker_for_line(testcase.line, sibling_cases, markers)
|
|
276
|
+
[marker_for_line(testcase.line, sibling_cases, markers), true]
|
|
277
|
+
elsif (by_title = marker_for_title(testcase, markers))
|
|
278
|
+
# No line numbers (vitest, jest): match the marker to the test whose declared
|
|
279
|
+
# name follows it in the file. This is the common case for the web surface, and
|
|
280
|
+
# without it any file with more than one marker resolves to nothing at all.
|
|
281
|
+
[by_title, true]
|
|
187
282
|
elsif markers.length == 1 && sibling_cases.none?(&:line)
|
|
188
|
-
# No line info anywhere and a single marker: unambiguous
|
|
189
|
-
|
|
283
|
+
# No line info anywhere and a single marker: unambiguous, but we can't say which
|
|
284
|
+
# test it annotates, so a title would be a guess.
|
|
285
|
+
[markers.first, sibling_cases.length == 1]
|
|
286
|
+
else
|
|
287
|
+
[nil, false]
|
|
190
288
|
end
|
|
191
289
|
end
|
|
192
290
|
|
|
291
|
+
# Match a testcase to a marker by the name of the test declared beneath the marker.
|
|
292
|
+
#
|
|
293
|
+
# Runners that omit line numbers still report the test's name, and vitest/jest qualify
|
|
294
|
+
# it with the enclosing describe blocks ("Group > Subgroup > test name"). So a marker
|
|
295
|
+
# above a test matches the last segment, and one above a `describe` matches any
|
|
296
|
+
# segment before it. A test-level match wins over a group-level one, so a marker on a
|
|
297
|
+
# single test inside an already-marked group still binds to just that test.
|
|
298
|
+
def marker_for_title(testcase, markers)
|
|
299
|
+
name = testcase.name.to_s.strip
|
|
300
|
+
return nil if name.empty?
|
|
301
|
+
|
|
302
|
+
segments = name.split(NAME_SEPARATOR).map(&:strip)
|
|
303
|
+
|
|
304
|
+
by_test = markers.select { |m| m[:title_kind] == :test && title_matches_test?(m[:title], name, segments) }
|
|
305
|
+
return best_by_title(by_test) unless by_test.empty?
|
|
306
|
+
|
|
307
|
+
by_group = markers.select do |m|
|
|
308
|
+
m[:title_kind] == :group && !m[:title].to_s.empty? && segments[0..-2].include?(m[:title])
|
|
309
|
+
end
|
|
310
|
+
best_by_title(by_group)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# The most specific match wins (longest title), and among equally good matches the
|
|
314
|
+
# lowest marker in the file — the same "nearest marker above the test" rule the
|
|
315
|
+
# line-based path applies when markers are stacked.
|
|
316
|
+
def best_by_title(markers)
|
|
317
|
+
markers.max_by { |m| [m[:title].length, m[:line]] }
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# vitest/jest join describe names and the test name with " > ".
|
|
321
|
+
NAME_SEPARATOR = / > /.freeze
|
|
322
|
+
|
|
323
|
+
def title_matches_test?(title, name, segments)
|
|
324
|
+
return false if title.nil? || title.empty?
|
|
325
|
+
|
|
326
|
+
segments.last == title || name == title || name.end_with?(title)
|
|
327
|
+
end
|
|
328
|
+
|
|
193
329
|
# A marker annotates the testcase that is the first test below it. Given a
|
|
194
330
|
# testcase line, find the marker that sits directly above it with no other
|
|
195
331
|
# testcase in between.
|
|
@@ -245,21 +381,69 @@ module Fp
|
|
|
245
381
|
end
|
|
246
382
|
|
|
247
383
|
# Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
|
|
248
|
-
# [{ slug:, surfaces: [..], line: }, ...] (1-based line numbers).
|
|
384
|
+
# [{ slug:, surfaces: [..], line:, title:, title_kind: }, ...] (1-based line numbers).
|
|
249
385
|
# `surfaces` is [] when the marker pins none (use the default surface).
|
|
250
386
|
#
|
|
387
|
+
# `title` is the name of the test (or describe block) declared below the marker, used
|
|
388
|
+
# to bind markers to results from runners that report no line numbers. It is nil when
|
|
389
|
+
# no declaration we recognise follows the marker.
|
|
390
|
+
#
|
|
251
391
|
# Only comment lines are considered — see COMMENT_LINE_RE.
|
|
252
392
|
def scan_markers(contents)
|
|
393
|
+
lines = contents.lines
|
|
253
394
|
markers = []
|
|
254
|
-
|
|
395
|
+
lines.each_with_index do |line, idx|
|
|
255
396
|
next unless line.match?(COMMENT_LINE_RE)
|
|
256
397
|
|
|
257
398
|
line.scan(MARKER_RE) do |(slug, surface_list)|
|
|
258
399
|
surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
|
|
259
|
-
|
|
400
|
+
title, kind = declared_test_after(lines, idx)
|
|
401
|
+
markers << { slug: slug, surfaces: surfaces, line: idx + 1, title: title, title_kind: kind }
|
|
260
402
|
end
|
|
261
403
|
end
|
|
262
404
|
markers
|
|
263
405
|
end
|
|
406
|
+
|
|
407
|
+
# How a test (or group) declaration announces its name, per language. Each entry is
|
|
408
|
+
# [regex, kind]; the captured group is the name. `:group` entries are describe-style
|
|
409
|
+
# blocks, whose name prefixes the names of the tests inside them.
|
|
410
|
+
TEST_DECL_PATTERNS = [
|
|
411
|
+
[/\b(?:it|test|bench)(?:\.\w+)*\s*\(\s*(['"`])(.+?)\1/, :test], # vitest/jest/mocha
|
|
412
|
+
[/\bdescribe(?:\.\w+)*\s*\(\s*(['"`])(.+?)\1/, :group], # vitest/jest/mocha group
|
|
413
|
+
[/\b(?:it|specify|scenario)\s+(['"])(.+?)\1/, :test], # rspec
|
|
414
|
+
[/\b(?:def|fun)\s+`(.+?)`/, :test], # kotlin backticked
|
|
415
|
+
[/\b(?:def|fun|func)\s+(test_?\w+)/i, :test] # minitest / xctest / pytest
|
|
416
|
+
].freeze
|
|
417
|
+
|
|
418
|
+
# The name of the first test declaration at or below `idx`, as [title, kind].
|
|
419
|
+
# Intervening comment and blank lines are skipped (a marker is often followed by more
|
|
420
|
+
# comments), but the search gives up at the first line of real code that declares
|
|
421
|
+
# nothing recognisable, so a marker can't borrow the name of an unrelated test
|
|
422
|
+
# further down the file.
|
|
423
|
+
def declared_test_after(lines, idx)
|
|
424
|
+
((idx + 1)...[idx + 1 + DECL_SEARCH_WINDOW, lines.length].min).each do |i|
|
|
425
|
+
line = lines[i]
|
|
426
|
+
next if line.strip.empty? || line.match?(COMMENT_LINE_RE)
|
|
427
|
+
|
|
428
|
+
TEST_DECL_PATTERNS.each do |re, kind|
|
|
429
|
+
match = line.match(re)
|
|
430
|
+
next unless match
|
|
431
|
+
|
|
432
|
+
# Two-quote patterns capture the quote first, the name second.
|
|
433
|
+
return [match.captures.last, kind]
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# A line of real code that declares nothing recognisable: the marker doesn't
|
|
437
|
+
# annotate a test we can name.
|
|
438
|
+
return [nil, nil]
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
[nil, nil]
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# How far below a marker to look for the declaration it annotates. The search stops at
|
|
445
|
+
# the first line of real code regardless, so this only bounds how long a comment block
|
|
446
|
+
# between the marker and its test may be.
|
|
447
|
+
DECL_SEARCH_WINDOW = 40
|
|
264
448
|
end
|
|
265
449
|
end
|
data/lib/fp/version.rb
CHANGED