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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20c34d118adff460a3de1eaaa15f66718593100ae0261f9dc064f63b65d8b3f2
4
- data.tar.gz: e14ab1bf7bbf6c646f42e0b9a9291653e072f47055b8dff8ccf2294d10effa7a
3
+ metadata.gz: e4c131ba204a5f2e3b872551963af559ee1cc2ba941eb192228cd261a12c6f48
4
+ data.tar.gz: 4919cdca3946beec35921271be6998938150085879a3b6c9ed4d8cd4c84aa622
5
5
  SHA512:
6
- metadata.gz: 248bf8d80901ed3a4075e44e501169bd37803dc4fad78b3b342cfd79307a4649b824664a0b7ce5013f3fd323b2f64ed7deaa2318813389604e21d8c212cd2c36
7
- data.tar.gz: 1e22a8d5b38f77581693dd4cb0d912aedb0d09afa8cdffaa96332eca3afc1d9c62893ff2c0a09e9ca7f4d44d46fd6126d71b915cb3bfa357099c2a3726ce2006
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
- def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil)
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
- def preview_parity(project_id, surface:, junit_xmls:, level: nil)
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
@@ -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
- def preview(project_id, junit_paths, surface, level)
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
- # writes agent evidence (present/stub) and never claims a test passed, this
11
- # command hands the raw JUnit XML to the backend's CI ingest endpoint, which:
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
- # - maps each testcase to its fp:<slug> marker (server-side, same
14
- # file-scanning convention as the CLI),
15
- # - writes passing / failing evidence per requirement for the surface,
16
- # - and marks any slug that was passing but has now vanished from the
17
- # report as failing.
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 than
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
- # CI (#1222) — agents can't reach this path, they only ever report evidence
23
- # via `fp report`.
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
- # Pull every occurrence of a repeatable flag (and its value) out of args,
96
- # returning [collected_values, remaining_args]. Leaves all other flags for
97
- # the normal parser.
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
@@ -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
@@ -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
- TestCase = Struct.new(:name, :classname, :file, :line, :status, keyword_init: true) do
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
- # Group testcases by their source file so we can reason about ordering.
148
- by_file = testcases.group_by(&:file)
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
- by_file.each do |file, cases|
151
- markers = markers_for(cases.first, base_dir, marker_cache)
152
- next if markers.empty?
179
+ surfaces = marker[:surfaces]
180
+ surfaces = [default_surface] if surfaces.empty?
153
181
 
154
- # A title is only trustworthy when we know which test the marker annotates: either
155
- # the report carried line numbers, or the file holds a single test. Otherwise the
156
- # marker was matched file-wide and naming any one test would be a guess — vitest
157
- # and jest emit no line numbers, so this is the common case, not an edge one.
158
- titles_reliable = cases.any?(&:line) || cases.length == 1
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
- cases.each do |tc|
161
- marker = marker_for(tc, cases, markers)
162
- next unless marker
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
- matched[tc.object_id] = true
217
+ surfaces = marker[:surfaces]
218
+ surfaces = [default_surface] if surfaces.empty?
165
219
 
166
- surfaces = marker[:surfaces]
167
- surfaces = [default_surface] if surfaces.empty?
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
- surfaces.each do |surface|
170
- if surface.nil? || surface.empty?
171
- missing_surface << { slug: marker[:slug], file: tc.file }
172
- else
173
- record_binding(bindings, marker[:slug], surface, tc, marker, titles_reliable: titles_reliable)
174
- end
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
- { bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq }
249
+ { results: results, unmatched: unmatched, missing_surface: missing_surface.uniq }
181
250
  end
182
251
 
183
- # Determine the marker (slug + surfaces) that annotates a testcase, or nil.
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
- markers.first
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
- contents.each_line.with_index(1) do |line, num|
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
- markers << { slug: slug, surfaces: surfaces, line: num }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.9'
4
+ VERSION = '0.0.10'
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.9
4
+ version: 0.0.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla