featureparity 0.0.28 → 0.0.30

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.
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # Shared by `fp question` and `fp questions`: find the requirement, print a question.
6
+ module QuestionSupport
7
+ private
8
+
9
+ def find_requirement!(project_id, slug)
10
+ result = client.find_requirement_by_slug(project_id, slug)
11
+ unless result[:ok]
12
+ output.error(result[:error], status: result[:status])
13
+ exit 1
14
+ end
15
+ result[:data]['requirement']
16
+ end
17
+
18
+ def print_question(question)
19
+ asker = question['asked_by'] || 'unknown'
20
+ kind = question['asked_by_kind'] == 'agent' ? 'agent' : 'human'
21
+ puts " [#{question['status']}] #{question['text']}"
22
+ puts " asked by #{asker} (#{kind}) at #{question['asked_at']}"
23
+ case question['status']
24
+ when 'answered'
25
+ puts " answer: #{question['answer']} (#{question['resolved_by']})"
26
+ when 'dismissed'
27
+ puts " dismissed by #{question['resolved_by']}"
28
+ end
29
+ end
30
+ end
31
+
32
+ # `fp question <slug> "<text>"` — attach an open question for a human to the draft it
33
+ # concerns (Fizzy #1786).
34
+ #
35
+ # An agent writing a batch of drafts usually has questions only a human can answer:
36
+ # a limit the source never set, a role it didn't name. Left in the chat, they're
37
+ # detached from the drafts. Asked here, the human reviewing the draft in Quick Review
38
+ # sees them beside it, and answers or dismisses each one there. Agents can ask but
39
+ # not answer; the web app is where a human resolves them.
40
+ class Question < Base
41
+ include QuestionSupport
42
+
43
+ KNOWN_FLAGS = { project: :string, body: :string }.freeze
44
+
45
+ help_summary 'Attach an open question for a human to a draft requirement'
46
+ help_usage 'fp question <slug> "<question>" [--project <slug>]'
47
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
48
+ help_flag :body, 'The question text (instead of the second argument)'
49
+ help_example 'fp question upload_limits "Is the 5 MB cap per file or per upload?"'
50
+ help_example 'fp questions upload_limits # list open and resolved questions'
51
+
52
+ def run(args)
53
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
54
+ require_project!(opts)
55
+
56
+ slug = positional.first
57
+ unless slug
58
+ output.error('Requirement slug is required. Usage: fp question <slug> "<question>" [--project <project>]')
59
+ exit 1
60
+ end
61
+
62
+ text = opts[:body] || positional[1..].join(' ')
63
+ if text.nil? || text.strip.empty?
64
+ output.error("A question needs its text. Usage: fp question #{slug} \"<question>\" " \
65
+ "(run `fp questions #{slug}` to list them)")
66
+ exit 1
67
+ end
68
+
69
+ project_id = resolve_project_id(opts[:project])
70
+ requirement = find_requirement!(project_id, slug)
71
+
72
+ result = client.ask_requirement_question(requirement['id'], text.strip)
73
+ unless result[:ok]
74
+ output.error(result[:error], status: result[:status])
75
+ exit 1
76
+ end
77
+
78
+ output.success(result[:data]) do
79
+ open_count = result[:data]['open_count']
80
+ puts "Asked on '#{slug}': #{result[:data].dig('question', 'text')}"
81
+ puts
82
+ puts "#{open_count} open question#{'s' unless open_count == 1} on this requirement." if open_count
83
+ puts 'A human reviewing it in Quick Review sees the question and answers or dismisses it there.'
84
+ puts "Run `fp questions #{slug}` to read the answers."
85
+ end
86
+ end
87
+ end
88
+
89
+ # `fp questions <slug>` — the questions asked on a requirement, open and resolved.
90
+ class Questions < Base
91
+ include QuestionSupport
92
+
93
+ KNOWN_FLAGS = { project: :string, open: :boolean }.freeze
94
+
95
+ help_summary "List a requirement's questions for a human, with their answers"
96
+ help_usage 'fp questions <slug> [--open] [--project <slug>]'
97
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
98
+ help_flag :open, 'Show only the questions still open'
99
+ help_example 'fp questions upload_limits'
100
+ help_example 'fp questions upload_limits --open --json'
101
+
102
+ def run(args)
103
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
104
+ require_project!(opts)
105
+
106
+ slug = positional.first
107
+ unless slug
108
+ output.error('Requirement slug is required. Usage: fp questions <slug> [--project <project>]')
109
+ exit 1
110
+ end
111
+
112
+ project_id = resolve_project_id(opts[:project])
113
+ requirement = find_requirement!(project_id, slug)
114
+
115
+ result = client.list_requirement_questions(requirement['id'])
116
+ unless result[:ok]
117
+ output.error(result[:error], status: result[:status])
118
+ exit 1
119
+ end
120
+
121
+ questions = result[:data]['questions'] || []
122
+ questions = questions.select { |q| q['status'] == 'open' } if opts[:open]
123
+ payload = { 'requirement' => requirement, 'questions' => questions,
124
+ 'open_count' => result[:data]['open_count'] }
125
+
126
+ output.success(payload) do
127
+ puts "Questions on '#{slug}':"
128
+ if questions.empty?
129
+ puts opts[:open] ? ' (none open)' : ' (none asked)'
130
+ else
131
+ questions.each { |q| print_question(q) }
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -36,7 +36,7 @@ module Fp
36
36
  help_flag :ci_url, 'CI run URL'
