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.
@@ -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
- EDIT_FLAGS = ADD_FLAGS.merge(key: :string).freeze
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, or --key.')
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)
@@ -4,6 +4,9 @@ module Fp
4
4
  module Commands
5
5
  # fp version
6
6
  class Version < Base
7
+ help_summary 'Show the installed fp version'
8
+ help_usage 'fp version'
9
+
7
10
  def run(_args)
8
11
  output.success({ version: Fp::VERSION }) do
9
12
  puts "fp #{Fp::VERSION}"
@@ -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, keyword_init: true) do
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
- markers = markers_for(cases.first, base_dir, marker_cache)
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
- repo_file = repo_relative_path(file, base_dir, root)
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
- # vitest/jest join describe names and the test name with " > ".
470
- NAME_SEPARATOR = / > /.freeze
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 a testcase's
517
- # source file. Returns [] when the file is unknown or unreadable.
518
- def markers_for(testcase, base_dir, cache)
519
- file = testcase.file
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[file] if cache.key?(file)
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?(resolved)
527
- scan_markers(File.read(resolved))
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[file] = markers
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.21'
4
+ VERSION = '0.0.23'
5
5
  end
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