featureparity 0.0.21 → 0.0.23
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 +70 -0
- data/lib/fp/commands/analyze.rb +10 -0
- data/lib/fp/commands/attach.rb +109 -46
- data/lib/fp/commands/attachments.rb +6 -0
- data/lib/fp/commands/base.rb +144 -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 -6
- data/lib/fp/commands/history.rb +8 -0
- 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 +194 -0
- data/lib/fp/commands/showroom_media.rb +334 -0
- data/lib/fp/commands/surfaces.rb +142 -3
- data/lib/fp/commands/version.rb +3 -0
- data/lib/fp/commands/whoami.rb +6 -0
- data/lib/fp/commands.rb +2 -0
- data/lib/fp/junit.rb +213 -18
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +45 -0
- data/skills/fp/references/cli.md +64 -1
- metadata +4 -2
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -18,6 +18,13 @@ module Fp
|
|
|
18
18
|
# surface with the same key, and there was no way to fix that short of a raw API call
|
|
19
19
|
# (Fizzy #1480). The server now refuses a duplicate key with a 409; `add` turns that
|
|
20
20
|
# into a pointer at `edit`. `delete` is guarded like `fp projects delete`.
|
|
21
|
+
#
|
|
22
|
+
# `edit` also keeps the surface's client-showroom card (Fizzy #1479): the link a
|
|
23
|
+
# client opens, the button text, and how to sign in and try it. The agent that
|
|
24
|
+
# deployed the surface and made the demo accounts is the one that knows these, so
|
|
25
|
+
# it fills them in rather than leaving a human to retype them. Pass an empty value
|
|
26
|
+
# to clear a field; a surface with neither a link nor instructions leaves the
|
|
27
|
+
# showroom. Feedback and the share link stay with humans (`fp showroom` explains).
|
|
21
28
|
class Surfaces < Base
|
|
22
29
|
ADD_FLAGS = {
|
|
23
30
|
project: :string,
|
|
@@ -28,7 +35,15 @@ module Fp
|
|
|
28
35
|
default_branch: :string
|
|
29
36
|
}.freeze
|
|
30
37
|
|
|
31
|
-
|
|
38
|
+
SHOWROOM_FLAGS = {
|
|
39
|
+
showroom_url: :string,
|
|
40
|
+
showroom_label: :string,
|
|
41
|
+
showroom_instructions: :string,
|
|
42
|
+
showroom_instructions_file: :string,
|
|
43
|
+
showroom_position: :string
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
EDIT_FLAGS = ADD_FLAGS.merge(key: :string).merge(SHOWROOM_FLAGS).freeze
|
|
32
47
|
|
|
33
48
|
DELETE_FLAGS = {
|
|
34
49
|
project: :string,
|
|
@@ -38,6 +53,30 @@ module Fp
|
|
|
38
53
|
VALID_KINDS = %w[api web android ios other].freeze
|
|
39
54
|
VALID_AUDIENCES = %w[customer ops both internal].freeze
|
|
40
55
|
|
|
56
|
+
help_summary 'List, view, create, edit, and delete a project\'s surfaces'
|
|
57
|
+
help_usage 'fp surfaces --project <slug>',
|
|
58
|
+
'fp surfaces show <key> --project <slug>',
|
|
59
|
+
'fp surfaces add <key> --project <slug> [--name ...] [--kind K] [--repo org/repo]',
|
|
60
|
+
'fp surfaces edit <key> --project <slug> [flags]',
|
|
61
|
+
'fp surfaces delete <key> --project <slug> [--yes]'
|
|
62
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
63
|
+
help_flag :name, 'Human-readable surface name'
|
|
64
|
+
help_flag :kind, "Surface kind: #{VALID_KINDS.join(', ')}"
|
|
65
|
+
help_flag :audience, "Audience: #{VALID_AUDIENCES.join(', ')}"
|
|
66
|
+
help_flag :repo, 'Repository the surface maps to (org/repo)'
|
|
67
|
+
help_flag :default_branch, 'Default branch for the repo'
|
|
68
|
+
help_flag :showroom_url, 'Client showroom card: the http(s) link a client opens (edit; empty clears)'
|
|
69
|
+
help_flag :showroom_label, 'Client showroom card: the button text for that link (edit)'
|
|
70
|
+
help_flag :showroom_instructions, 'Client showroom card: how to sign in and try the surface (edit)'
|
|
71
|
+
help_flag :showroom_instructions_file, 'Read --showroom-instructions from a file (multi-line)'
|
|
72
|
+
help_flag :showroom_position, 'Client showroom order: 1 is first; empty clears (unplaced surfaces follow, A-Z) (edit)'
|
|
73
|
+
help_flag :yes, 'Skip the typed-key confirmation on delete'
|
|
74
|
+
help_example 'fp surfaces --project stowzilla'
|
|
75
|
+
help_example 'fp surfaces add customer_android --project stowzilla --kind android --repo stowzilla/customer-android'
|
|
76
|
+
help_example 'fp surfaces delete web --project stowzilla --yes'
|
|
77
|
+
help_example 'fp surfaces edit web --project stowzilla --showroom-url https://dev.example.com --showroom-instructions-file docs/showroom/web.txt'
|
|
78
|
+
help_example 'fp surfaces edit web --project stowzilla --showroom-position 1'
|
|
79
|
+
|
|
41
80
|
def run(args)
|
|
42
81
|
# Subcommand dispatch. Bare `fp surfaces --project X` stays a list, so existing
|
|
43
82
|
# callers (and the docs) keep working.
|
|
@@ -167,7 +206,10 @@ module Fp
|
|
|
167
206
|
key = positional.first
|
|
168
207
|
if key.nil? || key.strip.empty?
|
|
169
208
|
output.error('Usage: fp surfaces edit <key> --project <slug> [--name "Name"] [--kind ...] ' \
|
|
170
|
-
'[--audience ...] [--repo org/repo] [--default-branch main] [--key new_key]'
|
|
209
|
+
'[--audience ...] [--repo org/repo] [--default-branch main] [--key new_key] ' \
|
|
210
|
+
'[--showroom-url URL] [--showroom-label "..."] ' \
|
|
211
|
+
'[--showroom-instructions "..." | --showroom-instructions-file PATH] ' \
|
|
212
|
+
'[--showroom-position N]')
|
|
171
213
|
exit 1
|
|
172
214
|
end
|
|
173
215
|
|
|
@@ -179,9 +221,11 @@ module Fp
|
|
|
179
221
|
%i[key name kind audience repo default_branch].each do |field|
|
|
180
222
|
params[field] = opts[field] if opts.key?(field)
|
|
181
223
|
end
|
|
224
|
+
params.merge!(showroom_params(opts))
|
|
182
225
|
|
|
183
226
|
if params.empty?
|
|
184
|
-
output.error('Nothing to change. Pass --name, --kind, --audience, --repo, --default-branch,
|
|
227
|
+
output.error('Nothing to change. Pass --name, --kind, --audience, --repo, --default-branch, --key, ' \
|
|
228
|
+
'or a --showroom-* flag.')
|
|
185
229
|
exit 1
|
|
186
230
|
end
|
|
187
231
|
|
|
@@ -208,9 +252,74 @@ module Fp
|
|
|
208
252
|
puts " Audience: #{record['audience']}"
|
|
209
253
|
puts " Repo: #{record['repo'] || '(none)'}"
|
|
210
254
|
puts " Branch: #{record['default_branch'] || '(none)'}"
|
|
255
|
+
print_showroom(record, indent: ' ') if params.keys.any? { |field| field.to_s.start_with?('showroom_') }
|
|
211
256
|
end
|
|
212
257
|
end
|
|
213
258
|
|
|
259
|
+
# The showroom fields from --showroom-* flags, as the API expects them. A string
|
|
260
|
+
# flag given as empty clears the field. The URL is checked here with the same rule
|
|
261
|
+
# the server applies (http/https only) so a typo fails before anything is sent.
|
|
262
|
+
def showroom_params(opts)
|
|
263
|
+
if opts.key?(:showroom_instructions) && opts.key?(:showroom_instructions_file)
|
|
264
|
+
output.error('Pass --showroom-instructions or --showroom-instructions-file, not both.')
|
|
265
|
+
exit 1
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
params = {}
|
|
269
|
+
%i[showroom_url showroom_label showroom_instructions].each do |field|
|
|
270
|
+
params[field] = flag_text(opts[field]) if opts.key?(field)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if opts.key?(:showroom_instructions_file)
|
|
274
|
+
params[:showroom_instructions] = read_text_file!(opts[:showroom_instructions_file],
|
|
275
|
+
'--showroom-instructions-file')
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
params[:showroom_position] = showroom_position!(opts[:showroom_position]) if opts.key?(:showroom_position)
|
|
279
|
+
|
|
280
|
+
url = params[:showroom_url]
|
|
281
|
+
if url && !url.empty? && url !~ %r{\Ahttps?://\S+\z}i
|
|
282
|
+
output.error("Invalid --showroom-url '#{url}'. Must be an http:// or https:// link; " \
|
|
283
|
+
'pass an empty value to clear it.')
|
|
284
|
+
exit 1
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
params
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# 1 is first. Empty clears it, sent as 0 because the API types the field as an
|
|
291
|
+
# integer (null would be refused at the gateway); the surface then follows the
|
|
292
|
+
# placed ones alphabetically.
|
|
293
|
+
def showroom_position!(value)
|
|
294
|
+
text = flag_text(value).strip
|
|
295
|
+
return 0 if text.empty?
|
|
296
|
+
|
|
297
|
+
position = Integer(text, 10, exception: false)
|
|
298
|
+
return position if position&.positive?
|
|
299
|
+
|
|
300
|
+
output.error("Invalid --showroom-position '#{text}'. Must be a whole number of 1 or more; " \
|
|
301
|
+
'pass an empty value to clear it.')
|
|
302
|
+
exit 1
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A string flag given with no value parses as `true`; treat that as "clear it",
|
|
306
|
+
# the same as an explicit empty string.
|
|
307
|
+
def flag_text(value)
|
|
308
|
+
value == true ? '' : value.to_s
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def read_text_file!(path, flag)
|
|
312
|
+
if path.nil? || path == true || path.to_s.strip.empty?
|
|
313
|
+
output.error("#{flag} needs a path")
|
|
314
|
+
exit 1
|
|
315
|
+
end
|
|
316
|
+
unless File.file?(path)
|
|
317
|
+
output.error("#{flag}: no such file '#{path}'")
|
|
318
|
+
exit 1
|
|
319
|
+
end
|
|
320
|
+
File.read(path).strip
|
|
321
|
+
end
|
|
322
|
+
|
|
214
323
|
def delete(args)
|
|
215
324
|
opts, positional = parse_flags(args, DELETE_FLAGS)
|
|
216
325
|
require_project!(opts)
|
|
@@ -311,12 +420,42 @@ module Fp
|
|
|
311
420
|
globs.each { |g| puts " - #{g}" }
|
|
312
421
|
end
|
|
313
422
|
|
|
423
|
+
puts
|
|
424
|
+
print_showroom(surface, indent: '')
|
|
425
|
+
|
|
314
426
|
puts
|
|
315
427
|
puts "ID: #{surface['id']}" if surface['id']
|
|
316
428
|
puts "Created: #{surface['created_at']}" if surface['created_at']
|
|
317
429
|
puts "Updated: #{surface['updated_at']}" if surface['updated_at']
|
|
318
430
|
end
|
|
319
431
|
|
|
432
|
+
# The surface's client-showroom card. Says plainly whether a client can see the
|
|
433
|
+
# surface, since that is decided by the content (a link or instructions), not a
|
|
434
|
+
# switch, and an agent that just wrote instructions should know they went live.
|
|
435
|
+
def print_showroom(surface, indent:)
|
|
436
|
+
url = text_or_nil(surface['showroom_url'])
|
|
437
|
+
label = text_or_nil(surface['showroom_label'])
|
|
438
|
+
instructions = text_or_nil(surface['showroom_instructions'])
|
|
439
|
+
shown = !(url.nil? && instructions.nil?)
|
|
440
|
+
|
|
441
|
+
position = surface['showroom_position']
|
|
442
|
+
position = nil if position.to_s.strip.empty? || position.to_i < 1
|
|
443
|
+
|
|
444
|
+
puts "#{indent}Showroom: #{shown ? 'shown to clients' : 'hidden (no link or instructions)'}"
|
|
445
|
+
return unless shown
|
|
446
|
+
|
|
447
|
+
puts "#{indent} Position: #{position ? position.to_i : '(not set; ordered by name)'}"
|
|
448
|
+
|
|
449
|
+
puts "#{indent} Link: #{url || '(none)'}#{label ? " [#{label}]" : ''}"
|
|
450
|
+
puts "#{indent} Instructions: #{instructions ? '' : '(none)'}"
|
|
451
|
+
instructions&.each_line { |line| puts "#{indent} #{line.rstrip}" }
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def text_or_nil(value)
|
|
455
|
+
text = value.to_s.strip
|
|
456
|
+
text.empty? ? nil : text
|
|
457
|
+
end
|
|
458
|
+
|
|
320
459
|
# test_globs may arrive as a JSON string or an array; normalize to an array for
|
|
321
460
|
# display. Blank/unparseable → empty (the server falls back to kind defaults).
|
|
322
461
|
def normalize_test_globs(value)
|
data/lib/fp/commands/version.rb
CHANGED
data/lib/fp/commands/whoami.rb
CHANGED
|
@@ -10,6 +10,12 @@ module Fp
|
|
|
10
10
|
# (masked — never the full token). Purely local; makes no API calls, so it
|
|
11
11
|
# works even without a valid key. Honors --profile / --api-url / --json.
|
|
12
12
|
class Whoami < Base
|
|
13
|
+
help_summary 'Show the resolved config (profile, API URL, environment, masked key)'
|
|
14
|
+
help_usage 'fp whoami [--json]'
|
|
15
|
+
help_example 'fp whoami'
|
|
16
|
+
help_example 'fp --profile staging whoami'
|
|
17
|
+
help_note 'Purely local; makes no API calls, so it works even without a valid key.'
|
|
18
|
+
|
|
13
19
|
def run(_args)
|
|
14
20
|
r = config.resolved
|
|
15
21
|
|
data/lib/fp/commands.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative 'commands/base'
|
|
4
4
|
require_relative 'commands/projects'
|
|
5
5
|
require_relative 'commands/surfaces'
|
|
6
|
+
require_relative 'commands/showroom'
|
|
7
|
+
require_relative 'commands/showroom_media'
|
|
6
8
|
require_relative 'commands/list'
|
|
7
9
|
require_relative 'commands/show'
|
|
8
10
|
require_relative 'commands/propose'
|
data/lib/fp/junit.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rexml/document'
|
|
4
|
+
require 'pathname'
|
|
4
5
|
require_relative 'source_fingerprint'
|
|
5
6
|
|
|
6
7
|
module Fp
|
|
@@ -18,7 +19,8 @@ module Fp
|
|
|
18
19
|
# is the runner's own verdict, which only CI is allowed to act on (`fp ci-report`).
|
|
19
20
|
# Both are kept because they answer different questions: a red test still proves the
|
|
20
21
|
# test exists (present), it just doesn't prove the requirement is met.
|
|
21
|
-
TestCase = Struct.new(:name, :classname, :file, :line, :status, :failed,
|
|
22
|
+
TestCase = Struct.new(:name, :classname, :file, :line, :status, :failed, :attachments,
|
|
23
|
+
keyword_init: true) do
|
|
22
24
|
# Agents only ever report present/stub — never passing/failing (that's CI).
|
|
23
25
|
# A skipped test is treated as a stub; anything else counts as present.
|
|
24
26
|
def stub?
|
|
@@ -28,6 +30,12 @@ module Fp
|
|
|
28
30
|
def failed?
|
|
29
31
|
!!failed
|
|
30
32
|
end
|
|
33
|
+
|
|
34
|
+
# Media (videos, screenshots, traces) the runner recorded for this test — e.g.
|
|
35
|
+
# Playwright's `[[ATTACHMENT|path]]` lines. Always an array.
|
|
36
|
+
def attachments
|
|
37
|
+
self[:attachments] || []
|
|
38
|
+
end
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
# One requirement's aggregated CI outcome for one surface, ready to hand to the
|
|
@@ -77,6 +85,12 @@ module Fp
|
|
|
77
85
|
# test, so the marker line is a close stand-in when the runner omits lines).
|
|
78
86
|
Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, keyword_init: true)
|
|
79
87
|
|
|
88
|
+
# A media file (video/screenshot) a run recorded for one bound testcase, ready for
|
|
89
|
+
# `fp report --junit --attach-media` to upload as visual evidence. Unlike Binding,
|
|
90
|
+
# these are per-testcase, not aggregated: a run can record a distinct video for every
|
|
91
|
+
# test, and `example_title` is what nests each capture under the receipt it proves.
|
|
92
|
+
MediaBinding = Struct.new(:slug, :surface, :path, :example_title, keyword_init: true)
|
|
93
|
+
|
|
80
94
|
# Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
|
|
81
95
|
# `@surface1,surface2`) suffix to target one or more specific surfaces, and an
|
|
82
96
|
# optional `:<type>` suffix to pin the kind of test:
|
|
@@ -132,13 +146,30 @@ module Fp
|
|
|
132
146
|
file: resolve_file(el.attributes['file'], classname, suite_name),
|
|
133
147
|
line: (el.attributes['line'] && el.attributes['line'].to_i),
|
|
134
148
|
status: testcase_status(el),
|
|
135
|
-
failed: testcase_failed?(el)
|
|
149
|
+
failed: testcase_failed?(el),
|
|
150
|
+
attachments: extract_attachments(el)
|
|
136
151
|
)
|
|
137
152
|
end
|
|
138
153
|
|
|
139
154
|
cases
|
|
140
155
|
end
|
|
141
156
|
|
|
157
|
+
# Playwright's JUnit reporter records media (video, screenshots, trace) by writing
|
|
158
|
+
# `[[ATTACHMENT|<path>]]` lines into the testcase's `<system-out>`. Pull those paths
|
|
159
|
+
# out so `fp report --junit --attach-media` can upload them as visual evidence for the
|
|
160
|
+
# bound requirement, instead of a hand-matched `fp attach` per file.
|
|
161
|
+
#
|
|
162
|
+
# The path is relative to wherever Playwright ran; it's resolved against --base-dir at
|
|
163
|
+
# upload time, the same way test-file paths are.
|
|
164
|
+
ATTACHMENT_RE = /\[\[ATTACHMENT\|([^\]]+)\]\]/.freeze
|
|
165
|
+
|
|
166
|
+
def extract_attachments(el)
|
|
167
|
+
text = el.get_elements('system-out').map(&:text).compact.join("\n")
|
|
168
|
+
return [] if text.empty?
|
|
169
|
+
|
|
170
|
+
text.scan(ATTACHMENT_RE).map { |(path)| path.strip }.reject(&:empty?).uniq
|
|
171
|
+
end
|
|
172
|
+
|
|
142
173
|
# Runners disagree about where the source file goes. Minitest- and RSpec-style reports
|
|
143
174
|
# put it on the testcase's `file` attribute; vitest and jest omit `file` entirely and
|
|
144
175
|
# carry the path in `classname` and the enclosing `<testsuite name>`. Markers live in
|
|
@@ -200,11 +231,20 @@ module Fp
|
|
|
200
231
|
# bindings: [Binding, ...], # unique (slug, surface, file) evidence
|
|
201
232
|
# unmatched: [TestCase, ...], # testcases with no discoverable marker
|
|
202
233
|
# missing_surface: [Binding-ish], # markers with no surface and no default
|
|
234
|
+
# media: [MediaBinding, ...], # attachments on bound testcases, per surface
|
|
235
|
+
# empty_name: [{slug:, surface:, file:}], # bound testcases with a blank name
|
|
236
|
+
# collisions: [{slug:,surface:, # cells >1 distinct file fed — they overwrite
|
|
237
|
+
# test_type:,files:}], # each other server-side (see detect_collisions)
|
|
203
238
|
# }
|
|
204
239
|
def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
|
|
205
240
|
bindings = {}
|
|
206
241
|
matched = {}
|
|
207
242
|
missing_surface = []
|
|
243
|
+
media = []
|
|
244
|
+
empty_name = []
|
|
245
|
+
# Track which source files feed each evidence cell so we can spot the silent
|
|
246
|
+
# overwrite below (see detect_collisions).
|
|
247
|
+
cell_files = Hash.new { |h, k| h[k] = [] }
|
|
208
248
|
|
|
209
249
|
walk_matches(testcases, base_dir) do |match|
|
|
210
250
|
tc = match.testcase
|
|
@@ -222,15 +262,52 @@ module Fp
|
|
|
222
262
|
if surface.nil? || surface.empty?
|
|
223
263
|
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
224
264
|
else
|
|
265
|
+
cell_files[[marker[:slug], surface, test_type]] << match.repo_file
|
|
225
266
|
record_binding(bindings, marker[:slug], surface, tc, marker,
|
|
226
267
|
titles_reliable: match.precise, file: match.repo_file,
|
|
227
268
|
test_type: test_type)
|
|
269
|
+
|
|
270
|
+
# A bound testcase with no name produces a receipt with no title, which is
|
|
271
|
+
# almost always an upstream reporter problem — flag it rather than filing a
|
|
272
|
+
# nameless receipt silently.
|
|
273
|
+
empty_name << { slug: marker[:slug], surface: surface, file: match.repo_file } if presence(tc.name).nil?
|
|
274
|
+
|
|
275
|
+
# Media the run recorded for this exact test, tied to its example title so
|
|
276
|
+
# each capture nests under the receipt it proves.
|
|
277
|
+
tc.attachments.each do |path|
|
|
278
|
+
media << MediaBinding.new(slug: marker[:slug], surface: surface, path: path,
|
|
279
|
+
example_title: (presence(tc.name) if match.precise))
|
|
280
|
+
end
|
|
228
281
|
end
|
|
229
282
|
end
|
|
230
283
|
end
|
|
231
284
|
|
|
232
285
|
unmatched = testcases.reject { |tc| matched[tc.object_id] }
|
|
233
|
-
{ bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq
|
|
286
|
+
{ bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq,
|
|
287
|
+
media: media, empty_name: empty_name.uniq,
|
|
288
|
+
collisions: detect_collisions(cell_files) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Find evidence cells that more than one *distinct source file* resolved to in a
|
|
292
|
+
# single run. Server-side, evidence identity is (project, slug, surface, repo,
|
|
293
|
+
# test_type) — the file is deliberately not part of it, so one requirement cell is
|
|
294
|
+
# one row. That means two different test files (an RSpec unit suite and a Playwright
|
|
295
|
+
# e2e suite, say) that bind to the same slug+surface and share a test_type both upsert
|
|
296
|
+
# the *same* row: the second POST silently overwrites the first, and only one suite's
|
|
297
|
+
# evidence survives. This is almost always the `unspecified` default colliding —
|
|
298
|
+
# neither suite pinned a `:type` marker nor passed `--test-type`.
|
|
299
|
+
#
|
|
300
|
+
# Returns [{ slug:, surface:, test_type:, files: [sorted unique repo paths] }, ...]
|
|
301
|
+
# for every cell fed by two or more distinct files, so the caller can warn instead of
|
|
302
|
+
# overwriting in silence. A single file feeding a cell is the normal case and is not
|
|
303
|
+
# reported.
|
|
304
|
+
def detect_collisions(cell_files)
|
|
305
|
+
cell_files.filter_map do |(slug, surface, test_type), files|
|
|
306
|
+
distinct = files.compact.uniq
|
|
307
|
+
next if distinct.length < 2
|
|
308
|
+
|
|
309
|
+
{ slug: slug, surface: surface, test_type: test_type, files: distinct.sort }
|
|
310
|
+
end
|
|
234
311
|
end
|
|
235
312
|
|
|
236
313
|
# Aggregate a run's results per (slug, surface) into the pass/fail verdict CI files.
|
|
@@ -346,14 +423,29 @@ module Fp
|
|
|
346
423
|
# can never disagree about which test a marker annotates.
|
|
347
424
|
def walk_matches(testcases, base_dir)
|
|
348
425
|
marker_cache = {}
|
|
426
|
+
resolve_cache = {}
|
|
349
427
|
root = repo_root_for(base_dir)
|
|
350
428
|
|
|
351
429
|
# Group testcases by their source file so we can reason about ordering.
|
|
352
430
|
testcases.group_by(&:file).each do |file, cases|
|
|
353
|
-
|
|
431
|
+
# The report's path may not exist under base_dir (Playwright's classname is
|
|
432
|
+
# relative to its testDir, not the repo or cwd). Find the real file first; its
|
|
433
|
+
# resolved location is what we read markers from and what we report.
|
|
434
|
+
source_path = resolve_source_path(file, base_dir, resolve_cache)
|
|
435
|
+
markers = markers_for(source_path, marker_cache)
|
|
354
436
|
next if markers.empty?
|
|
355
437
|
|
|
356
|
-
|
|
438
|
+
# Report the file relative to the repo root. When the file was found exactly where
|
|
439
|
+
# the report said (the common case), keep the reported path verbatim — outside a
|
|
440
|
+
# git checkout that's all we have, and expanding it would change long-standing
|
|
441
|
+
# output. Only when the search *relocated* the file (Playwright's testDir case) do
|
|
442
|
+
# we report from its real location instead of the path that didn't exist.
|
|
443
|
+
repo_file =
|
|
444
|
+
if source_path && source_path != File.expand_path(file.to_s, base_dir)
|
|
445
|
+
repo_relative_path(source_path, base_dir, root)
|
|
446
|
+
else
|
|
447
|
+
repo_relative_path(file, base_dir, root)
|
|
448
|
+
end
|
|
357
449
|
|
|
358
450
|
cases.each do |tc|
|
|
359
451
|
marker, precise = marker_for(tc, cases, markers)
|
|
@@ -361,7 +453,7 @@ module Fp
|
|
|
361
453
|
|
|
362
454
|
yield Match.new(
|
|
363
455
|
testcase: tc, marker: marker, precise: precise, repo_file: repo_file,
|
|
364
|
-
source_path: File.expand_path(file, base_dir)
|
|
456
|
+
source_path: source_path || File.expand_path(file.to_s, base_dir)
|
|
365
457
|
)
|
|
366
458
|
end
|
|
367
459
|
end
|
|
@@ -466,8 +558,11 @@ module Fp
|
|
|
466
558
|
markers.max_by { |m| [m[:title].length, m[:line]] }
|
|
467
559
|
end
|
|
468
560
|
|
|
469
|
-
#
|
|
470
|
-
|
|
561
|
+
# How runners join an enclosing group's name to the test's. vitest/jest use " > ",
|
|
562
|
+
# Playwright uses " › " (U+203A), and pytest-style ids use "::"
|
|
563
|
+
# (file.py::TestClass::test_name, no surrounding spaces). Matching all three lets a
|
|
564
|
+
# marker bind by title regardless of which runner produced the report.
|
|
565
|
+
NAME_SEPARATOR = /\s+>\s+|\s+›\s+|::/.freeze
|
|
471
566
|
|
|
472
567
|
def title_matches_test?(title, name, segments)
|
|
473
568
|
return false if title.nil? || title.empty?
|
|
@@ -513,23 +608,123 @@ module Fp
|
|
|
513
608
|
line: line, title: (testcase.name if titles_reliable), state: state)
|
|
514
609
|
end
|
|
515
610
|
|
|
516
|
-
# Load and cache the fp:<slug> markers (with line numbers) for
|
|
517
|
-
# source
|
|
518
|
-
def markers_for(
|
|
519
|
-
|
|
520
|
-
return [] if file.nil? || file.empty?
|
|
611
|
+
# Load and cache the fp:<slug> markers (with line numbers) for an already-resolved
|
|
612
|
+
# absolute source path. Returns [] when the path is nil or unreadable.
|
|
613
|
+
def markers_for(source_path, cache)
|
|
614
|
+
return [] if source_path.nil? || source_path.empty?
|
|
521
615
|
|
|
522
|
-
return cache[
|
|
616
|
+
return cache[source_path] if cache.key?(source_path)
|
|
523
617
|
|
|
524
|
-
resolved = File.expand_path(file, base_dir)
|
|
525
618
|
markers =
|
|
526
|
-
if File.file?(
|
|
527
|
-
scan_markers(File.read(
|
|
619
|
+
if File.file?(source_path)
|
|
620
|
+
scan_markers(File.read(source_path))
|
|
528
621
|
else
|
|
529
622
|
[]
|
|
530
623
|
end
|
|
531
624
|
|
|
532
|
-
cache[
|
|
625
|
+
cache[source_path] = markers
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Common directories a test file lives under when the reported path is relative to a
|
|
629
|
+
# runner's testDir rather than the repo/cwd. Tried in order after base_dir itself.
|
|
630
|
+
COMMON_TEST_DIRS = %w[e2e tests test spec specs integration].freeze
|
|
631
|
+
|
|
632
|
+
# How many directory levels deep the last-resort **/<name> glob will search, so an
|
|
633
|
+
# unresolved path doesn't walk a whole monorepo.
|
|
634
|
+
GLOB_SEARCH_DEPTH = 6
|
|
635
|
+
|
|
636
|
+
# Resolve a report's test-file path to an absolute path that actually exists, so
|
|
637
|
+
# markers can be read from it. Returns nil when nothing matches.
|
|
638
|
+
#
|
|
639
|
+
# Playwright's JUnit reporter writes `classname="loyalty.spec.js"` — relative to its
|
|
640
|
+
# `testDir` (e.g. `frontend/e2e/`), not the repo root or the directory the runner was
|
|
641
|
+
# invoked from. So `<base-dir>/loyalty.spec.js` doesn't exist and every testcase ends
|
|
642
|
+
# up "had no fp: marker". Rather than make the user hand-annotate the XML, search:
|
|
643
|
+
#
|
|
644
|
+
# 1. base_dir itself (the path as reported) — the common, unambiguous case;
|
|
645
|
+
# 2. Playwright's testDir, read from a playwright.config.* under base_dir;
|
|
646
|
+
# 3. the usual test directories (e2e/, tests/, test/, spec/, ...);
|
|
647
|
+
# 4. a bounded **/<basename> glob, accepted only when it matches exactly one file.
|
|
648
|
+
#
|
|
649
|
+
# Prints which strategy found the file (unless silent) so a surprising resolution is
|
|
650
|
+
# visible rather than magic. Results are cached per reported path.
|
|
651
|
+
def resolve_source_path(file, base_dir, cache = {}, silent: false)
|
|
652
|
+
return nil if file.nil? || file.empty?
|
|
653
|
+
return cache[file] if cache.key?(file)
|
|
654
|
+
|
|
655
|
+
cache[file] = compute_source_path(file, base_dir, silent: silent)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def compute_source_path(file, base_dir, silent: false)
|
|
659
|
+
direct = File.expand_path(file, base_dir)
|
|
660
|
+
return direct if File.file?(direct)
|
|
661
|
+
|
|
662
|
+
candidates = source_path_candidates(file, base_dir)
|
|
663
|
+
found = candidates.find { |path| File.file?(path) }
|
|
664
|
+
if found
|
|
665
|
+
report_resolution(file, found, base_dir) unless silent
|
|
666
|
+
return found
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
globbed = glob_for_source(file, base_dir)
|
|
670
|
+
if globbed
|
|
671
|
+
report_resolution(file, globbed, base_dir) unless silent
|
|
672
|
+
return globbed
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
nil
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Ordered, non-glob candidate locations for a reported path: Playwright's testDir
|
|
679
|
+
# first (the authoritative answer when a config declares one), then the common test
|
|
680
|
+
# directories. Each is tried both with the full reported path and with just its
|
|
681
|
+
# basename, since Playwright reports a bare filename.
|
|
682
|
+
def source_path_candidates(file, base_dir)
|
|
683
|
+
basename = File.basename(file)
|
|
684
|
+
dirs = [playwright_test_dir(base_dir), *COMMON_TEST_DIRS].compact.uniq
|
|
685
|
+
|
|
686
|
+
dirs.flat_map do |dir|
|
|
687
|
+
base = File.expand_path(dir, base_dir)
|
|
688
|
+
[File.expand_path(file, base), File.join(base, basename)]
|
|
689
|
+
end.uniq
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# The last resort: a bounded glob for the basename anywhere under base_dir. Only
|
|
693
|
+
# accepted when it resolves to exactly one file — an ambiguous match is no better
|
|
694
|
+
# than no match, and silently picking one would bind a marker from the wrong file.
|
|
695
|
+
def glob_for_source(file, base_dir)
|
|
696
|
+
basename = File.basename(file)
|
|
697
|
+
return nil if basename.empty?
|
|
698
|
+
|
|
699
|
+
pattern_dirs = (1..GLOB_SEARCH_DEPTH).map { |d| File.join(base_dir, *Array.new(d, '*')) }
|
|
700
|
+
matches = Dir.glob([File.join(base_dir, basename), *pattern_dirs.map { |p| "#{p}/#{basename}" }])
|
|
701
|
+
.select { |p| File.file?(p) }.uniq
|
|
702
|
+
matches.length == 1 ? File.expand_path(matches.first) : nil
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Read Playwright's `testDir` from a playwright.config.{js,ts,mjs,cjs} under base_dir.
|
|
706
|
+
# The config is JS/TS, not something we can evaluate, so pull the string literal with
|
|
707
|
+
# a conservative regex. Returns nil when there's no config or no testDir in it.
|
|
708
|
+
def playwright_test_dir(base_dir)
|
|
709
|
+
config = Dir.glob(File.join(base_dir, 'playwright.config.{js,ts,mjs,cjs}')).first
|
|
710
|
+
return nil unless config && File.file?(config)
|
|
711
|
+
|
|
712
|
+
contents = File.read(config)
|
|
713
|
+
match = contents.match(/testDir\s*:\s*(['"`])(.+?)\1/)
|
|
714
|
+
match && match[2]
|
|
715
|
+
rescue StandardError
|
|
716
|
+
nil
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# Tell the user which strategy resolved a path that wasn't where the report said,
|
|
720
|
+
# so a surprising resolution is visible. Printed to stderr to stay out of --json.
|
|
721
|
+
def report_resolution(reported, found, base_dir)
|
|
722
|
+
rel = begin
|
|
723
|
+
Pathname.new(found).relative_path_from(Pathname.new(File.expand_path(base_dir))).to_s
|
|
724
|
+
rescue StandardError
|
|
725
|
+
found
|
|
726
|
+
end
|
|
727
|
+
warn "fp: resolved test file '#{reported}' to '#{rel}' (not found at the reported path)"
|
|
533
728
|
end
|
|
534
729
|
|
|
535
730
|
# Scan file contents for fp:<slug>[@surface[,surface...]][:type] markers, returning
|
data/lib/fp/version.rb
CHANGED
data/skills/fp/SKILL.md
CHANGED
|
@@ -336,6 +336,46 @@ This also catches the one regression a green test suite can't: a test that was d
|
|
|
336
336
|
renamed, or had its `fp:` marker stripped. Every remaining test passes, so CI is happy,
|
|
337
337
|
while the requirement quietly lost its only proof.
|
|
338
338
|
|
|
339
|
+
### 9. Fill in the client showroom when a surface is deployed
|
|
340
|
+
|
|
341
|
+
The project's client (the person the software is being built for) sees one page: the
|
|
342
|
+
showroom. It is a welcome note plus a card per surface — a link, a button label, and
|
|
343
|
+
instructions for signing in and trying it. You deployed the surface, created the demo
|
|
344
|
+
accounts, and know the URLs, so you fill the card in; a human retyping it into project
|
|
345
|
+
settings is the step that goes stale.
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
fp showroom --project X # current state; hidden surfaces listed
|
|
349
|
+
fp projects edit X --showroom-welcome-file docs/showroom/welcome.txt
|
|
350
|
+
fp surfaces edit web --project X \
|
|
351
|
+
--showroom-url https://dev.example.com \
|
|
352
|
+
--showroom-label "Open the web app" \
|
|
353
|
+
--showroom-instructions-file docs/showroom/web.txt
|
|
354
|
+
fp showroom media add e2e/results/checkout.webm --project X --surface web \
|
|
355
|
+
--caption "Checking out with a saved card" # on the web card
|
|
356
|
+
fp showroom media add docs/showroom/overview.png --project X # no --surface: the gallery
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Write instructions a non-technical reader can follow: where to click, which account to
|
|
360
|
+
sign in as (a dedicated demo account, not the one your e2e suite mutates), what data is
|
|
361
|
+
already there to look at, and one or two things worth trying. Keep the file in the repo
|
|
362
|
+
(`docs/showroom/<surface>.txt`) so the next deploy can re-send it. A surface with neither
|
|
363
|
+
a link nor instructions is not shown; clear both to take a card down. Cards appear in the
|
|
364
|
+
team's chosen order (`--showroom-position 1` is first); surfaces with no position follow,
|
|
365
|
+
alphabetically.
|
|
366
|
+
|
|
367
|
+
Plenty of clients never open the demo, so show them the thing: put a screenshot or a
|
|
368
|
+
short clip of each surface on its card, and an overview in the gallery under the welcome
|
|
369
|
+
note. The captures your e2e run already recorded are usually the right ones — pick the
|
|
370
|
+
ones that show a finished flow, not a test fixture, and caption them in the client's
|
|
371
|
+
words. Images and video only (png, jpg, gif, webp, mp4, webm, mov; 100 MB); SVG and PDF
|
|
372
|
+
are refused. `fp showroom media` lists what's there with ids, `fp showroom media edit
|
|
373
|
+
<id> --caption ... --position N` reorders or rewords (`--position N` takes slot N and
|
|
374
|
+
shifts the rest; an empty value clears the caption or moves the item last), and `fp showroom
|
|
375
|
+
media remove <id> --yes` takes one down. A surface with media but no link is still shown.
|
|
376
|
+
Feedback and the share link are human-only, so after writing the content tell a person
|
|
377
|
+
it's ready.
|
|
378
|
+
|
|
339
379
|
### Repo-local config (`.featureparity.yml`)
|
|
340
380
|
|
|
341
381
|
If a repo has a `.featureparity.yml`, `--project` is optional — `fp` reads the slug from
|
|
@@ -419,6 +459,11 @@ not as the fix — you still write the proposal.
|
|
|
419
459
|
| List projects | `fp projects` |
|
|
420
460
|
| Create a project | `fp projects create <slug> --name "..." --surfaces api,web` |
|
|
421
461
|
| View / edit a project | `fp projects show <slug>`, `fp projects edit <slug> --name "..."` |
|
|
462
|
+
| Preview the client showroom | `fp showroom --project X` |
|
|
463
|
+
| Write a surface's showroom card | `fp surfaces edit <key> --project X --showroom-url ... --showroom-instructions-file ...` |
|
|
464
|
+
| Write the showroom welcome | `fp projects edit <slug> --showroom-welcome-file ...` |
|
|
465
|
+
| Add a screenshot/video to the showroom | `fp showroom media add <file> --project X [--surface <key>] [--caption "..."]` |
|
|
466
|
+
| List / edit / remove showroom media | `fp showroom media --project X`, `fp showroom media edit <id> ...`, `fp showroom media remove <id> --yes` |
|
|
422
467
|
|
|
423
468
|
All commands support `--json` for machine-readable output.
|
|
424
469
|
|