37
37
  help_flag :title, 'Human-readable example/test name to display'
38
38
  help_flag :state, 'Evidence state: present or stub (default present)'
39
- help_flag :junit, 'JUnit XML report to batch-upload evidence from'
39
+ help_flag :junit, 'JUnit XML report (or RSpec `--format json` output) to batch-upload evidence from'
40
40
  help_flag :base_dir, 'Where relative test-file paths in the report resolve from'
41
41
  help_flag :attach_media, 'With --junit: upload the videos/screenshots the run recorded ([[ATTACHMENT|…]]) as visual evidence'
42
42
  help_flag :stamp_all, 'With --junit: put --pr/--work-item on every receipt, not only on tests in files this branch changed'
@@ -124,6 +124,7 @@ module Fp
124
124
  params[:line] = opts[:line].to_i if opts[:line]
125
125
 
126
126
  # Try to report evidence
127
+ warn_if_not_production!
127
128
  result = client.report_evidence(params)
128
129
 
129
130
  # Evidence API may not exist yet (#1214)
@@ -288,6 +289,7 @@ module Fp
288
289
  # on requirements the branch never touched — "View Card" on an unrelated
289
290
  # requirement led to whichever card last ran the suite (Fizzy #1631). Only the
290
291
  # tests in files this branch changed get them; the rest keep their own.
292
+ warn_if_not_production!
291
293
  provenance = provenance_scope(opts, base_dir)
292
294
  reported = []
293
295
  skipped_unknown = []
@@ -504,7 +506,8 @@ module Fp
504
506
  stamping = !opts[:pr].nil? || !opts[:work_item].nil?
505
507
  data = {
506
508
  repo: opts[:repo],
507
- reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded], stamped: (stamping ? r[:stamped] != false : nil), stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]) }.compact },
509
+ reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded], stamped: (stamping ? r[:stamped] != false : nil), stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]), tests: binding_tests(r[:binding]) }.compact },
510
+ tests_bound: reported.sum { |r| binding_tests(r[:binding]).length },
508
511
  provenance: (stamping ? { scoped: provenance.scoped?, mode: provenance.mode.to_s, stamped: reported.count { |r| r[:stamped] != false } } : nil),
509
512
  skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
510
513
  unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } },
@@ -515,7 +518,8 @@ module Fp
515
518
  }
516
519
 
517
520
  output.success(data) do
518
- puts "Reported evidence from #{opts[:junit]}:"
521
+ tests_bound = reported.sum { |r| binding_tests(r[:binding]).length }
522
+ puts "Reported evidence from #{opts[:junit]}: #{tests_bound} bound test(s) → #{reported.length} receipt(s)."
519
523
  reported.each do |r|
520
524
  superseded = ci_superseded?(r[:state], r[:stored_status], r[:stored_source])
521
525
  marker = if !r[:recorded]
@@ -530,6 +534,15 @@ module Fp
530
534
  shown_state = superseded ? "#{r[:stored_status]}, kept CI — #{r[:state]} ignored" : r[:state]
531
535
  stamp = stamping && provenance.scoped? && r[:stamped] ? ' ← this change' : ''
532
536
  puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface}#{type} (#{shown_state}) #{loc}#{stamp}"
