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.
- checksums.yaml +4 -4
- data/lib/fp/cli.rb +40 -0
- data/lib/fp/client.rb +34 -0
- data/lib/fp/commands/analyze.rb +10 -0
- data/lib/fp/commands/attach.rb +109 -39
- data/lib/fp/commands/attachments.rb +6 -0
- data/lib/fp/commands/base.rb +136 -0
- data/lib/fp/commands/changed_tests.rb +12 -0
- data/lib/fp/commands/changes.rb +9 -0
- data/lib/fp/commands/check.rb +41 -3
- data/lib/fp/commands/ci_report.rb +17 -0
- data/lib/fp/commands/comment.rb +10 -0
- data/lib/fp/commands/config_cmd.rb +9 -0
- data/lib/fp/commands/conversation.rb +5 -0
- data/lib/fp/commands/export.rb +10 -0
- data/lib/fp/commands/help.rb +81 -5
- data/lib/fp/commands/history.rb +20 -1
- data/lib/fp/commands/init.rb +50 -1
- data/lib/fp/commands/list.rb +12 -0
- data/lib/fp/commands/matrix.rb +9 -0
- data/lib/fp/commands/profile.rb +9 -0
- data/lib/fp/commands/projects.rb +63 -3
- data/lib/fp/commands/propose.rb +18 -0
- data/lib/fp/commands/propose_change.rb +73 -2
- data/lib/fp/commands/report.rb +157 -7
- data/lib/fp/commands/repos.rb +11 -0
- data/lib/fp/commands/setup.rb +11 -0
- data/lib/fp/commands/show.rb +42 -3
- data/lib/fp/commands/showroom.rb +141 -0
- data/lib/fp/commands/surfaces.rb +258 -0
- data/lib/fp/commands/version.rb +3 -0
- data/lib/fp/commands/whoami.rb +6 -0
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/junit.rb +213 -18
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +27 -0
- data/skills/fp/references/cli.md +52 -2
- metadata +3 -2
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -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)
|
data/lib/fp/commands/version.rb
CHANGED
data/lib/fp/commands/whoami.rb
CHANGED
|
@@ -10,6 +10,12 @@ module Fp
|
|
|
10
10
|
# (masked — never the full token). Purely local; makes no API calls, so it
|
|
11
11
|
# works even without a valid key. Honors --profile / --api-url / --json.
|
|
12
12
|
class Whoami < Base
|
|
13
|
+
help_summary 'Show the resolved config (profile, API URL, environment, masked key)'
|
|
14
|
+
help_usage 'fp whoami [--json]'
|
|
15
|
+
help_example 'fp whoami'
|
|
16
|
+
help_example 'fp --profile staging whoami'
|
|
17
|
+
help_note 'Purely local; makes no API calls, so it works even without a valid key.'
|
|
18
|
+
|
|
13
19
|
def run(_args)
|
|
14
20
|
r = config.resolved
|
|
15
21
|
|
data/lib/fp/commands.rb
CHANGED
data/lib/fp/junit.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rexml/document'
|
|
4
|
+
require 'pathname'
|
|
4
5
|
require_relative 'source_fingerprint'
|
|
5
6
|
|
|
6
7
|
module Fp
|
|
@@ -18,7 +19,8 @@ module Fp
|
|
|
18
19
|
# is the runner's own verdict, which only CI is allowed to act on (`fp ci-report`).
|
|
19
20
|
# Both are kept because they answer different questions: a red test still proves the
|
|
20
21
|
# test exists (present), it just doesn't prove the requirement is met.
|
|
21
|
-
TestCase = Struct.new(:name, :classname, :file, :line, :status, :failed,
|
|
22
|
+
TestCase = Struct.new(:name, :classname, :file, :line, :status, :failed, :attachments,
|
|
23
|
+
keyword_init: true) do
|
|
22
24
|
# Agents only ever report present/stub — never passing/failing (that's CI).
|
|
23
25
|
# A skipped test is treated as a stub; anything else counts as present.
|
|
24
26
|
def stub?
|
|
@@ -28,6 +30,12 @@ module Fp
|
|
|
28
30
|
def failed?
|
|
29
31
|
!!failed
|
|
30
32
|
end
|
|
33
|
+
|
|
34
|
+
# Media (videos, screenshots, traces) the runner recorded for this test — e.g.
|
|
35
|
+
# Playwright's `[[ATTACHMENT|path]]` lines. Always an array.
|
|
36
|
+
def attachments
|
|
37
|
+
self[:attachments] || []
|
|
38
|
+
end
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
# One requirement's aggregated CI outcome for one surface, ready to hand to the
|
|
@@ -77,6 +85,12 @@ module Fp
|
|
|
77
85
|
# test, so the marker line is a close stand-in when the runner omits lines).
|
|
78
86
|
Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, keyword_init: true)
|
|
79
87
|
|
|
88
|
+
# A media file (video/screenshot) a run recorded for one bound testcase, ready for
|
|
89
|
+
# `fp report --junit --attach-media` to upload as visual evidence. Unlike Binding,
|
|
90
|
+
# these are per-testcase, not aggregated: a run can record a distinct video for every
|
|
91
|
+
# test, and `example_title` is what nests each capture under the receipt it proves.
|
|
92
|
+
MediaBinding = Struct.new(:slug, :surface, :path, :example_title, keyword_init: true)
|
|
93
|
+
|
|
80
94
|
# Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
|
|
81
95
|
# `@surface1,surface2`) suffix to target one or more specific surfaces, and an
|
|
82
96
|
# optional `:<type>` suffix to pin the kind of test:
|
|
@@ -132,13 +146,30 @@ module Fp
|
|
|
132
146
|
file: resolve_file(el.attributes['file'], classname, suite_name),
|
|
133
147
|
line: (el.attributes['line'] && el.attributes['line'].to_i),
|
|
134
148
|
status: testcase_status(el),
|
|
135
|
-
failed: testcase_failed?(el)
|
|
149
|
+
failed: testcase_failed?(el),
|
|
150
|
+
attachments: extract_attachments(el)
|
|
136
151
|
)
|
|
137
152
|
end
|
|
138
153
|
|
|
139
154
|
cases
|
|
140
155
|
end
|
|
141
156
|
|
|
157
|
+
# Playwright's JUnit reporter records media (video, screenshots, trace) by writing
|
|
158
|
+
# `[[ATTACHMENT|<path>]]` lines into the testcase's `<system-out>`. Pull those paths
|
|
159
|
+
# out so `fp report --junit --attach-media` can upload them as visual evidence for the
|
|
160
|
+
# bound requirement, instead of a hand-matched `fp attach` per file.
|
|
161
|
+
#
|
|
162
|
+
# The path is relative to wherever Playwright ran; it's resolved against --base-dir at
|
|
163
|
+
# upload time, the same way test-file paths are.
|
|
164
|
+
ATTACHMENT_RE = /\[\[ATTACHMENT\|([^\]]+)\]\]/.freeze
|
|
165
|
+
|
|
166
|
+
def extract_attachments(el)
|
|
167
|
+
text = el.get_elements('system-out').map(&:text).compact.join("\n")
|
|
168
|
+
return [] if text.empty?
|
|
169
|
+
|
|
170
|
+
text.scan(ATTACHMENT_RE).map { |(path)| path.strip }.reject(&:empty?).uniq
|
|
171
|
+
end
|
|
172
|
+
|
|
142
173
|
# Runners disagree about where the source file goes. Minitest- and RSpec-style reports
|
|
143
174
|
# put it on the testcase's `file` attribute; vitest and jest omit `file` entirely and
|
|
144
175
|
# carry the path in `classname` and the enclosing `<testsuite name>`. Markers live in
|
|
@@ -200,11 +231,20 @@ module Fp
|
|
|
200
231
|
# bindings: [Binding, ...], # unique (slug, surface, file) evidence
|
|
201
232
|
# unmatched: [TestCase, ...], # testcases with no discoverable marker
|
|
202
233
|
# missing_surface: [Binding-ish], # markers with no surface and no default
|
|
234
|
+
# media: [MediaBinding, ...], # attachments on bound testcases, per surface
|
|
235
|
+
# empty_name: [{slug:, surface:, file:}], # bound testcases with a blank name
|
|
236
|
+
# collisions: [{slug:,surface:, # cells >1 distinct file fed — they overwrite
|
|
237
|
+
# test_type:,files:}], # each other server-side (see detect_collisions)
|
|
203
238
|
# }
|
|
204
239
|
def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
|
|
205
240
|
bindings = {}
|
|
206
241
|
matched = {}
|
|
207
242
|
missing_surface = []
|
|
243
|
+
media = []
|
|
244
|
+
empty_name = []
|
|
245
|
+
# Track which source files feed each evidence cell so we can spot the silent
|
|
246
|
+
# overwrite below (see detect_collisions).
|
|
247
|
+
cell_files = Hash.new { |h, k| h[k] = [] }
|
|
208
248
|
|
|
209
249
|
walk_matches(testcases, base_dir) do |match|
|
|
210
250
|
tc = match.testcase
|
|
@@ -222,15 +262,52 @@ module Fp
|
|
|
222
262
|
if surface.nil? || surface.empty?
|
|
223
263
|
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
224
264
|
else
|
|
265
|
+
cell_files[[marker[:slug], surface, test_type]] << match.repo_file
|
|
225
266
|
record_binding(bindings, marker[:slug], surface, tc, marker,
|
|
226
267
|
titles_reliable: match.precise, file: match.repo_file,
|
|
227
268
|
test_type: test_type)
|
|
269
|
+
|
|
270
|
+
# A bound testcase with no name produces a receipt with no title, which is
|
|
271
|
+
# almost always an upstream reporter problem — flag it rather than filing a
|
|
272
|
+
# nameless receipt silently.
|
|
273
|
+
empty_name << { slug: marker[:slug], surface: surface, file: match.repo_file } if presence(tc.name).nil?
|
|
274
|
+
|
|
275
|
+
# Media the run recorded for this exact test, tied to its example title so
|
|
276
|
+
# each capture nests under the receipt it proves.
|
|
277
|
+
tc.attachments.each do |path|
|
|
278
|
+
media << MediaBinding.new(slug: marker[:slug], surface: surface, path: path,
|
|
279
|
+
example_title: (presence(tc.name) if match.precise))
|
|
280
|
+
end
|
|
228
281
|
end
|
|
229
282
|
end
|
|
230
283
|
end
|
|
231
284
|
|
|
232
285
|
unmatched = testcases.reject { |tc| matched[tc.object_id] }
|
|
233
|
-
{ bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq
|
|
286
|
+
{ bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq,
|
|
287
|
+
media: media, empty_name: empty_name.uniq,
|
|
288
|
+
collisions: detect_collisions(cell_files) }
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Find evidence cells that more than one *distinct source file* resolved to in a
|
|
292
|
+
# single run. Server-side, evidence identity is (project, slug, surface, repo,
|
|
293
|
+
# test_type) — the file is deliberately not part of it, so one requirement cell is
|
|
294
|
+
# one row. That means two different test files (an RSpec unit suite and a Playwright
|
|
295
|
+
# e2e suite, say) that bind to the same slug+surface and share a test_type both upsert
|
|
296
|
+
# the *same* row: the second POST silently overwrites the first, and only one suite's
|
|
297
|
+
# evidence survives. This is almost always the `unspecified` default colliding —
|
|
298
|
+
# neither suite pinned a `:type` marker nor passed `--test-type`.
|
|
299
|
+
#
|
|
300
|
+
# Returns [{ slug:, surface:, test_type:, files: [sorted unique repo paths] }, ...]
|
|
301
|
+
# for every cell fed by two or more distinct files, so the caller can warn instead of
|
|
302
|
+
# overwriting in silence. A single file feeding a cell is the normal case and is not
|
|
303
|
+
# reported.
|
|
304
|
+
def detect_collisions(cell_files)
|
|
305
|
+
cell_files.filter_map do |(slug, surface, test_type), files|
|
|
306
|
+
distinct = files.compact.uniq
|
|
307
|
+
next if distinct.length < 2
|
|
308
|
+
|
|
309
|
+
{ slug: slug, surface: surface, test_type: test_type, files: distinct.sort }
|
|
310
|
+
end
|
|
234
311
|
end
|
|
235
312
|
|
|
236
313
|
# Aggregate a run's results per (slug, surface) into the pass/fail verdict CI files.
|
|
@@ -346,14 +423,29 @@ module Fp
|
|
|
346
423
|
# can never disagree about which test a marker annotates.
|
|
347
424
|
def walk_matches(testcases, base_dir)
|
|
348
425
|
marker_cache = {}
|
|
426
|
+
resolve_cache = {}
|
|
349
427
|
root = repo_root_for(base_dir)
|
|
350
428
|
|
|
351
429
|
# Group testcases by their source file so we can reason about ordering.
|
|
352
430
|
testcases.group_by(&:file).each do |file, cases|
|
|
353
|
-
|
|
431
|
+
# The report's path may not exist under base_dir (Playwright's classname is
|
|
432
|
+
# relative to its testDir, not the repo or cwd). Find the real file first; its
|
|
433
|
+
# resolved location is what we read markers from and what we report.
|
|
434
|
+
source_path = resolve_source_path(file, base_dir, resolve_cache)
|
|
435
|
+
markers = markers_for(source_path, marker_cache)
|
|
354
436
|
next if markers.empty?
|
|
355
437
|
|
|
356
|
-
|
|
438
|
+
# Report the file relative to the repo root. When the file was found exactly where
|
|
439
|
+
# the report said (the common case), keep the reported path verbatim — outside a
|
|
440
|
+
# git checkout that's all we have, and expanding it would change long-standing
|
|
441
|
+
# output. Only when the search *relocated* the file (Playwright's testDir case) do
|
|
442
|
+
# we report from its real location instead of the path that didn't exist.
|
|
443
|
+
repo_file =
|
|
444
|
+
if source_path && source_path != File.expand_path(file.to_s, base_dir)
|
|
445
|
+
repo_relative_path(source_path, base_dir, root)
|
|
446
|
+
else
|
|
447
|
+
repo_relative_path(file, base_dir, root)
|
|
448
|
+
end
|
|
357
449
|
|
|
358
450
|
cases.each do |tc|
|
|
359
451
|
marker, precise = marker_for(tc, cases, markers)
|
|
@@ -361,7 +453,7 @@ module Fp
|
|
|
361
453
|
|
|
362
454
|
yield Match.new(
|
|
363
455
|
testcase: tc, marker: marker, precise: precise, repo_file: repo_file,
|
|
364
|
-
source_path: File.expand_path(file, base_dir)
|
|
456
|
+
source_path: source_path || File.expand_path(file.to_s, base_dir)
|
|
365
457
|
)
|
|
366
458
|
end
|
|
367
459
|
end
|
|
@@ -466,8 +558,11 @@ module Fp
|
|
|
466
558
|
markers.max_by { |m| [m[:title].length, m[:line]] }
|
|
467
559
|
end
|
|
468
560
|
|
|
469
|
-
#
|
|
470
|
-
|
|
561
|
+
# How runners join an enclosing group's name to the test's. vitest/jest use " > ",
|
|
562
|
+
# Playwright uses " › " (U+203A), and pytest-style ids use "::"
|
|
563
|
+
# (file.py::TestClass::test_name, no surrounding spaces). Matching all three lets a
|
|
564
|
+
# marker bind by title regardless of which runner produced the report.
|
|
565
|
+
NAME_SEPARATOR = /\s+>\s+|\s+›\s+|::/.freeze
|
|
471
566
|
|
|
472
567
|
def title_matches_test?(title, name, segments)
|
|
473
568
|
return false if title.nil? || title.empty?
|
|
@@ -513,23 +608,123 @@ module Fp
|
|
|
513
608
|
line: line, title: (testcase.name if titles_reliable), state: state)
|
|
514
609
|
end
|
|
515
610
|
|
|
516
|
-
# Load and cache the fp:<slug> markers (with line numbers) for
|
|
517
|
-
# source
|
|
518
|
-
def markers_for(
|
|
519
|
-
|
|
520
|
-
return [] if file.nil? || file.empty?
|
|
611
|
+
# Load and cache the fp:<slug> markers (with line numbers) for an already-resolved
|
|
612
|
+
# absolute source path. Returns [] when the path is nil or unreadable.
|
|
613
|
+
def markers_for(source_path, cache)
|
|
614
|
+
return [] if source_path.nil? || source_path.empty?
|
|
521
615
|
|
|
522
|
-
return cache[
|
|
616
|
+
return cache[source_path] if cache.key?(source_path)
|
|
523
617
|
|
|
524
|
-
resolved = File.expand_path(file, base_dir)
|
|
525
618
|
markers =
|
|
526
|
-
if File.file?(
|
|
527
|
-
scan_markers(File.read(
|
|
619
|
+
if File.file?(source_path)
|
|
620
|
+
scan_markers(File.read(source_path))
|
|
528
621
|
else
|
|
529
622
|
[]
|
|
530
623
|
end
|
|
531
624
|
|
|
532
|
-
cache[
|
|
625
|
+
cache[source_path] = markers
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Common directories a test file lives under when the reported path is relative to a
|
|
629
|
+
# runner's testDir rather than the repo/cwd. Tried in order after base_dir itself.
|
|
630
|
+
COMMON_TEST_DIRS = %w[e2e tests test spec specs integration].freeze
|
|
631
|
+
|
|
632
|
+
# How many directory levels deep the last-resort **/<name> glob will search, so an
|
|
633
|
+
# unresolved path doesn't walk a whole monorepo.
|
|
634
|
+
GLOB_SEARCH_DEPTH = 6
|
|
635
|
+
|
|
636
|
+
# Resolve a report's test-file path to an absolute path that actually exists, so
|
|
637
|
+
# markers can be read from it. Returns nil when nothing matches.
|
|
638
|
+
#
|
|
639
|
+
# Playwright's JUnit reporter writes `classname="loyalty.spec.js"` — relative to its
|
|
640
|
+
# `testDir` (e.g. `frontend/e2e/`), not the repo root or the directory the runner was
|
|
641
|
+
# invoked from. So `<base-dir>/loyalty.spec.js` doesn't exist and every testcase ends
|
|
642
|
+
# up "had no fp: marker". Rather than make the user hand-annotate the XML, search:
|
|
643
|
+
#
|
|
644
|
+
# 1. base_dir itself (the path as reported) — the common, unambiguous case;
|
|
645
|
+
# 2. Playwright's testDir, read from a playwright.config.* under base_dir;
|
|
646
|
+
# 3. the usual test directories (e2e/, tests/, test/, spec/, ...);
|
|
647
|
+
# 4. a bounded **/<basename> glob, accepted only when it matches exactly one file.
|
|
648
|
+
#
|
|
649
|
+
# Prints which strategy found the file (unless silent) so a surprising resolution is
|
|
650
|
+
# visible rather than magic. Results are cached per reported path.
|
|
651
|
+
def resolve_source_path(file, base_dir, cache = {}, silent: false)
|
|
652
|
+
return nil if file.nil? || file.empty?
|
|
653
|
+
return cache[file] if cache.key?(file)
|
|
654
|
+
|
|
655
|
+
cache[file] = compute_source_path(file, base_dir, silent: silent)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def compute_source_path(file, base_dir, silent: false)
|
|
659
|
+
direct = File.expand_path(file, base_dir)
|
|
660
|
+
return direct if File.file?(direct)
|
|
661
|
+
|
|
662
|
+
candidates = source_path_candidates(file, base_dir)
|
|
663
|
+
found = candidates.find { |path| File.file?(path) }
|
|
664
|
+
if found
|
|
665
|
+
report_resolution(file, found, base_dir) unless silent
|
|
666
|
+
return found
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
globbed = glob_for_source(file, base_dir)
|
|
670
|
+
if globbed
|
|
671
|
+
report_resolution(file, globbed, base_dir) unless silent
|
|
672
|
+
return globbed
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
nil
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Ordered, non-glob candidate locations for a reported path: Playwright's testDir
|
|
679
|
+
# first (the authoritative answer when a config declares one), then the common test
|
|
680
|
+
# directories. Each is tried both with the full reported path and with just its
|
|
681
|
+
# basename, since Playwright reports a bare filename.
|
|
682
|
+
def source_path_candidates(file, base_dir)
|
|
683
|
+
basename = File.basename(file)
|
|
684
|
+
dirs = [playwright_test_dir(base_dir), *COMMON_TEST_DIRS].compact.uniq
|
|
685
|
+
|
|
686
|
+
dirs.flat_map do |dir|
|
|
687
|
+
base = File.expand_path(dir, base_dir)
|
|
688
|
+
[File.expand_path(file, base), File.join(base, basename)]
|
|
689
|
+
end.uniq
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# The last resort: a bounded glob for the basename anywhere under base_dir. Only
|
|
693
|
+
# accepted when it resolves to exactly one file — an ambiguous match is no better
|
|
694
|
+
# than no match, and silently picking one would bind a marker from the wrong file.
|
|
695
|
+
def glob_for_source(file, base_dir)
|
|
696
|
+
basename = File.basename(file)
|
|
697
|
+
return nil if basename.empty?
|
|
698
|
+
|
|
699
|
+
pattern_dirs = (1..GLOB_SEARCH_DEPTH).map { |d| File.join(base_dir, *Array.new(d, '*')) }
|
|
700
|
+
matches = Dir.glob([File.join(base_dir, basename), *pattern_dirs.map { |p| "#{p}/#{basename}" }])
|
|
701
|
+
.select { |p| File.file?(p) }.uniq
|
|
702
|
+
matches.length == 1 ? File.expand_path(matches.first) : nil
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Read Playwright's `testDir` from a playwright.config.{js,ts,mjs,cjs} under base_dir.
|
|
706
|
+
# The config is JS/TS, not something we can evaluate, so pull the string literal with
|
|
707
|
+
# a conservative regex. Returns nil when there's no config or no testDir in it.
|
|
708
|
+
def playwright_test_dir(base_dir)
|
|
709
|
+
config = Dir.glob(File.join(base_dir, 'playwright.config.{js,ts,mjs,cjs}')).first
|
|
710
|
+
return nil unless config && File.file?(config)
|
|
711
|
+
|
|
712
|
+
contents = File.read(config)
|
|
713
|
+
match = contents.match(/testDir\s*:\s*(['"`])(.+?)\1/)
|
|
714
|
+
match && match[2]
|
|
715
|
+
rescue StandardError
|
|
716
|
+
nil
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
# Tell the user which strategy resolved a path that wasn't where the report said,
|
|
720
|
+
# so a surprising resolution is visible. Printed to stderr to stay out of --json.
|
|
721
|
+
def report_resolution(reported, found, base_dir)
|
|
722
|
+
rel = begin
|
|
723
|
+
Pathname.new(found).relative_path_from(Pathname.new(File.expand_path(base_dir))).to_s
|
|
724
|
+
rescue StandardError
|
|
725
|
+
found
|
|
726
|
+
end
|
|
727
|
+
warn "fp: resolved test file '#{reported}' to '#{rel}' (not found at the reported path)"
|
|
533
728
|
end
|
|
534
729
|
|
|
535
730
|
# Scan file contents for fp:<slug>[@surface[,surface...]][:type] markers, returning
|