featureparity 0.0.21 → 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 +24 -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 +74 -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 +141 -0
- data/lib/fp/commands/surfaces.rb +116 -3
- 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 +38 -1
- metadata +3 -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,14 @@ 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
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
EDIT_FLAGS = ADD_FLAGS.merge(key: :string).merge(SHOWROOM_FLAGS).freeze
|
|
32
46
|
|
|
33
47
|
DELETE_FLAGS = {
|
|
34
48
|
project: :string,
|
|
@@ -38,6 +52,28 @@ module Fp
|
|
|
38
52
|
VALID_KINDS = %w[api web android ios other].freeze
|
|
39
53
|
VALID_AUDIENCES = %w[customer ops both internal].freeze
|
|
40
54
|
|
|
55
|
+
help_summary 'List, view, create, edit, and delete a project\'s surfaces'
|
|
56
|
+
help_usage 'fp surfaces --project <slug>',
|
|
57
|
+
'fp surfaces show <key> --project <slug>',
|
|
58
|
+
'fp surfaces add <key> --project <slug> [--name ...] [--kind K] [--repo org/repo]',
|
|
59
|
+
'fp surfaces edit <key> --project <slug> [flags]',
|
|
60
|
+
'fp surfaces delete <key> --project <slug> [--yes]'
|
|
61
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
62
|
+
help_flag :name, 'Human-readable surface name'
|
|
63
|
+
help_flag :kind, "Surface kind: #{VALID_KINDS.join(', ')}"
|
|
64
|
+
help_flag :audience, "Audience: #{VALID_AUDIENCES.join(', ')}"
|
|
65
|
+
help_flag :repo, 'Repository the surface maps to (org/repo)'
|
|
66
|
+
help_flag :default_branch, 'Default branch for the repo'
|
|
67
|
+
help_flag :showroom_url, 'Client showroom card: the http(s) link a client opens (edit; empty clears)'
|
|
68
|
+
help_flag :showroom_label, 'Client showroom card: the button text for that link (edit)'
|
|
69
|
+
help_flag :showroom_instructions, 'Client showroom card: how to sign in and try the surface (edit)'
|
|
70
|
+
help_flag :showroom_instructions_file, 'Read --showroom-instructions from a file (multi-line)'
|
|
71
|
+
help_flag :yes, 'Skip the typed-key confirmation on delete'
|
|
72
|
+
help_example 'fp surfaces --project stowzilla'
|
|
73
|
+
help_example 'fp surfaces add customer_android --project stowzilla --kind android --repo stowzilla/customer-android'
|
|
74
|
+
help_example 'fp surfaces delete web --project stowzilla --yes'
|
|
75
|
+
help_example 'fp surfaces edit web --project stowzilla --showroom-url https://dev.example.com --showroom-instructions-file docs/showroom/web.txt'
|
|
76
|
+
|
|
41
77
|
def run(args)
|
|
42
78
|
# Subcommand dispatch. Bare `fp surfaces --project X` stays a list, so existing
|
|
43
79
|
# callers (and the docs) keep working.
|
|
@@ -167,7 +203,9 @@ module Fp
|
|
|
167
203
|
key = positional.first
|
|
168
204
|
if key.nil? || key.strip.empty?
|
|
169
205
|
output.error('Usage: fp surfaces edit <key> --project <slug> [--name "Name"] [--kind ...] ' \
|
|
170
|
-
'[--audience ...] [--repo org/repo] [--default-branch main] [--key new_key]'
|
|
206
|
+
'[--audience ...] [--repo org/repo] [--default-branch main] [--key new_key] ' \
|
|
207
|
+
'[--showroom-url URL] [--showroom-label "..."] ' \
|
|
208
|
+
'[--showroom-instructions "..." | --showroom-instructions-file PATH]')
|
|
171
209
|
exit 1
|
|
172
210
|
end
|
|
173
211
|
|
|
@@ -179,9 +217,11 @@ module Fp
|
|
|
179
217
|
%i[key name kind audience repo default_branch].each do |field|
|
|
180
218
|
params[field] = opts[field] if opts.key?(field)
|
|
181
219
|
end
|
|
220
|
+
params.merge!(showroom_params(opts))
|
|
182
221
|
|
|
183
222
|
if params.empty?
|
|
184
|
-
output.error('Nothing to change. Pass --name, --kind, --audience, --repo, --default-branch,
|
|
223
|
+
output.error('Nothing to change. Pass --name, --kind, --audience, --repo, --default-branch, --key, ' \
|
|
224
|
+
'or a --showroom-* flag.')
|
|
185
225
|
exit 1
|
|
186
226
|
end
|
|
187
227
|
|
|
@@ -208,9 +248,57 @@ module Fp
|
|
|
208
248
|
puts " Audience: #{record['audience']}"
|
|
209
249
|
puts " Repo: #{record['repo'] || '(none)'}"
|
|
210
250
|
puts " Branch: #{record['default_branch'] || '(none)'}"
|
|
251
|
+
print_showroom(record, indent: ' ') if params.keys.any? { |field| field.to_s.start_with?('showroom_') }
|
|
211
252
|
end
|
|
212
253
|
end
|
|
213
254
|
|
|
255
|
+
# The showroom fields from --showroom-* flags, as the API expects them. A string
|
|
256
|
+
# flag given as empty clears the field. The URL is checked here with the same rule
|
|
257
|
+
# the server applies (http/https only) so a typo fails before anything is sent.
|
|
258
|
+
def showroom_params(opts)
|
|
259
|
+
if opts.key?(:showroom_instructions) && opts.key?(:showroom_instructions_file)
|
|
260
|
+
output.error('Pass --showroom-instructions or --showroom-instructions-file, not both.')
|
|
261
|
+
exit 1
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
params = {}
|
|
265
|
+
%i[showroom_url showroom_label showroom_instructions].each do |field|
|
|
266
|
+
params[field] = flag_text(opts[field]) if opts.key?(field)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
if opts.key?(:showroom_instructions_file)
|
|
270
|
+
params[:showroom_instructions] = read_text_file!(opts[:showroom_instructions_file],
|
|
271
|
+
'--showroom-instructions-file')
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
url = params[:showroom_url]
|
|
275
|
+
if url && !url.empty? && url !~ %r{\Ahttps?://\S+\z}i
|
|
276
|
+
output.error("Invalid --showroom-url '#{url}'. Must be an http:// or https:// link; " \
|
|
277
|
+
'pass an empty value to clear it.')
|
|
278
|
+
exit 1
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
params
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# A string flag given with no value parses as `true`; treat that as "clear it",
|
|
285
|
+
# the same as an explicit empty string.
|
|
286
|
+
def flag_text(value)
|
|
287
|
+
value == true ? '' : value.to_s
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def read_text_file!(path, flag)
|
|
291
|
+
if path.nil? || path == true || path.to_s.strip.empty?
|
|
292
|
+
output.error("#{flag} needs a path")
|
|
293
|
+
exit 1
|
|
294
|
+
end
|
|
295
|
+
unless File.file?(path)
|
|
296
|
+
output.error("#{flag}: no such file '#{path}'")
|
|
297
|
+
exit 1
|
|
298
|
+
end
|
|
299
|
+
File.read(path).strip
|
|
300
|
+
end
|
|
301
|
+
|
|
214
302
|
def delete(args)
|
|
215
303
|
opts, positional = parse_flags(args, DELETE_FLAGS)
|
|
216
304
|
require_project!(opts)
|
|
@@ -311,12 +399,37 @@ module Fp
|
|
|
311
399
|
globs.each { |g| puts " - #{g}" }
|
|
312
400
|
end
|
|
313
401
|
|
|
402
|
+
puts
|
|
403
|
+
print_showroom(surface, indent: '')
|
|
404
|
+
|
|
314
405
|
puts
|
|
315
406
|
puts "ID: #{surface['id']}" if surface['id']
|
|
316
407
|
puts "Created: #{surface['created_at']}" if surface['created_at']
|
|
317
408
|
puts "Updated: #{surface['updated_at']}" if surface['updated_at']
|
|
318
409
|
end
|
|
319
410
|
|
|
411
|
+
# The surface's client-showroom card. Says plainly whether a client can see the
|
|
412
|
+
# surface, since that is decided by the content (a link or instructions), not a
|
|
413
|
+
# switch, and an agent that just wrote instructions should know they went live.
|
|
414
|
+
def print_showroom(surface, indent:)
|
|
415
|
+
url = text_or_nil(surface['showroom_url'])
|
|
416
|
+
label = text_or_nil(surface['showroom_label'])
|
|
417
|
+
instructions = text_or_nil(surface['showroom_instructions'])
|
|
418
|
+
shown = !(url.nil? && instructions.nil?)
|
|
419
|
+
|
|
420
|
+
puts "#{indent}Showroom: #{shown ? 'shown to clients' : 'hidden (no link or instructions)'}"
|
|
421
|
+
return unless shown
|
|
422
|
+
|
|
423
|
+
puts "#{indent} Link: #{url || '(none)'}#{label ? " [#{label}]" : ''}"
|
|
424
|
+
puts "#{indent} Instructions: #{instructions ? '' : '(none)'}"
|
|
425
|
+
instructions&.each_line { |line| puts "#{indent} #{line.rstrip}" }
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def text_or_nil(value)
|
|
429
|
+
text = value.to_s.strip
|
|
430
|
+
text.empty? ? nil : text
|
|
431
|
+
end
|
|
432
|
+
|
|
320
433
|
# test_globs may arrive as a JSON string or an array; normalize to an array for
|
|
321
434
|
# display. Blank/unparseable → empty (the server falls back to kind defaults).
|
|
322
435
|
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
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,30 @@ 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
|
+
```
|
|
355
|
+
|
|
356
|
+
Write instructions a non-technical reader can follow: where to click, which account to
|
|
357
|
+
sign in as (a dedicated demo account, not the one your e2e suite mutates), what data is
|
|
358
|
+
already there to look at, and one or two things worth trying. Keep the file in the repo
|
|
359
|
+
(`docs/showroom/<surface>.txt`) so the next deploy can re-send it. A surface with neither
|
|
360
|
+
a link nor instructions is not shown; clear both to take a card down. Feedback and the
|
|
361
|
+
share link are human-only, so after writing the content tell a person it's ready.
|
|
362
|
+
|
|
339
363
|
### Repo-local config (`.featureparity.yml`)
|
|
340
364
|
|
|
341
365
|
If a repo has a `.featureparity.yml`, `--project` is optional — `fp` reads the slug from
|
|
@@ -419,6 +443,9 @@ not as the fix — you still write the proposal.
|
|
|
419
443
|
| List projects | `fp projects` |
|
|
420
444
|
| Create a project | `fp projects create <slug> --name "..." --surfaces api,web` |
|
|
421
445
|
| View / edit a project | `fp projects show <slug>`, `fp projects edit <slug> --name "..."` |
|
|
446
|
+
| Preview the client showroom | `fp showroom --project X` |
|
|
447
|
+
| Write a surface's showroom card | `fp surfaces edit <key> --project X --showroom-url ... --showroom-instructions-file ...` |
|
|
448
|
+
| Write the showroom welcome | `fp projects edit <slug> --showroom-welcome-file ...` |
|
|
422
449
|
|
|
423
450
|
All commands support `--json` for machine-readable output.
|
|
424
451
|
|
data/skills/fp/references/cli.md
CHANGED
|
@@ -36,7 +36,7 @@ fp projects # list
|
|
|
36
36
|
fp projects --json
|
|
37
37
|
fp projects show <slug> # details + surfaces
|
|
38
38
|
fp projects create <slug> --name "Name" [--description "..."] [--surfaces api,web]
|
|
39
|
-
fp projects edit <slug> [--name "..."] [--description "..."]
|
|
39
|
+
fp projects edit <slug> [--name "..."] [--description "..."] [--showroom-welcome "..." | --showroom-welcome-file PATH]
|
|
40
40
|
fp projects delete <slug> [--yes]
|
|
41
41
|
```
|
|
42
42
|
|
|
@@ -62,6 +62,7 @@ fp surfaces --project stowzilla --json
|
|
|
62
62
|
fp surfaces show <key> --project stowzilla
|
|
63
63
|
fp surfaces add <key> --project stowzilla [--name "..."] [--kind api|web|android|ios|other] [--audience ...] [--repo org/repo] [--default-branch main]
|
|
64
64
|
fp surfaces edit <key> --project stowzilla [--name "..."] [--kind ...] [--audience ...] [--repo ...] [--default-branch ...] [--key new_key]
|
|
65
|
+
fp surfaces edit <key> --project stowzilla [--showroom-url URL] [--showroom-label "..."] [--showroom-instructions "..." | --showroom-instructions-file PATH]
|
|
65
66
|
fp surfaces delete <key> --project stowzilla [--yes]
|
|
66
67
|
```
|
|
67
68
|
|
|
@@ -74,6 +75,42 @@ A surface that requirements still require can't be deleted or re-keyed — the s
|
|
|
74
75
|
refuses and names them. Taking a surface off a requirement is a human's call, so ask
|
|
75
76
|
one rather than working around it.
|
|
76
77
|
|
|
78
|
+
### fp showroom
|
|
79
|
+
|
|
80
|
+
The client showroom is the one page a client of the project sees: the project's
|
|
81
|
+
welcome note, then a card per surface with a link, a button label, and instructions
|
|
82
|
+
for signing in and trying it. The team's agents keep that content; this command
|
|
83
|
+
shows the result.
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
fp showroom --project stowzilla # what a client sees, plus which surfaces are hidden and why
|
|
87
|
+
fp showroom --project stowzilla --json # { showroom, hidden, share_link }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Writing the content:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
fp projects edit stowzilla --showroom-welcome "Hi Sam, here's what we built."
|
|
94
|
+
fp projects edit stowzilla --showroom-welcome-file docs/showroom/welcome.txt
|
|
95
|
+
fp surfaces edit web --project stowzilla \
|
|
96
|
+
--showroom-url https://dev.example.com \
|
|
97
|
+
--showroom-label "Open the web app" \
|
|
98
|
+
--showroom-instructions-file docs/showroom/web.txt
|
|
99
|
+
fp surfaces edit ios --project stowzilla --showroom-url "" --showroom-instructions "" # take the card down
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A surface is on the showroom once it has a link or instructions, and leaves it when
|
|
103
|
+
both are blank; there is no separate on/off switch, so `fp showroom` lists the hidden
|
|
104
|
+
surfaces so a card that dropped out is noticed. Links must be `http://` or `https://`
|
|
105
|
+
(the CLI checks before sending). The `-file` variants are for anything longer than a
|
|
106
|
+
line: the showroom keeps line breaks, so write real paragraphs. Only the flags you pass
|
|
107
|
+
are sent.
|
|
108
|
+
|
|
109
|
+
Two things are deliberately not available to an agent's key: leaving feedback (that is
|
|
110
|
+
the client talking) and turning the share link on or off (publishing a link anyone can
|
|
111
|
+
open is a person's decision). `fp showroom` reports the share link as `unknown` for
|
|
112
|
+
that reason; a human checks it under Project Settings → Showroom in the web app.
|
|
113
|
+
|
|
77
114
|
### fp list
|
|
78
115
|
|
|
79
116
|
List requirements.
|