537
+ tests = binding_tests(r[:binding])
538
+ next if tests.length < 2
539
+
540
+ puts " #{tests.length} tests share this receipt (one per requirement, surface, test type and file):"
541
+ tests.each do |t|
542
+ at = t[:line] ? ":#{t[:line]}" : ''
543
+ skipped = t[:state] == 'stub' ? ' (skipped)' : ''
544
+ puts " - #{t[:name] || '(unnamed)'} #{r[:binding].file}#{at}#{skipped}"
545
+ end
533
546
  end
534
547
 
535
548
  if stamping
@@ -589,6 +602,13 @@ module Fp
589
602
  end
590
603
  end
591
604
 
605
+ # The testcases that fed a receipt. Several tests proving one requirement is normal;
606
+ # the server keeps one row per cell, so the summary names each of them rather than
607
+ # leaving the reader to guess whether the rest were dropped.
608
+ def binding_tests(binding)
609
+ binding.tests || [{ name: binding.title, line: binding.line, state: binding.state }]
610
+ end
611
+
592
612
  # Warn when two or more distinct test files in this run resolved to the *same*
593
613
  # evidence cell. Server-side identity is (project, slug, surface, repo, test_type) —
594
614
  # the file isn't part of it — so those reports upsert the same row and the last one
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+
6
+ module Fp
7
+ module Commands
8
+ # fp run [report flags] -- <test command...>
9
+ #
10
+ # Runs the test suite with a report formatter fp adds itself, then reports it exactly
11
+ # as `fp report --junit <that report>` would (Fizzy #1786). Getting a report out of a
12
+ # suite usually meant adding a formatter gem or package to someone else's project
13
+ # (rspec_junit_formatter, say) before the first `fp report` could run; every runner
14
+ # here can write one with options it already has, so nothing in the project changes.
15
+ #
16
+ # rspec --format json --out <tmp> (+ --format progress when none was given)
17
+ # pytest -o junit_family=xunit1 --junitxml=<tmp> (xunit1 carries file and line)
18
+ # vitest --reporter=default --reporter=junit --outputFile.junit=<tmp>
19
+ # playwright --reporter=list,junit with PLAYWRIGHT_JUNIT_OUTPUT_NAME=<tmp>
20
+ #
21
+ # It *is* `fp report`: same flags, same present/stub-only rule. The test command's
22
+ # own exit status wins, after reporting, so a red suite still fails the caller.
23
+ class Run < Base
24
+ help_summary 'Run a test command with a report formatter added, then fp report its results'
25
+ help_usage 'fp run [--surface X] [--repo org/repo] [fp report flags] -- <test command...>'
26
+ help_flag :surface, 'Surface the evidence is for (default for markers that pin none)'
27
+ help_flag :repo, 'Repository the tests live in (org/repo)', required: true
28
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
29
+ help_flag :test_type, "Kind of test (project's vocabulary, e.g. unit, e2e)"
30
+ help_flag :work_item, 'Work-item URL that prompted the work'
31
+ help_flag :pr, 'Pull request URL'
32
+ help_flag :base_dir, 'Where relative test-file paths in the report resolve from'
33
+ help_example 'fp run --surface api --repo acme/shop -- bundle exec rspec'
34
+ help_example 'fp run --surface web --repo acme/shop -- npx vitest run'
35
+ help_example 'fp run --surface api --repo acme/shop -- pytest tests/'
36
+ help_example 'fp run --surface web --repo acme/shop -- npx playwright test'
37
+ help_note "Supports rspec, pytest, vitest and playwright. Any other runner: emit JUnit XML yourself\n" \
38
+ "and use `fp report --junit <file>`. Takes every `fp report` flag; agents still report\n" \
39
+ "present/stub only. Exits with the test command's status when it failed."
40
+
41
+ RUNNERS = %w[rspec pytest vitest playwright].freeze
42
+
43
+ def run(args)
44
+ split = args.index('--')
45
+ report_args = split ? args[0...split] : args
46
+ command = split ? args[(split + 1)..] : []
47
+
48
+ if command.empty?
49
+ output.error('Give the test command after `--`, e.g. fp run --surface api --repo org/repo -- bundle exec rspec')
50
+ exit 1
51
+ end
52
+ if report_args.include?('--junit') || report_args.any? { |a| a.start_with?('--junit=') }
53
+ output.error('fp run writes its own report; drop --junit (or use `fp report --junit <file>` instead).')
54
+ exit 1
55
+ end
56
+
57
+ runner = self.class.detect_runner(command)
58
+ unless runner
59
+ output.error("fp run doesn't recognise the runner in `#{command.join(' ')}`. " \
60
+ "Supported: #{RUNNERS.join(', ')}. For anything else, make the runner write " \
61
+ 'JUnit XML and run `fp report --junit <file>`.')
62
+ exit 1
63
+ end
64
+
65
+ tmp = Dir.mktmpdir('fp-run-')
66
+ begin
67
+ report_path, argv, env = self.class.inject(runner, command, tmp)
68
+ warn "fp run: #{argv.join(' ')}"
69
+ test_status = execute(env, argv)
70
+ if test_status.nil?
71
+ output.error("Could not start `#{argv.first}`: is it installed and on PATH?")
72
+ exit 127
73
+ end
74
+
75
+ unless File.file?(report_path) && File.size(report_path).positive?
76
+ output.error("The test command exited #{test_status} without writing a report, so there is nothing to report.")
77
+ exit(test_status.zero? ? 1 : test_status)
78
+ end
79
+
80
+ report_status = report(report_args, report_path)
81
+ exit test_status unless test_status.zero?
82
+ exit report_status unless report_status.zero?
83
+ ensure
84
+ FileUtils.rm_rf(tmp)
85
+ end
86
+ end
87
+
88
+ # Which supported runner a command invokes, by its words: `bundle exec rspec`,
89
+ # `bin/rspec`, `python -m pytest`, `npx vitest run`, `pnpm exec playwright test`.
90
+ def self.detect_runner(command)
91
+ command.each_with_index do |word, i|
92
+ base = File.basename(word)
93
+ return 'rspec' if base == 'rspec'
94
+ return 'pytest' if %w[pytest py.test].include?(base)
95
+ return 'vitest' if base == 'vitest'
96
+ return 'playwright' if base == 'playwright' && command[i + 1] == 'test'
97
+ end
98
+ nil
99
+ end
100
+
101
+ # [report path, argv to run, extra env] for a runner.
102
+ def self.inject(runner, command, tmp)
103
+ case runner
104
+ when 'rspec'
105
+ path = File.join(tmp, 'rspec.json')
106
+ formatted = command.any? { |a| a.start_with?('--format') || a.start_with?('-f') }
107
+ extra = ['--format', 'json', '--out', path]
108
+ extra += ['--format', 'progress'] unless formatted
109
+ [path, command + extra, {}]
110
+ when 'pytest'
111
+ path = File.join(tmp, 'junit.xml')
112
+ [path, command + ['-o', 'junit_family=xunit1', "--junitxml=#{path}"], {}]
113
+ when 'vitest'
114
+ path = File.join(tmp, 'junit.xml')
115
+ [path, command + ['--reporter=default', '--reporter=junit', "--outputFile.junit=#{path}"], {}]
116
+ when 'playwright'
117
+ path = File.join(tmp, 'junit.xml')
118
+ [path, command + ['--reporter=list,junit'], { 'PLAYWRIGHT_JUNIT_OUTPUT_NAME' => path }]
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ # The command's exit status, or nil when it couldn't be started.
125
+ def execute(env, argv)
126
+ ok = system(env, *argv)
127
+ return nil if ok.nil?
128
+
129
+ $?.exitstatus || 1
130
+ end
131
+
132
+ # `fp report --junit` with the caller's flags; its own exit status (0 on success).
133
+ def report(report_args, report_path)
134
+ # The report sits in a temp dir, so `fp report` can't find the project from it the
135
+ # way it does beside a checked-in report. The checkout we ran in says which it is.
136
+ unless report_args.include?('--project') || report_args.any? { |a| a.start_with?('--project=') }
137
+ project = ProjectConfig.discover(Dir.pwd).project
138
+ report_args += ['--project', project] if project
139
+ end
140
+ Report.new(client: client, output: output, config: config).run(report_args + ['--junit', report_path])
141
+ 0
142
+ rescue SystemExit => e
143
+ e.status
144
+ end
145
+ end
146
+ end
147
+ end
@@ -44,12 +44,17 @@ module Fp
44
44
  # Agents only ever speak slugs, so translate the parent's ID back into one.
