featureparity 0.0.28 → 0.0.29
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/cli.rb +11 -2
- data/lib/fp/client.rb +26 -0
- data/lib/fp/commands/base.rb +14 -0
- data/lib/fp/commands/check.rb +38 -4
- data/lib/fp/commands/ci_report.rb +2 -2
- data/lib/fp/commands/comment.rb +1 -1
- data/lib/fp/commands/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +13 -1
- data/lib/fp/commands/history.rb +4 -0
- data/lib/fp/commands/init.rb +172 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +1 -0
- data/lib/fp/commands/propose.rb +401 -52
- data/lib/fp/commands/propose_change.rb +7 -1
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +23 -3
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +27 -0
- data/lib/fp/commands.rb +5 -0
- data/lib/fp/junit.rb +60 -7
- data/lib/fp/repo_detector.rb +488 -0
- data/lib/fp/requirement_lint.rb +105 -0
- data/lib/fp/rspec_json.rb +54 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +244 -105
- data/skills/fp/references/cli.md +231 -3
- metadata +9 -1
|
@@ -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
|
data/lib/fp/commands/report.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|