featureparity 0.0.20 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,10 +7,24 @@ module Fp
7
7
  # fp surfaces --project <slug> - list available surfaces for a project
8
8
  # fp surfaces show <key> --project <slug> - view a single surface's details
9
9
  # fp surfaces add <key> --project <slug> - create a surface
10
+ # fp surfaces edit <key> --project <slug> - change an existing surface
11
+ # fp surfaces delete <key> --project <slug> - delete a surface, with confirmation
10
12
  #
11
13
  # `add` exists because a requirement can only name surfaces that already exist, so an
12
14
  # agent onboarding a project it hasn't seen before would otherwise be blocked waiting
13
15
  # on a human to click through the web app.
16
+ #
17
+ # `edit` and `delete` exist because `add` on an existing key used to make a second
18
+ # surface with the same key, and there was no way to fix that short of a raw API call
19
+ # (Fizzy #1480). The server now refuses a duplicate key with a 409; `add` turns that
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).
14
28
  class Surfaces < Base
15
29
  ADD_FLAGS = {
16
30
  project: :string,
@@ -21,9 +35,45 @@ module Fp
21
35
  default_branch: :string
22
36
  }.freeze
23
37
 
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
46
+
47
+ DELETE_FLAGS = {
48
+ project: :string,
49
+ yes: :boolean
50
+ }.freeze
51
+
24
52
  VALID_KINDS = %w[api web android ios other].freeze
25
53
  VALID_AUDIENCES = %w[customer ops both internal].freeze
26
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
+
27
77
  def run(args)
28
78
  # Subcommand dispatch. Bare `fp surfaces --project X` stays a list, so existing
29
79
  # callers (and the docs) keep working.
@@ -32,6 +82,10 @@ module Fp
32
82
  add(args[1..])
33
83
  when 'show'
34
84
  show(args[1..])
85
+ when 'edit'
86
+ edit(args[1..])
87
+ when 'delete'
88
+ delete(args[1..])
35
89
  else
36
90
  list(args)
37
91
  end
@@ -116,6 +170,15 @@ module Fp
116
170
 
117
171
  result = client.create_surface(project_id, params)
118
172
 
173
+ if !result[:ok] && result[:status] == 409
174
+ output.error(
175
+ "Surface '#{key}' already exists in project '#{opts[:project]}'. " \
176
+ "Change it with: fp surfaces edit #{key} --project #{opts[:project]} [--name ...] [--kind ...] [--repo ...]",
177
+ status: 409
178
+ )
179
+ exit 1
180
+ end
181
+
119
182
  unless result[:ok]
120
183
  output.error(result[:error], status: result[:status])
121
184
  exit 1
@@ -133,6 +196,176 @@ module Fp
133
196
  end
134
197
  end
135
198
 