45
45
  parent_slug = resolve_parent_slug(project_id, requirement['parent_id'])
46
46
 
47
+ # Open questions for a human (Fizzy #1786), read only when the row says it has some.
48
+ open_questions = fetch_open_questions(requirement)
49
+
47
50
  payload = result[:data].merge('evidence' => evidence, 'attachments' => attachments)
48
51
  payload['parent_slug'] = parent_slug if parent_slug
52
+ payload['open_questions'] = open_questions unless open_questions.empty?
49
53
 
50
54
  output.success(payload) do
51
55
  print_requirement(requirement, evidence: evidence, attachments: attachments,
52
56
  parent_slug: parent_slug, source_history: source_history)
57
+ print_open_questions(open_questions, requirement['slug'])
53
58
  end
54
59
  end
55
60
 
@@ -68,6 +73,27 @@ module Fp
68
73
  end
69
74
  end
70
75
 
76
+ def fetch_open_questions(requirement)
77
+ return [] unless requirement['open_question_count'].to_f.positive?
78
+
79
+ result = client.list_requirement_questions(requirement['id'])
80
+ return [] unless result[:ok]
81
+
82
+ (result[:data]['questions'] || []).select { |q| q['status'] == 'open' }
83
+ end
84
+
85
+ def print_open_questions(questions, slug)
86
+ return if questions.empty?
87
+
88
+ puts
89
+ puts 'Open Questions (for a human):'
90
+ questions.each do |q|
91
+ puts " - #{q['text']}"
92
+ puts " asked by #{q['asked_by']} at #{q['asked_at']}"
93
+ end
94
+ puts " (fp questions #{slug} lists answered ones too)"
95
+ end
96
+
71
97
  def fetch_attachments(requirement_id)
