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.
@@ -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
- 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
+ }.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, or --key.')
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)
@@ -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,7 @@
3
3
  require_relative 'commands/base'
4
4
  require_relative 'commands/projects'
5
5
  require_relative 'commands/surfaces'
6
+ require_relative 'commands/showroom'
6
7
  require_relative 'commands/list'
7
8
  require_relative 'commands/show'
8
9
  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.22'
5
5
  end
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
 
@@ -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.