199
+ def edit(args)
200
+ opts, positional = parse_flags(args, EDIT_FLAGS)
201
+ require_project!(opts)
202
+
203
+ key = positional.first
204
+ if key.nil? || key.strip.empty?
205
+ output.error('Usage: fp surfaces edit <key> --project <slug> [--name "Name"] [--kind ...] ' \
206
+ '[--audience ...] [--repo org/repo] [--default-branch main] [--key new_key] ' \
207
+ '[--showroom-url URL] [--showroom-label "..."] ' \
208
+ '[--showroom-instructions "..." | --showroom-instructions-file PATH]')
209
+ exit 1
210
+ end
211
+
212
+ validate_choice!(opts[:kind], VALID_KINDS, '--kind')
213
+ validate_choice!(opts[:audience], VALID_AUDIENCES, '--audience')
214
+
215
+ # Only what was passed is sent, so an edit never blanks a field it wasn't asked about.
216
+ params = {}
217
+ %i[key name kind audience repo default_branch].each do |field|
218
+ params[field] = opts[field] if opts.key?(field)
219
+ end
220
+ params.merge!(showroom_params(opts))
221
+
222
+ if params.empty?
223
+ output.error('Nothing to change. Pass --name, --kind, --audience, --repo, --default-branch, --key, ' \
224
+ 'or a --showroom-* flag.')
225
+ exit 1
226
+ end
227
+
228
+ project_id = resolve_project_id(opts[:project])
229
+ surface = find_surface!(project_id, key)
230
+
231
+ result = client.update_surface(project_id, surface['id'], params)
232
+
233
+ unless result[:ok]
234
+ output.error(result[:error], status: result[:status])
235
+ exit 1
236
+ end
237
+
238
+ # The update action answers with update!'s return value, not the record, so read
239
+ # the surface back rather than trusting the response body.
240
+ updated = client.get_surface(project_id, surface['id'])
241
+ record = (updated[:ok] && updated[:data]['surface']) || surface.merge(params.transform_keys(&:to_s))
242
+
243
+ output.success({ surface: record, project: opts[:project] }) do
244
+ puts "Updated surface '#{record['key']}' in project '#{opts[:project]}'."
245
+ puts
246
+ puts " Name: #{record['name']}"
247
+ puts " Kind: #{record['kind']}"
248
+ puts " Audience: #{record['audience']}"
249
+ puts " Repo: #{record['repo'] || '(none)'}"
250
+ puts " Branch: #{record['default_branch'] || '(none)'}"
251
+ print_showroom(record, indent: ' ') if params.keys.any? { |field| field.to_s.start_with?('showroom_') }
252
+ end
253
+ end
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
+
302
+ def delete(args)
303
+ opts, positional = parse_flags(args, DELETE_FLAGS)
304
+ require_project!(opts)
305
+
306
+ key = positional.first
307
+ if key.nil? || key.strip.empty?
308
+ output.error('Usage: fp surfaces delete <key> --project <slug> [--yes]')
309
+ exit 1
310
+ end
311
+
312
+ project_id = resolve_project_id(opts[:project])
313
+ surface = find_surface!(project_id, key)
314
+ confirm_delete!(surface, opts)
315
+
316
+ result = client.delete_surface(project_id, surface['id'])
317
+
318
+ unless result[:ok]
319
+ output.error(result[:error], status: result[:status])
320
+ exit 1
321
+ end
322
+
323
+ output.success({ deleted: true, surface: { 'id' => surface['id'], 'key' => surface['key'] },
324
+ project: opts[:project] }) do
325
+ puts "Deleted surface '#{surface['key']}' from project '#{opts[:project]}'."
326
+ end
327
+ end
328
+
329
+ # Same guard as `fp projects delete`: --yes skips the prompt, an interactive
330
+ # terminal types the key back, and anything else is refused.
331
+ def confirm_delete!(surface, opts)
332
+ return if opts[:yes]
333
+
334
+ unless interactive?
335
+ output.error(
336
+ "Refusing to delete surface '#{surface['key']}' without confirmation. " \
337
+ 'Re-run with --yes to confirm from a non-interactive session.'
338
+ )
339
+ exit 1
340
+ end
341
+
342
+ warn "This will delete surface '#{surface['key']}' (#{surface['name']}) from project '#{opts[:project]}'."
343
+ warn 'Evidence filed against it stays behind with nothing to measure.'
344
+ $stderr.print "Type '#{surface['key']}' to confirm: "
345
+ $stderr.flush
346
+
347
+ typed = $stdin.gets&.strip
348
+ return if typed == surface['key']
349
+
350
+ output.error('Aborted. The key did not match; nothing was deleted.')
351
+ exit 1
352
+ end
353
+
354
+ def interactive?
355
+ $stdin.respond_to?(:tty?) && $stdin.tty?
356
+ end
357
+
358
+ def find_surface!(project_id, key)
359
+ result = client.find_surface_by_key(project_id, key)
360
+
361
+ unless result[:ok]
362
+ output.error(result[:error], status: result[:status])
363
+ exit 1
364
+ end
365
+
366
+ result[:data]['surface']
367
+ end
368
+
136
369
  def validate_choice!(value, allowed, flag)
137
370
  return if value.nil? || allowed.include?(value)
138
371
 
@@ -166,12 +399,37 @@ module Fp
166
399
  globs.each { |g| puts " - #{g}" }
167
400
  end
168
401
 
402
+ puts
403
+ print_showroom(surface, indent: '')
404
+
169
405
  puts
170
406
  puts "ID: #{surface['id']}" if surface['id']
171
407
  puts "Created: #{surface['created_at']}" if surface['created_at']
172
408
  puts "Updated: #{surface['updated_at']}" if surface['updated_at']
173
409
  end
174
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
+
175
433
  # test_globs may arrive as a JSON string or an array; normalize to an array for
176
434
  # display. Blank/unparseable → empty (the server falls back to kind defaults).
177
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