72
98
  return [] if requirement_id.to_s.empty?
73
99
 
@@ -146,6 +172,7 @@ module Fp
146
172
  line << src['url'] if src['url']
147
173
  puts " - #{line.join(' ')}"
148
174
  puts " #{src['context']}" if src['context']
175
+ Array(src['refs']).each { |ref| puts " read from #{ref}" }
149
176
  end
150
177
  end
151
178
  puts
data/lib/fp/commands.rb CHANGED
@@ -9,7 +9,9 @@ require_relative 'commands/list'
9
9
  require_relative 'commands/show'
10
10
  require_relative 'commands/propose'
11
11
  require_relative 'commands/propose_change'
12
+ require_relative 'commands/mark'
12
13
  require_relative 'commands/comment'
14
+ require_relative 'commands/question'
13
15
  require_relative 'commands/comments'
14
16
  require_relative 'commands/history'
15
17
  require_relative 'commands/analyze'
@@ -19,8 +21,11 @@ require_relative 'commands/ci_report'
19
21
  require_relative 'commands/changed_tests'
20
22
  require_relative 'commands/spot_check'
21
23
  require_relative 'commands/check'
24
+ require_relative 'commands/coverage'
22
25
  require_relative 'commands/init'
23
26
  require_relative 'commands/tutorial'
27
+ require_relative 'commands/doctor'
28
+ require_relative 'commands/run'
24
29
  require_relative 'commands/matrix'
25
30
  require_relative 'commands/export'
26
31
  require_relative 'commands/profile'
data/lib/fp/junit.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'rexml/document'
4
4
  require 'pathname'
5
5
  require_relative 'source_fingerprint'
6
+ require_relative 'rspec_json'
6
7
 
7
8
  module Fp
8
9
  # Parses JUnit XML reports and binds testcases to fp:<slug> markers found in
@@ -83,7 +84,7 @@ module Fp
83
84
  # testcase's own line when the runner reported one, otherwise the line of
84
85
  # the fp:<slug> marker that annotates it (markers sit directly above the
85
86
  # test, so the marker line is a close stand-in when the runner omits lines).
86
- Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, keyword_init: true)
87
+ Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, :tests, keyword_init: true)
87
88
 
88
89
  # A media file (video/screenshot) a run recorded for one bound testcase, ready for
89
90
  # `fp report --junit --attach-media` to upload as visual evidence. Unlike Binding,
@@ -120,17 +121,51 @@ module Fp
120
121
 
121
122
  module_function
122
123
 
123
- # Parse a JUnit XML file into an array of TestCase structs.
124
+ # Parse a test report into an array of TestCase structs: JUnit XML, or RSpec's
125
+ # built-in `--format json` output (Fizzy #1786), told apart by content so every
126
+ # `--junit` flag takes either with no new option. RSpec's JSON needs no extra gem,
127
+ # where JUnit from RSpec needs `rspec_junit_formatter` in the project's Gemfile.
124
128
  # Raises ArgumentError if the file is missing or unparseable.
125
129
  def parse_file(path)
126
130
  raise ArgumentError, "JUnit file not found: #{path}" unless File.file?(path)
