featureparity 0.0.20 → 0.0.22
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 +40 -0
- data/lib/fp/client.rb +34 -0
- data/lib/fp/commands/analyze.rb +10 -0
- data/lib/fp/commands/attach.rb +109 -39
- data/lib/fp/commands/attachments.rb +6 -0
- data/lib/fp/commands/base.rb +136 -0
- data/lib/fp/commands/changed_tests.rb +12 -0
- data/lib/fp/commands/changes.rb +9 -0
- data/lib/fp/commands/check.rb +41 -3
- data/lib/fp/commands/ci_report.rb +17 -0
- data/lib/fp/commands/comment.rb +10 -0
- data/lib/fp/commands/config_cmd.rb +9 -0
- data/lib/fp/commands/conversation.rb +5 -0
- data/lib/fp/commands/export.rb +10 -0
- data/lib/fp/commands/help.rb +81 -5
- data/lib/fp/commands/history.rb +20 -1
- data/lib/fp/commands/init.rb +50 -1
- data/lib/fp/commands/list.rb +12 -0
- data/lib/fp/commands/matrix.rb +9 -0
- data/lib/fp/commands/profile.rb +9 -0
- data/lib/fp/commands/projects.rb +63 -3
- data/lib/fp/commands/propose.rb +18 -0
- data/lib/fp/commands/propose_change.rb +73 -2
- data/lib/fp/commands/report.rb +157 -7
- data/lib/fp/commands/repos.rb +11 -0
- data/lib/fp/commands/setup.rb +11 -0
- data/lib/fp/commands/show.rb +42 -3
- data/lib/fp/commands/showroom.rb +141 -0
- data/lib/fp/commands/surfaces.rb +258 -0
- data/lib/fp/commands/version.rb +3 -0
- data/lib/fp/commands/whoami.rb +6 -0
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/junit.rb +213 -18
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +27 -0
- data/skills/fp/references/cli.md +52 -2
- metadata +3 -2
|
@@ -10,11 +10,36 @@ module Fp
|
|
|
10
10
|
# asked for, and what prompted the ask. The requirement drops back to draft until a
|
|
11
11
|
# human reviews the diff — agents never approve their own changes.
|
|
12
12
|
class ProposeChange < Base
|
|
13
|
+
help_summary 'Propose a change to an existing requirement (or --removal to retire it)'
|
|
14
|
+
help_usage 'fp propose-change <slug> --project <slug> [--name/--why/--acceptance ...] --reason "..."',
|
|
15
|
+
'fp propose-change <slug> --removal --reason "..."'
|
|
16
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
17
|
+
help_flag :name, 'Proposed new title'
|
|
18
|
+
help_flag :why, 'Proposed new Why'
|
|
19
|
+
help_flag :acceptance, 'Proposed new acceptance criteria'
|
|
20
|
+
help_flag :required, 'Proposed new required surfaces (comma-separated; cannot remove a surface)'
|
|
21
|
+
help_flag :required_types, 'Proposed new required test types (comma-separated)'
|
|
22
|
+
help_flag :category, 'Proposed new category: functional, non-functional, ux, performance, security, compliance'
|
|
23
|
+
help_flag :parent, "Set the parent requirement's slug"
|
|
24
|
+
help_flag :no_parent, 'Propose un-nesting the requirement back to the top level (clears --parent)'
|
|
25
|
+
help_flag :slug, 'Proposed new slug (evidence follows; markers still need updating)'
|
|
26
|
+
help_flag :reason, 'What prompted this change (use this, not --source-*, to record the prompt)'
|
|
27
|
+
help_flag :removal, 'Propose deleting the requirement instead of changing it'
|
|
28
|
+
help_flag :withdraw, 'Take back the open proposal you authored, restoring the requirement as it was'
|
|
29
|
+
help_flag :source_type, "Correct the requirement's recorded origin type (not the change's prompt)"
|
|
30
|
+
help_flag :source_url, "Correct the requirement's recorded origin URL"
|
|
31
|
+
help_flag :source_context, "Correct the requirement's recorded origin context"
|
|
32
|
+
help_example 'fp propose-change print_qr --project stowzilla --acceptance "QR scans after 24h at -20C" --reason "Cold-chain rollout"'
|
|
33
|
+
help_example 'fp propose-change print_qr --removal --reason "Superseded by print_container_label"'
|
|
34
|
+
help_note "Applies the change and drops the requirement to draft for a human to review. Use --reason\n" \
|
|
35
|
+
"to say what prompted it; --source-* overwrite the requirement's own origin fields."
|
|
36
|
+
|
|
13
37
|
KNOWN_FLAGS = {
|
|
14
38
|
project: :string, name: :string, why: :string, acceptance: :string,
|
|
15
39
|
required: :string, required_types: :string, category: :string, parent: :string, slug: :string,
|
|
40
|
+
no_parent: :boolean,
|
|
16
41
|
reason: :string, source_type: :string, source_url: :string, source_context: :string,
|
|
17
|
-
removal: :boolean
|
|
42
|
+
removal: :boolean, withdraw: :boolean
|
|
18
43
|
}.freeze
|
|
19
44
|
|
|
20
45
|
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
@@ -39,17 +64,21 @@ module Fp
|
|
|
39
64
|
exit 1
|
|
40
65
|
end
|
|
41
66
|
|
|
67
|
+
validate_flag_combinations!(opts)
|
|
42
68
|
validate_enums!(opts)
|
|
43
69
|
|
|
44
70
|
project_id = resolve_project_id(opts[:project])
|
|
45
71
|
requirement = find_requirement!(project_id, slug)
|
|
46
72
|
|
|
73
|
+
return withdraw!(requirement, slug) if opts[:withdraw]
|
|
74
|
+
|
|
47
75
|
params = build_params(opts, project_id)
|
|
48
76
|
removal = opts[:removal] ? true : false
|
|
49
77
|
|
|
50
78
|
if !removal && params.keys.none? { |k| proposed_field?(k) }
|
|
51
79
|
output.error('Nothing to propose. Pass at least one of --name/--slug/--why/--acceptance/' \
|
|
52
|
-
'--required/--required-types/--category/--parent, or --removal to
|
|
80
|
+
'--required/--required-types/--category/--parent/--no-parent, or --removal to ' \
|
|
81
|
+
'propose deletion.')
|
|
53
82
|
exit 1
|
|
54
83
|
end
|
|
55
84
|
|
|
@@ -68,6 +97,31 @@ module Fp
|
|
|
68
97
|
|
|
69
98
|
private
|
|
70
99
|
|
|
100
|
+
# Withdraw the requirement's own open proposal (Fizzy #1492). The single-open-
|
|
101
|
+
# proposal rule means an agent that wants to *amend* what it proposed can't just
|
|
102
|
+
# open a second one — it withdraws the first (restoring the requirement to what it
|
|
103
|
+
# was) and proposes again. The server only lets the proposal's author withdraw it.
|
|
104
|
+
def withdraw!(requirement, slug)
|
|
105
|
+
change_id = requirement['pending_change_id']
|
|
106
|
+
if change_id.nil? || change_id.to_s.empty?
|
|
107
|
+
output.error("Requirement '#{slug}' has no open proposal to withdraw.")
|
|
108
|
+
exit 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
result = client.withdraw_requirement_change(requirement['id'], change_id)
|
|
112
|
+
unless result[:ok]
|
|
113
|
+
output.error(result[:error], status: result[:status])
|
|
114
|
+
exit 1
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
output.success(result[:data]) do
|
|
118
|
+
puts "Withdrew the open proposal for '#{slug}'."
|
|
119
|
+
puts
|
|
120
|
+
puts 'The requirement is restored to what it was before the proposal.'
|
|
121
|
+
puts 'To amend it, propose the change again with the corrected values.'
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
71
125
|
# A 409 means the requirement already has an open proposal — the one-at-a-time rule
|
|
72
126
|
# (two "what it was" snapshots would make a rollback ambiguous). Rather than a dead
|
|
73
127
|
# end, show what the open proposal is and point at `fp comment` so the agent can
|
|
@@ -109,6 +163,20 @@ module Fp
|
|
|
109
163
|
warn ' Fields: ' + changes.keys.join(', ')
|
|
110
164
|
end
|
|
111
165
|
|
|
166
|
+
# --parent and --no-parent contradict each other, and --withdraw is a standalone
|
|
167
|
+
# verb that ignores proposed values (Fizzy #1492).
|
|
168
|
+
def validate_flag_combinations!(opts)
|
|
169
|
+
if opts[:parent] && opts[:no_parent]
|
|
170
|
+
output.error('Pass either --parent <slug> or --no-parent, not both.')
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
return unless opts[:withdraw] && opts[:removal]
|
|
175
|
+
|
|
176
|
+
output.error('--withdraw cancels an open proposal; it cannot be combined with --removal.')
|
|
177
|
+
exit 1
|
|
178
|
+
end
|
|
179
|
+
|
|
112
180
|
def validate_enums!(opts)
|
|
113
181
|
if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
|
|
114
182
|
output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
|
|
@@ -143,6 +211,9 @@ module Fp
|
|
|
143
211
|
params[:required_test_types] = opts[:required_types].split(',').map { |t| t.strip.downcase }.reject(&:empty?)
|
|
144
212
|
end
|
|
145
213
|
params[:parent_id] = resolve_parent_id(project_id, opts[:parent]) if opts[:parent]
|
|
214
|
+
# --no-parent un-nests a child back to top level: an empty parent_id. The server
|
|
215
|
+
# treats "" as "no parent" and diffs it against the current parent (Fizzy #1492).
|
|
216
|
+
params[:parent_id] = '' if opts[:no_parent]
|
|
146
217
|
|
|
147
218
|
params[:removal] = true if opts[:removal]
|
|
148
219
|
params[:reason] = opts[:reason] if opts[:reason]
|
data/lib/fp/commands/report.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'attach'
|
|
4
|
+
|
|
3
5
|
module Fp
|
|
4
6
|
module Commands
|
|
5
7
|
# Single requirement:
|
|
@@ -8,13 +10,39 @@ module Fp
|
|
|
8
10
|
#
|
|
9
11
|
# Batch from a JUnit XML report (upload evidence without CI):
|
|
10
12
|
# fp report --junit <path> --project <slug> --surface X --repo org/repo
|
|
11
|
-
# [--base-dir DIR] [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--state present|stub]
|
|
13
|
+
# [--base-dir DIR] [--attach-media] [--pr URL] [--sha X] [--work-item URL] [--ci-url URL] [--state present|stub]
|
|
12
14
|
#
|
|
13
15
|
# In --junit mode the CLI parses the report, discovers the fp:<slug> markers
|
|
14
16
|
# in the referenced test files, and uploads evidence for every requirement
|
|
15
17
|
# the suite covered. This lets an agent run the suite locally and report
|
|
16
|
-
# evidence without waiting on CI.
|
|
18
|
+
# evidence without waiting on CI. Playwright's reporter is handled natively:
|
|
19
|
+
# the › / :: title separators, a classname relative to the runner's testDir,
|
|
20
|
+
# and (with --attach-media) the [[ATTACHMENT|…]] videos it records.
|
|
17
21
|
class Report < Base
|
|
22
|
+
help_summary 'Report test evidence for a requirement (agents report present/stub only)'
|
|
23
|
+
help_usage 'fp report <slug> --project <slug> --surface <surface> --file <path> --repo <org/repo>',
|
|
24
|
+
'fp report --junit <file> --project <slug> --surface <surface> --repo <org/repo>'
|
|
25
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
26
|
+
help_flag :surface, 'Surface this evidence is for', required: true
|
|
27
|
+
help_flag :test_type, "Kind of test (project's vocabulary, e.g. unit, integration, e2e)"
|
|
28
|
+
help_flag :file, 'Path to the test file (single-requirement mode)', required: true
|
|
29
|
+
help_flag :line, 'Line number of the test within the file'
|
|
30
|
+
help_flag :repo, 'Repository the test lives in (org/repo)', required: true
|
|
31
|
+
help_flag :pr, 'Pull request URL'
|
|
32
|
+
help_flag :sha, 'Commit SHA the evidence was observed at'
|
|
33
|
+
help_flag :work_item, 'Work-item URL (card/issue) that prompted the work'
|
|
34
|
+
help_flag :ci_url, 'CI run URL'
|
|
35
|
+
help_flag :title, 'Human-readable example/test name to display'
|
|
36
|
+
help_flag :state, 'Evidence state: present or stub (default present)'
|
|
37
|
+
help_flag :junit, 'JUnit XML report to batch-upload evidence from'
|
|
38
|
+
help_flag :base_dir, 'Where relative test-file paths in the report resolve from'
|
|
39
|
+
help_flag :attach_media, 'With --junit: upload the videos/screenshots the run recorded ([[ATTACHMENT|…]]) as visual evidence'
|
|
40
|
+
help_example 'fp report print_container_qr --project stowzilla --surface api --file spec/qr_spec.rb --repo stowzilla/app'
|
|
41
|
+
help_example 'fp report --junit junit.xml --project stowzilla --surface api --repo stowzilla/app',
|
|
42
|
+
'Batch-upload from a JUnit report (slugs come from fp:<slug> markers)'
|
|
43
|
+
help_note "Agents report 'present' or 'stub' only. CI writes 'passing'/'failing' via fp ci-report.\n" \
|
|
44
|
+
'Always pass --test-type when a surface has more than one suite, or they overwrite each other.'
|
|
45
|
+
|
|
18
46
|
KNOWN_FLAGS = {
|
|
19
47
|
project: :string,
|
|
20
48
|
surface: :string,
|
|
@@ -29,7 +57,8 @@ module Fp
|
|
|
29
57
|
title: :string,
|
|
30
58
|
state: :string,
|
|
31
59
|
junit: :string,
|
|
32
|
-
base_dir: :string
|
|
60
|
+
base_dir: :string,
|
|
61
|
+
attach_media: :boolean
|
|
33
62
|
}.freeze
|
|
34
63
|
|
|
35
64
|
VALID_STATES = %w[present stub].freeze
|
|
@@ -212,6 +241,9 @@ module Fp
|
|
|
212
241
|
bindings = result[:bindings]
|
|
213
242
|
unmatched = result[:unmatched]
|
|
214
243
|
missing_surface = result[:missing_surface] || []
|
|
244
|
+
media = result[:media] || []
|
|
245
|
+
empty_name = result[:empty_name] || []
|
|
246
|
+
collisions = result[:collisions] || []
|
|
215
247
|
|
|
216
248
|
# Markers that pinned no surface AND no --surface default was given.
|
|
217
249
|
unless missing_surface.empty?
|
|
@@ -232,13 +264,16 @@ module Fp
|
|
|
232
264
|
reported = []
|
|
233
265
|
skipped_unknown = []
|
|
234
266
|
api_unavailable = false
|
|
267
|
+
# slug -> requirement record (or nil when the slug names no requirement), so a
|
|
268
|
+
# slug looked up for evidence isn't fetched again to upload its media.
|
|
269
|
+
requirement_cache = {}
|
|
235
270
|
|
|
236
271
|
bindings.each do |binding|
|
|
237
272
|
state = state_override || binding.state
|
|
238
273
|
|
|
239
274
|
# Verify the requirement exists before reporting (skip unknown slugs
|
|
240
275
|
# with a warning rather than aborting the whole batch).
|
|
241
|
-
req_result = client.find_requirement_by_slug(project_id, binding.slug)
|
|
276
|
+
req_result = requirement_cache[binding.slug] ||= client.find_requirement_by_slug(project_id, binding.slug)
|
|
242
277
|
unless req_result[:ok]
|
|
243
278
|
skipped_unknown << binding
|
|
244
279
|
next
|
|
@@ -280,7 +315,69 @@ module Fp
|
|
|
280
315
|
stored_source: stored_source_from(report_result) }
|
|
281
316
|
end
|
|
282
317
|
|
|
283
|
-
|
|
318
|
+
uploaded_media = []
|
|
319
|
+
if opts[:attach_media]
|
|
320
|
+
uploaded_media = upload_media(opts, media, base_dir, project_id, requirement_cache)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable, collisions,
|
|
324
|
+
empty_name: empty_name, media: media, uploaded_media: uploaded_media)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Upload the media (videos/screenshots) a run recorded for its bound testcases as
|
|
328
|
+
# visual evidence, one per MediaBinding, nesting each under the test it proves via
|
|
329
|
+
# example_title. Unknown slugs and unsupported/oversized files are skipped with a
|
|
330
|
+
# note rather than aborting the batch. Reuses the slug->requirement cache the
|
|
331
|
+
# evidence loop already filled.
|
|
332
|
+
def upload_media(opts, media, base_dir, project_id, requirement_cache)
|
|
333
|
+
media.map do |m|
|
|
334
|
+
path = File.expand_path(m.path, base_dir)
|
|
335
|
+
req_result = requirement_cache[m.slug] ||= client.find_requirement_by_slug(project_id, m.slug)
|
|
336
|
+
|
|
337
|
+
outcome = { slug: m.slug, surface: m.surface, path: m.path, example_title: m.example_title }
|
|
338
|
+
if !req_result[:ok]
|
|
339
|
+
outcome.merge(uploaded: false, reason: 'unknown requirement')
|
|
340
|
+
else
|
|
341
|
+
requirement_id = req_result[:data].dig('requirement', 'id')
|
|
342
|
+
upload_one_media(requirement_id, path, m, opts).merge(
|
|
343
|
+
slug: m.slug, surface: m.surface, path: m.path, example_title: m.example_title
|
|
344
|
+
)
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Presign → PUT → create, mirroring `fp attach`, for a single media file.
|
|
350
|
+
def upload_one_media(requirement_id, path, media_binding, opts)
|
|
351
|
+
return { uploaded: false, reason: 'file not found' } unless File.file?(path)
|
|
352
|
+
|
|
353
|
+
filename = File.basename(path)
|
|
354
|
+
ext = File.extname(filename).delete('.').downcase
|
|
355
|
+
content_type = Fp::Commands::Attach::CONTENT_TYPES[ext]
|
|
356
|
+
return { uploaded: false, reason: "unsupported type .#{ext}" } unless content_type
|
|
357
|
+
|
|
358
|
+
size = File.size(path)
|
|
359
|
+
return { uploaded: false, reason: 'empty file' } if size.zero?
|
|
360
|
+
if size > Fp::Commands::Attach::MAX_FILE_SIZE
|
|
361
|
+
return { uploaded: false, reason: 'file too large' }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
presign = client.presign_attachment(requirement_id, filename: filename,
|
|
365
|
+
content_type: content_type, size_bytes: size)
|
|
366
|
+
return { uploaded: false, reason: presign[:error] || 'presign failed' } unless presign[:ok]
|
|
367
|
+
|
|
368
|
+
p = presign[:data]['presign']
|
|
369
|
+
up = client.upload_to_presigned_url(p['upload_url'], path, content_type)
|
|
370
|
+
return { uploaded: false, reason: up[:error] || 'upload failed' } unless up[:ok]
|
|
371
|
+
|
|
372
|
+
created = client.create_attachment(requirement_id, attachment_id: p['attachment_id'],
|
|
373
|
+
s3_key: p['s3_key'], filename: filename,
|
|
374
|
+
content_type: content_type, size_bytes: size,
|
|
375
|
+
surface: media_binding.surface,
|
|
376
|
+
example_title: media_binding.example_title,
|
|
377
|
+
test_run_url: opts[:ci_url], sha: opts[:sha])
|
|
378
|
+
return { uploaded: false, reason: created[:error] || 'create failed' } unless created[:ok]
|
|
379
|
+
|
|
380
|
+
{ uploaded: true }
|
|
284
381
|
end
|
|
285
382
|
|
|
286
383
|
# Pull the stored status/source out of the server's echoed evidence record so the
|
|
@@ -299,12 +396,17 @@ module Fp
|
|
|
299
396
|
(data['evidence'] || {})['source']
|
|
300
397
|
end
|
|
301
398
|
|
|
302
|
-
def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable
|
|
399
|
+
def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable, collisions = [],
|
|
400
|
+
empty_name: [], media: [], uploaded_media: [])
|
|
303
401
|
data = {
|
|
304
402
|
repo: opts[:repo],
|
|
305
403
|
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], stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]) } },
|
|
306
404
|
skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
|
|
307
|
-
unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } }
|
|
405
|
+
unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } },
|
|
406
|
+
empty_name: empty_name,
|
|
407
|
+
media: media.map { |m| { slug: m.slug, surface: m.surface, path: m.path, example_title: m.example_title } },
|
|
408
|
+
uploaded_media: uploaded_media,
|
|
409
|
+
collisions: collisions.map { |c| { slug: c[:slug], surface: c[:surface], test_type: c[:test_type], files: c[:files] } }
|
|
308
410
|
}
|
|
309
411
|
|
|
310
412
|
output.success(data) do
|
|
@@ -324,6 +426,8 @@ module Fp
|
|
|
324
426
|
puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface}#{type} (#{shown_state}) #{loc}"
|
|
325
427
|
end
|
|
326
428
|
|
|
429
|
+
emit_collision_warning(collisions)
|
|
430
|
+
|
|
327
431
|
unless skipped_unknown.empty?
|
|
328
432
|
puts
|
|
329
433
|
puts "⚠️ Skipped #{skipped_unknown.length} marker(s) with no matching requirement in this project:"
|
|
@@ -335,12 +439,58 @@ module Fp
|
|
|
335
439
|
puts "ℹ️ #{unmatched.length} testcase(s) had no fp:<slug> marker and were skipped."
|
|
336
440
|
end
|
|
337
441
|
|
|
442
|
+
unless empty_name.empty?
|
|
443
|
+
puts
|
|
444
|
+
puts "⚠️ #{empty_name.length} bound testcase(s) have an empty name — the receipt will have no title."
|
|
445
|
+
puts ' This is almost always an upstream reporter problem; check the JUnit XML.'
|
|
446
|
+
empty_name.each { |e| puts " - #{e[:slug]} → #{e[:surface]} (#{e[:file]})" }
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
unless media.empty?
|
|
450
|
+
puts
|
|
451
|
+
if opts[:attach_media]
|
|
452
|
+
ok = uploaded_media.count { |m| m[:uploaded] }
|
|
453
|
+
puts "📎 Uploaded #{ok}/#{media.length} media attachment(s):"
|
|
454
|
+
uploaded_media.each do |m|
|
|
455
|
+
mark = m[:uploaded] ? '✓' : '✗'
|
|
456
|
+
title = m[:example_title] ? " [#{m[:example_title]}]" : ''
|
|
457
|
+
suffix = m[:uploaded] ? '' : " (skipped: #{m[:reason]})"
|
|
458
|
+
puts " #{mark} #{m[:slug]} → #{m[:surface]}#{title} #{m[:path]}#{suffix}"
|
|
459
|
+
end
|
|
460
|
+
else
|
|
461
|
+
puts "📎 #{media.length} media attachment(s) found in the report (e.g. Playwright videos)."
|
|
462
|
+
puts ' Pass --attach-media to upload them as visual evidence.'
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
|
|
338
466
|
if api_unavailable
|
|
339
467
|
puts
|
|
340
468
|
puts '⚠️ Evidence API not available yet. These will be recorded when #1214 is complete.'
|
|
341
469
|
end
|
|
342
470
|
end
|
|
343
471
|
end
|
|
472
|
+
|
|
473
|
+
# Warn when two or more distinct test files in this run resolved to the *same*
|
|
474
|
+
# evidence cell. Server-side identity is (project, slug, surface, repo, test_type) —
|
|
475
|
+
# the file isn't part of it — so those reports upsert the same row and the last one
|
|
476
|
+
# posted wins. The suites silently overwrite each other, and only one survives on the
|
|
477
|
+
# matrix. It's almost always the `unspecified` default: neither suite pinned a test
|
|
478
|
+
# type. Make it loud and point at the fix.
|
|
479
|
+
def emit_collision_warning(collisions)
|
|
480
|
+
return if collisions.nil? || collisions.empty?
|
|
481
|
+
|
|
482
|
+
puts
|
|
483
|
+
puts "⚠️ #{collisions.length} evidence cell(s) were fed by more than one test file in this run."
|
|
484
|
+
puts ' These share one evidence row server-side, so the last report wins and the'
|
|
485
|
+
puts ' others are silently overwritten. Give each kind of test its own test type'
|
|
486
|
+
puts ' (a `:type` marker suffix, e.g. fp:slug@surface:e2e, or --test-type) so they'
|
|
487
|
+
puts ' become distinct cells instead of colliding:'
|
|
488
|
+
collisions.each do |c|
|
|
489
|
+
type = c[:test_type] ? c[:test_type] : 'unspecified'
|
|
490
|
+
puts " - #{c[:slug]} → #{c[:surface]} [#{type}] collided across:"
|
|
491
|
+
c[:files].each { |f| puts " #{f}" }
|
|
492
|
+
end
|
|
493
|
+
end
|
|
344
494
|
end
|
|
345
495
|
end
|
|
346
496
|
end
|
data/lib/fp/commands/repos.rb
CHANGED
|
@@ -16,6 +16,17 @@ module Fp
|
|
|
16
16
|
# fp repos list [--project <slug>]
|
|
17
17
|
# fp repos unset <surface> --project <slug>
|
|
18
18
|
class Repos < Base
|
|
19
|
+
help_summary 'Map surfaces to local repo paths on this machine'
|
|
20
|
+
help_usage 'fp repos set <surface> <path> --project <slug> [--repo org/repo]',
|
|
21
|
+
'fp repos get <surface> --project <slug>',
|
|
22
|
+
'fp repos list [--project <slug>]',
|
|
23
|
+
'fp repos unset <surface> --project <slug>'
|
|
24
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
25
|
+
help_flag :repo, 'Optional org/repo to record alongside the local path (set only)'
|
|
26
|
+
help_example 'fp repos set customer_android ~/code/customer-android --project stowzilla --repo stowzilla/customer-android'
|
|
27
|
+
help_example 'cd "$(fp repos get customer_android --project stowzilla)"'
|
|
28
|
+
help_note 'Stored per-installation in ~/.config/fp/config.yml, not in the web app.'
|
|
29
|
+
|
|
19
30
|
KNOWN_FLAGS = {
|
|
20
31
|
project: :string,
|
|
21
32
|
repo: :string
|
data/lib/fp/commands/setup.rb
CHANGED
|
@@ -6,6 +6,17 @@ module Fp
|
|
|
6
6
|
# Interactive guided setup for new users.
|
|
7
7
|
# Validates the API key, checks connectivity, and saves a profile.
|
|
8
8
|
class Setup < Base
|
|
9
|
+
help_summary 'Interactive first-time setup: validate a key and save a profile'
|
|
10
|
+
help_usage 'fp setup',
|
|
11
|
+
'fp setup --api-key fp_... [--profile NAME] [--non-interactive]'
|
|
12
|
+
help_flag :api_key, 'API key to validate and save (starts with fp_)'
|
|
13
|
+
help_flag :profile, 'Profile name to write (defaults to default)'
|
|
14
|
+
help_flag :api_url, 'Override API URL'
|
|
15
|
+
help_flag :environment, 'Named environment for the profile'
|
|
16
|
+
help_flag :non_interactive, 'Do not prompt; require flags to be supplied'
|
|
17
|
+
help_example 'fp setup'
|
|
18
|
+
help_example 'fp setup --api-key fp_... --profile ci-agent --non-interactive'
|
|
19
|
+
|
|
9
20
|
KNOWN_FLAGS = {
|
|
10
21
|
api_key: :string,
|
|
11
22
|
profile: :string,
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -5,6 +5,11 @@ module Fp
|
|
|
5
5
|
# fp show <slug> --project <slug>
|
|
6
6
|
# Show requirement details including evidence receipts
|
|
7
7
|
class Show < Base
|
|
8
|
+
help_summary 'Show a requirement, its acceptance criteria, and evidence receipts'
|
|
9
|
+
help_usage 'fp show <slug> --project <slug>'
|
|
10
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
11
|
+
help_example 'fp show print_container_qr --project stowzilla'
|
|
12
|
+
|
|
8
13
|
def run(args)
|
|
9
14
|
opts, positional = parse_flags(args)
|
|
10
15
|
require_project!(opts)
|
|
@@ -31,14 +36,20 @@ module Fp
|
|
|
31
36
|
# every requirement no matter how many receipts have been filed.
|
|
32
37
|
evidence = fetch_evidence(project_id, slug)
|
|
33
38
|
|
|
39
|
+
# Attachments (screenshots, recordings, downloadable files) live on their own
|
|
40
|
+
# collection keyed by the requirement's id, so fetch them here to show an agent
|
|
41
|
+
# what visual evidence is already on the requirement without a second command.
|
|
42
|
+
attachments = fetch_attachments(requirement['id'])
|
|
43
|
+
|
|
34
44
|
# Agents only ever speak slugs, so translate the parent's ID back into one.
|
|
35
45
|
parent_slug = resolve_parent_slug(project_id, requirement['parent_id'])
|
|
36
46
|
|
|
37
|
-
payload = result[:data].merge('evidence' => evidence)
|
|
47
|
+
payload = result[:data].merge('evidence' => evidence, 'attachments' => attachments)
|
|
38
48
|
payload['parent_slug'] = parent_slug if parent_slug
|
|
39
49
|
|
|
40
50
|
output.success(payload) do
|
|
41
|
-
print_requirement(requirement, evidence: evidence,
|
|
51
|
+
print_requirement(requirement, evidence: evidence, attachments: attachments,
|
|
52
|
+
parent_slug: parent_slug, source_history: source_history)
|
|
42
53
|
end
|
|
43
54
|
end
|
|
44
55
|
|
|
@@ -51,6 +62,15 @@ module Fp
|
|
|
51
62
|
result[:data]['evidence'] || []
|
|
52
63
|
end
|
|
53
64
|
|
|
65
|
+
def fetch_attachments(requirement_id)
|
|
66
|
+
return [] if requirement_id.to_s.empty?
|
|
67
|
+
|
|
68
|
+
result = client.list_attachments(requirement_id)
|
|
69
|
+
return [] unless result[:ok]
|
|
70
|
+
|
|
71
|
+
result[:data]['attachments'] || []
|
|
72
|
+
end
|
|
73
|
+
|
|
54
74
|
def resolve_parent_slug(project_id, parent_id)
|
|
55
75
|
return nil if parent_id.nil? || parent_id.to_s.empty?
|
|
56
76
|
|
|
@@ -61,7 +81,7 @@ module Fp
|
|
|
61
81
|
parent && parent['slug']
|
|
62
82
|
end
|
|
63
83
|
|
|
64
|
-
def print_requirement(req, evidence: [], parent_slug: nil, source_history: [])
|
|
84
|
+
def print_requirement(req, evidence: [], attachments: [], parent_slug: nil, source_history: [])
|
|
65
85
|
puts "#{req['title']}"
|
|
66
86
|
puts "=" * req['title'].length
|
|
67
87
|
puts
|
|
@@ -156,10 +176,29 @@ module Fp
|
|
|
156
176
|
end
|
|
157
177
|
end
|
|
158
178
|
|
|
179
|
+
print_attachments(attachments)
|
|
180
|
+
|
|
159
181
|
puts "Created: #{req['created_at']}"
|
|
160
182
|
puts "Updated: #{req['updated_at']}"
|
|
161
183
|
puts "By: #{req['created_by']}" if req['created_by']
|
|
162
184
|
end
|
|
185
|
+
|
|
186
|
+
# Attachments: visual evidence (screenshots, recordings) and downloadable files on
|
|
187
|
+
# the requirement, so an agent can see what's already captured without a separate
|
|
188
|
+
# command. Shown even when empty so the absence is explicit (Fizzy #1488).
|
|
189
|
+
def print_attachments(attachments)
|
|
190
|
+
puts 'Attachments:'
|
|
191
|
+
if attachments.empty?
|
|
192
|
+
puts ' (none)'
|
|
193
|
+
else
|
|
194
|
+
attachments.each do |att|
|
|
195
|
+
puts " #{att['id']} #{att['filename']}"
|
|
196
|
+
puts " Surface: #{att['surface']}" if att['surface'] && !att['surface'].to_s.empty?
|
|
197
|
+
puts " Test: #{att['example_title']}" if att['example_title'] && !att['example_title'].to_s.empty?
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
puts
|
|
201
|
+
end
|
|
163
202
|
end
|
|
164
203
|
end
|
|
165
204
|
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp showroom --project <slug> - the client showroom, as a client will see it
|
|
6
|
+
#
|
|
7
|
+
# The showroom (Fizzy #1479) is the one page a client gets: the welcome note, then
|
|
8
|
+
# a card per surface with a link, a button label, and instructions. Its content is
|
|
9
|
+
# written through `fp projects edit --showroom-welcome` and `fp surfaces edit
|
|
10
|
+
# --showroom-*`, and this command is how an agent checks the result without a
|
|
11
|
+
# browser: the same payload the client is served, plus the surfaces that are *not*
|
|
12
|
+
# on it and why, so a card that silently dropped out (both fields cleared) is
|
|
13
|
+
# noticed.
|
|
14
|
+
#
|
|
15
|
+
# Whether the share link is on is a human's business — the API refuses an agent's
|
|
16
|
+
# key — so it is reported as unknown rather than guessed at.
|
|
17
|
+
class Showroom < Base
|
|
18
|
+
help_summary 'Preview the client showroom exactly as a client will see it'
|
|
19
|
+
help_usage 'fp showroom --project <slug>'
|
|
20
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
21
|
+
help_example 'fp showroom --project stowzilla'
|
|
22
|
+
help_note "Prints the welcome note and each surface's card, then the surfaces that are NOT shown and why.\n" \
|
|
23
|
+
"Write the content with `fp projects edit --showroom-welcome` and `fp surfaces edit --showroom-*`.\n" \
|
|
24
|
+
'The share link is reported as unknown: only a person can see or change it.'
|
|
25
|
+
def run(args)
|
|
26
|
+
opts, = parse_flags(args)
|
|
27
|
+
require_project!(opts)
|
|
28
|
+
|
|
29
|
+
project_id = resolve_project_id(opts[:project])
|
|
30
|
+
|
|
31
|
+
result = client.get_showroom(project_id)
|
|
32
|
+
unless result[:ok]
|
|
33
|
+
output.error(result[:error], status: result[:status])
|
|
34
|
+
exit 1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
showroom = result[:data]['showroom'] || {}
|
|
38
|
+
shown_ids = (showroom['surfaces'] || []).map { |surface| surface['id'] }
|
|
39
|
+
hidden = hidden_surfaces(project_id, shown_ids)
|
|
40
|
+
share = share_link_status(project_id)
|
|
41
|
+
|
|
42
|
+
output.success({ showroom: showroom, hidden: hidden, share_link: share, project: opts[:project] }) do
|
|
43
|
+
print_showroom(showroom, hidden, share, project: opts[:project])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Surfaces with neither a link nor instructions. The showroom payload leaves them
|
|
50
|
+
# out, so they are read from the surface list instead.
|
|
51
|
+
def hidden_surfaces(project_id, shown_ids)
|
|
52
|
+
result = client.list_surfaces(project_id)
|
|
53
|
+
return [] unless result[:ok]
|
|
54
|
+
|
|
55
|
+
(result[:data]['surfaces'] || []).reject { |surface| shown_ids.include?(surface['id']) }.map do |surface|
|
|
56
|
+
{ 'key' => surface['key'], 'name' => surface['name'], 'kind' => surface['kind'],
|
|
57
|
+
'reason' => 'no showroom link or instructions' }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# { 'state' => 'on' | 'off' | 'unknown', 'token' => ..., 'reason' => ... }
|
|
62
|
+
def share_link_status(project_id)
|
|
63
|
+
result = client.get_showroom_share_link(project_id)
|
|
64
|
+
if result[:ok]
|
|
65
|
+
link = result[:data]['share_link']
|
|
66
|
+
link ? { 'state' => 'on', 'token' => link['token'], 'created_by' => link['created_by'] } : { 'state' => 'off' }
|
|
67
|
+
elsif result[:status] == 403
|
|
68
|
+
{ 'state' => 'unknown', 'reason' => 'only a person can see or change the share link (Project Settings → Showroom)' }
|
|
69
|
+
else
|
|
70
|
+
{ 'state' => 'unknown', 'reason' => result[:error] }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def print_showroom(showroom, hidden, share, project:)
|
|
75
|
+
info = showroom['project'] || {}
|
|
76
|
+
title = "Showroom: #{info['name'] || project}"
|
|
77
|
+
puts title
|
|
78
|
+
puts '=' * title.length
|
|
79
|
+
puts
|
|
80
|
+
|
|
81
|
+
welcome = text_or_nil(info['welcome'])
|
|
82
|
+
if welcome
|
|
83
|
+
puts 'Welcome:'
|
|
84
|
+
welcome.each_line { |line| puts " #{line.rstrip}" }
|
|
85
|
+
else
|
|
86
|
+
puts 'Welcome: (none) — set one with: fp projects edit ' \
|
|
87
|
+
"#{info['slug'] || project} --showroom-welcome \"...\""
|
|
88
|
+
end
|
|
89
|
+
puts
|
|
90
|
+
|
|
91
|
+
surfaces = showroom['surfaces'] || []
|
|
92
|
+
if surfaces.empty?
|
|
93
|
+
puts 'Surfaces shown: none. The client sees only the welcome note.'
|
|
94
|
+
else
|
|
95
|
+
puts "Surfaces shown (#{surfaces.size}):"
|
|
96
|
+
surfaces.each { |surface| print_card(surface) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
unless hidden.empty?
|
|
100
|
+
puts
|
|
101
|
+
puts "Not shown (#{hidden.size}):"
|
|
102
|
+
hidden.each do |surface|
|
|
103
|
+
puts " - #{surface['name'] || surface['key']} (#{surface['key']}): #{surface['reason']}"
|
|
104
|
+
end
|
|
105
|
+
puts " Add one with: fp surfaces edit <key> --project #{project} --showroom-url ... " \
|
|
106
|
+
'--showroom-instructions-file ...'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
puts
|
|
110
|
+
case share['state']
|
|
111
|
+
when 'on'
|
|
112
|
+
puts "Share link: on (created by #{share['created_by'] || 'unknown'}) — the content above is live."
|
|
113
|
+
when 'off'
|
|
114
|
+
puts 'Share link: off — only signed-in clients and the team can see this.'
|
|
115
|
+
else
|
|
116
|
+
puts "Share link: unknown — #{share['reason']}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def print_card(surface)
|
|
121
|
+
puts
|
|
122
|
+
puts " #{surface['name']} (#{surface['kind']})"
|
|
123
|
+
url = text_or_nil(surface['url'])
|
|
124
|
+
label = text_or_nil(surface['label'])
|
|
125
|
+
puts " Link: #{url ? "#{url}#{label ? " [#{label}]" : ''}" : '(none)'}"
|
|
126
|
+
instructions = text_or_nil(surface['instructions'])
|
|
127
|
+
if instructions
|
|
128
|
+
puts ' Instructions:'
|
|
129
|
+
instructions.each_line { |line| puts " #{line.rstrip}" }
|
|
130
|
+
else
|
|
131
|
+
puts ' Instructions: (none)'
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def text_or_nil(value)
|
|
136
|
+
text = value.to_s.strip
|
|
137
|
+
text.empty? ? nil : text
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|