featureparity 0.0.27 → 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 +14 -2
- data/lib/fp/client.rb +79 -2
- data/lib/fp/commands/analyze.rb +17 -0
- data/lib/fp/commands/base.rb +16 -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/comments.rb +146 -0
- data/lib/fp/commands/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +41 -1
- data/lib/fp/commands/history.rb +16 -1
- data/lib/fp/commands/init.rb +181 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +6 -1
- data/lib/fp/commands/propose.rb +435 -53
- data/lib/fp/commands/propose_change.rb +13 -7
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +40 -4
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +41 -3
- data/lib/fp/commands/spot_check.rb +458 -0
- data/lib/fp/commands/tutorial.rb +95 -0
- data/lib/fp/commands/whoami.rb +2 -0
- data/lib/fp/commands.rb +8 -0
- data/lib/fp/config.rb +12 -0
- data/lib/fp/junit.rb +74 -11
- 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/tutorial/hexhaven/AGENTS.md +5 -0
- data/lib/fp/tutorial/hexhaven/README.md +29 -0
- data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
- data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -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 +304 -3
- metadata +19 -2
|
@@ -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)
|
|
@@ -208,7 +209,10 @@ module Fp
|
|
|
208
209
|
|
|
209
210
|
# Batch-upload evidence from a JUnit XML report.
|
|
210
211
|
def run_junit(opts)
|
|
211
|
-
|
|
212
|
+
# The report's own checkout says which project it is: discovering from the
|
|
213
|
+
# report's directory (and up) means `fp report --junit hexhaven/junit.xml` run
|
|
214
|
+
# from a parent folder reads hexhaven's .featureparity.yml, not the parent's.
|
|
215
|
+
require_project!(opts, ProjectConfig.discover(File.dirname(File.expand_path(opts[:junit].to_s))))
|
|
212
216
|
require_flag(opts, :repo)
|
|
213
217
|
# --surface is the default for markers that don't pin their own surface
|
|
214
218
|
# (e.g. `fp:slug@api,web`). It's only strictly required when at least one
|
|
@@ -244,6 +248,19 @@ module Fp
|
|
|
244
248
|
result = Fp::JUnit.bind_markers(testcases, base_dir: base_dir,
|
|
245
249
|
default_surface: opts[:surface],
|
|
246
250
|
default_test_type: opts[:test_type])
|
|
251
|
+
# Run from outside the checkout, paths in the report resolve from nowhere. When
|
|
252
|
+
# nothing binds from here and no --base-dir was given, try the report's own
|
|
253
|
+
# directory before giving up.
|
|
254
|
+
report_dir = File.dirname(File.expand_path(opts[:junit].to_s))
|
|
255
|
+
if result[:bindings].empty? && opts[:base_dir].nil? && report_dir != File.expand_path(base_dir)
|
|
256
|
+
retried = Fp::JUnit.bind_markers(testcases, base_dir: report_dir,
|
|
257
|
+
default_surface: opts[:surface],
|
|
258
|
+
default_test_type: opts[:test_type])
|
|
259
|
+
if retried[:bindings].any?
|
|
260
|
+
base_dir = report_dir
|
|
261
|
+
result = retried
|
|
262
|
+
end
|
|
263
|
+
end
|
|
247
264
|
bindings = result[:bindings]
|
|
248
265
|
unmatched = result[:unmatched]
|
|
249
266
|
missing_surface = result[:missing_surface] || []
|
|
@@ -272,6 +289,7 @@ module Fp
|
|
|
272
289
|
# on requirements the branch never touched — "View Card" on an unrelated
|
|
273
290
|
# requirement led to whichever card last ran the suite (Fizzy #1631). Only the
|
|
274
291
|
# tests in files this branch changed get them; the rest keep their own.
|
|
292
|
+
warn_if_not_production!
|
|
275
293
|
provenance = provenance_scope(opts, base_dir)
|
|
276
294
|
reported = []
|
|
277
295
|
skipped_unknown = []
|
|
@@ -488,7 +506,8 @@ module Fp
|
|
|
488
506
|
stamping = !opts[:pr].nil? || !opts[:work_item].nil?
|
|
489
507
|
data = {
|
|
490
508
|
repo: opts[:repo],
|
|
491
|
-
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 },
|
|
492
511
|
provenance: (stamping ? { scoped: provenance.scoped?, mode: provenance.mode.to_s, stamped: reported.count { |r| r[:stamped] != false } } : nil),
|
|
493
512
|
skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
|
|
494
513
|
unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } },
|
|
@@ -499,7 +518,8 @@ module Fp
|
|
|
499
518
|
}
|
|
500
519
|
|
|
501
520
|
output.success(data) do
|
|
502
|
-
|
|
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)."
|
|
503
523
|
reported.each do |r|
|
|
504
524
|
superseded = ci_superseded?(r[:state], r[:stored_status], r[:stored_source])
|
|
505
525
|
marker = if !r[:recorded]
|
|
@@ -514,6 +534,15 @@ module Fp
|
|
|
514
534
|
shown_state = superseded ? "#{r[:stored_status]}, kept CI — #{r[:state]} ignored" : r[:state]
|
|
515
535
|
stamp = stamping && provenance.scoped? && r[:stamped] ? ' ← this change' : ''
|
|
516
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
|
|
517
546
|
end
|
|
518
547
|
|
|
519
548
|
if stamping
|
|
@@ -573,6 +602,13 @@ module Fp
|
|
|
573
602
|
end
|
|
574
603
|
end
|
|
575
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
|
+
|
|
576
612
|
# Warn when two or more distinct test files in this run resolved to the *same*
|
|
577
613
|
# evidence cell. Server-side identity is (project, slug, surface, repo, test_type) —
|
|
578
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
|
|
|
@@ -59,7 +64,34 @@ module Fp
|
|
|
59
64
|
result = client.list_evidence(project_id, slug: slug)
|
|
60
65
|
return [] unless result[:ok]
|
|
61
66
|
|
|
62
|
-
|
|
67
|
+
# The server names the receipts CI has since spoken for; flag them on the rows so
|
|
68
|
+
# both the listing and --json say so, rather than a stale `present` reading as a
|
|
69
|
+
# second verdict beside CI's (Fizzy #1762).
|
|
70
|
+
superseded = Array(result[:data]['superseded_ids'])
|
|
71
|
+
(result[:data]['evidence'] || []).map do |e|
|
|
72
|
+
superseded.include?(e['id']) ? e.merge('superseded' => true) : e
|
|
73
|
+
end
|
|
74
|
+
end
|
|
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)"
|
|
63
95
|
end
|
|
64
96
|
|
|
65
97
|
def fetch_attachments(requirement_id)
|
|
@@ -140,6 +172,7 @@ module Fp
|
|
|
140
172
|
line << src['url'] if src['url']
|
|
141
173
|
puts " - #{line.join(' ')}"
|
|
142
174
|
puts " #{src['context']}" if src['context']
|
|
175
|
+
Array(src['refs']).each { |ref| puts " read from #{ref}" }
|
|
143
176
|
end
|
|
144
177
|
end
|
|
145
178
|
puts
|
|
@@ -156,8 +189,13 @@ module Fp
|
|
|
156
189
|
.group_by { |e| e['surface'] }
|
|
157
190
|
.each do |surface, records|
|
|
158
191
|
puts " #{surface}:"
|
|
159
|
-
|
|
160
|
-
|
|
192
|
+
# The receipts the matrix counts first; superseded ones after.
|
|
193
|
+
records.partition { |e| !e['superseded'] }.flatten(1).each do |e|
|
|
194
|
+
if e['superseded']
|
|
195
|
+
puts " Status: superseded by CI (was #{e['status']}; CI's latest run is what counts)"
|
|
196
|
+
else
|
|
197
|
+
puts " Status: #{e['status']}"
|
|
198
|
+
end
|
|
161
199
|
# A pull request's CI may have written over the merged verdict; show both
|
|
162
200
|
# when they differ so "failing" next to "PR: …" reads as the branch's
|
|
163
201
|
# result, not the default branch's.
|