127
131
 
128
- xml = File.read(path)
129
- parse_string(xml)
132
+ content = File.read(path)
133
+ return RspecJson.parse(content, path) if RspecJson.json?(content)
134
+
135
+ parse_string(content)
130
136
  rescue REXML::ParseException => e
131
137
  raise ArgumentError, "Could not parse JUnit XML (#{path}): #{e.message}"
132
138
  end
133
139
 
140
+ # The report as JUnit XML, whatever format the file is in. The CLI resolves markers
141
+ # itself, but ci-report and check also send the raw report for servers old enough to
142
+ # re-resolve it, and those only read XML.
143
+ def read_xml(path)
144
+ content = File.read(path)
145
+ return content unless RspecJson.json?(content)
146
+
147
+ to_xml(RspecJson.parse(content, path))
148
+ end
149
+
150
+ # Render parsed testcases as a minimal JUnit document.
151
+ def to_xml(testcases)
152
+ doc = REXML::Document.new
153
+ doc << REXML::XMLDecl.new('1.0', 'UTF-8')
154
+ suite = doc.add_element('testsuites').add_element('testsuite', 'name' => 'fp',
155
+ 'tests' => testcases.length.to_s)
156
+ testcases.each do |tc|
157
+ attrs = { 'name' => tc.name.to_s, 'classname' => tc.classname.to_s }
158
+ attrs['file'] = tc.file if tc.file
159
+ attrs['line'] = tc.line.to_s if tc.line
160
+ el = suite.add_element('testcase', attrs)
161
+ el.add_element('skipped') if tc.stub?
162
+ el.add_element('failure') if tc.failed?
163
+ end
164
+ out = +''
165
+ doc.write(out)
166
+ out
167
+ end
168
+
134
169
  def parse_string(xml)
135
170
  doc = REXML::Document.new(xml)
136
171
  cases = []
@@ -144,7 +179,7 @@ module Fp
144
179
  name: el.attributes['name'],
145
180
  classname: classname,
146
181
  file: resolve_file(el.attributes['file'], classname, suite_name),
147
- line: (el.attributes['line'] && el.attributes['line'].to_i),
182
+ line: testcase_line(el, suite_name),
148
183
  status: testcase_status(el),
149
184
  failed: testcase_failed?(el),
150
185
  attachments: extract_attachments(el)
@@ -154,6 +189,16 @@ module Fp
154
189
  cases
155
190
  end
156
191
 
192
+ # pytest's `line` (written with junit_family=xunit1, the only family that carries a
193
+ # file) counts from 0, so a `def` directly under its marker would report the marker's
194
+ # own line and bind to the marker above that. Its suites are named "pytest".
195
+ def testcase_line(el, suite_name)
196
+ line = el.attributes['line']&.to_i
197
+ return line unless line && suite_name == 'pytest' && el.attributes['file'].to_s.end_with?('.py')
198
+
199
+ line + 1
200
+ end
201
+
157
202
  # Playwright's JUnit reporter records media (video, screenshots, trace) by writing
158
203
  # `[[ATTACHMENT|<path>]]` lines into the testcase's `<system-out>`. Pull those paths
159
204
  # out so `fp report --junit --attach-media` can upload them as visual evidence for the
@@ -600,12 +645,20 @@ module Fp
600
645
  # omitted line info.
601
646
  line = testcase.line || (marker && marker[:line])
602
647
 
648
+ # Every testcase that fed this receipt, so `fp report` can say "3 tests → one
649
+ # receipt" instead of printing one line and leaving the other two unaccounted for.
650
+ entry = { name: presence(testcase.name), line: line, state: state }
651
+
603
652
  existing = bindings[key]
604
653
  # Prefer a concrete 'present' state over 'stub' when a slug has both.
605
- return if existing && !(existing.state == 'stub' && state == 'present')
654
+ if existing && !(existing.state == 'stub' && state == 'present')
655
+ existing.tests << entry
656
+ return
657
+ end
606
658
 
607
659
  bindings[key] = Binding.new(slug: slug, surface: surface, test_type: test_type, file: rel_file,
608
- line: line, title: (testcase.name if titles_reliable), state: state)
660
+ line: line, title: (testcase.name if titles_reliable), state: state,
661
+ tests: (existing ? existing.tests : []) + [entry])
609
662
  end
610
663
 
611
664
  # Load and cache the fp:<slug> markers (with line numbers) for an already-resolved