• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pulibrary / pdc_describe / 9091a1ae-29be-458c-984a-339d213919c4

12 Dec 2024 07:41PM UTC coverage: 26.434% (-69.7%) from 96.113%
9091a1ae-29be-458c-984a-339d213919c4

Pull #2000

circleci

jrgriffiniii
Removing integration with ActiveStorage
Pull Request #2000: Bump actionpack from 7.2.1.1 to 7.2.2.1

945 of 3575 relevant lines covered (26.43%)

0.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

43.21
/app/models/work.rb
1
# frozen_string_literal: true
2

3
# rubocop:disable Metrics/ClassLength
4
class Work < ApplicationRecord
1✔
5
  # Errors for cases where there is no valid Group
6
  class InvalidGroupError < ::ArgumentError; end
1✔
7

8
  has_many :work_activity, -> { order(updated_at: :desc) }, dependent: :destroy
1✔
9
  has_many :user_work, -> { order(updated_at: :desc) }, dependent: :destroy
1✔
10
  has_many :upload_snapshots, -> { order(updated_at: :desc) }, dependent: :destroy
1✔
11

12
  belongs_to :group, class_name: "Group"
1✔
13
  belongs_to :curator, class_name: "User", foreign_key: "curator_user_id", optional: true
1✔
14

15
  attribute :work_type, :string, default: "DATASET"
1✔
16
  attribute :profile, :string, default: "DATACITE"
1✔
17

18
  attr_accessor :user_entered_doi
1✔
19

20
  alias state_history user_work
1✔
21

22
  delegate :valid_to_submit, :valid_to_draft, :valid_to_approve, :valid_to_complete, to: :work_validator
1✔
23

24
  include AASM
1✔
25

26
  aasm column: :state do
1✔
27
    state :none, initial: true
1✔
28
    state :draft, :awaiting_approval, :approved, :withdrawn, :deletion_marker
1✔
29

30
    event :draft, after: :draft_doi do
1✔
31
      transitions from: :none, to: :draft, guard: :valid_to_draft
1✔
32
    end
33

34
    event :complete_submission do
1✔
35
      transitions from: :draft, to: :awaiting_approval, guard: :valid_to_complete
1✔
36
    end
37

38
    event :request_changes do
1✔
39
      transitions from: :awaiting_approval, to: :awaiting_approval, guard: :valid_to_submit
1✔
40
    end
41

42
    event :revert_to_draft do
1✔
43
      transitions from: :awaiting_approval, to: :draft, guard: :valid_to_draft
1✔
44
    end
45

46
    event :approve do
1✔
47
      transitions from: :awaiting_approval, to: :approved, guard: :valid_to_approve, after: :publish
1✔
48
    end
49

50
    event :withdraw do
1✔
51
      transitions from: [:draft, :awaiting_approval, :approved], to: :withdrawn
1✔
52
    end
53

54
    event :resubmit do
1✔
55
      transitions from: :withdrawn, to: :draft
1✔
56
    end
57

58
    event :remove do
1✔
59
      transitions from: :withdrawn, to: :deletion_marker
1✔
60
    end
61

62
    after_all_events :track_state_change
1✔
63
  end
64

65
  def state=(new_state)
1✔
66
    new_state_sym = new_state.to_sym
×
67
    valid_states = self.class.aasm.states.map(&:name)
×
68
    raise(StandardError, "Invalid state '#{new_state}'") unless valid_states.include?(new_state_sym)
×
69
    aasm_write_state_without_persistence(new_state_sym)
×
70
  end
71

72
  ##
73
  # Is this work editable by a given user?
74
  # A work is editable when:
75
  # * it is being edited by the person who made it
76
  # * it is being edited by a group admin of the group where is resides
77
  # * it is being edited by a super admin
78
  # @param [User]
79
  # @return [Boolean]
80
  def editable_by?(user)
1✔
81
    submitted_by?(user) || administered_by?(user)
×
82
  end
83

84
  def editable_in_current_state?(user)
1✔
85
    # anyone with edit privleges can edit a work while it is in draft
86
    return editable_by?(user) if draft?
×
87

88
    # Only admisitrators can edit a work in other states
89
    administered_by?(user)
×
90
  end
91

92
  def submitted_by?(user)
1✔
93
    created_by_user_id == user.id
×
94
  end
95

96
  def administered_by?(user)
1✔
97
    user.has_role?(:group_admin, group)
×
98
  end
99

100
  class << self
1✔
101
    def find_by_doi(doi)
1✔
102
      prefix = "10.34770/"
×
103
      doi = "#{prefix}#{doi}" unless doi.blank? || doi.start_with?(prefix)
×
104
      Work.find_by!("metadata @> ?", JSON.dump(doi:))
×
105
    end
106

107
    def find_by_ark(ark)
1✔
108
      prefix = "ark:/"
×
109
      ark = "#{prefix}#{ark}" unless ark.blank? || ark.start_with?(prefix)
×
110
      Work.find_by!("metadata @> ?", JSON.dump(ark:))
×
111
    end
112

113
    delegate :resource_type_general_values, to: PDCMetadata::Resource
1✔
114
  end
115

116
  include Rails.application.routes.url_helpers
1✔
117

118
  before_save do |work|
1✔
119
    # Ensure that the metadata JSONB postgres field is persisted properly
120
    work.metadata = JSON.parse(work.resource.to_json)
×
121
  end
122

123
  after_save do |work|
1✔
124
    if work.approved?
×
125
      work.reload
×
126
    end
127
  end
128

129
  validate do |_work|
1✔
130
    work_validator.valid?
×
131
  end
132

133
  # Overload ActiveRecord.reload method
134
  # https://apidock.com/rails/ActiveRecord/Base/reload
135
  #
136
  # NOTE: Usually `after_save` is a better place to put this kind of code:
137
  #
138
  #   after_save do |work|
139
  #     work.resource = nil
140
  #   end
141
  #
142
  # but that does not work in this case because the block points to a different
143
  # memory object for `work` than the we want we want to reload.
144
  def reload(options = nil)
1✔
145
    super
×
146
    # Force `resource` to be reloaded
147
    @resource = nil
×
148
    self
×
149
  end
150

151
  def title
1✔
152
    resource.main_title
×
153
  end
154

155
  def uploads_attributes
1✔
156
    return [] if approved? # once approved we no longer allow the updating of uploads via the application
×
157
    uploads.map do |upload|
×
158
      {
159
        id: upload.id,
×
160
        key: upload.key,
161
        filename: upload.filename.to_s,
162
        created_at: upload.created_at,
163
        url: upload.url
164
      }
165
    end
166
  end
167

168
  def form_attributes
1✔
169
    {
170
      uploads: uploads_attributes
×
171
    }
172
  end
173

174
  def draft_doi
1✔
175
    return if resource.doi.present?
×
176
    resource.doi = datacite_service.draft_doi
×
177
    save!
×
178
  end
179

180
  # Return the DOI formatted as a URL, so it can be used as a link on display pages
181
  # @return [String] A url formatted version of the DOI
182
  def doi_url
1✔
183
    return "https://doi.org/#{doi}" unless doi.starts_with?("https://doi.org")
×
184
    doi
×
185
  end
186

187
  def created_by_user
1✔
188
    User.find(created_by_user_id)
×
189
  rescue ActiveRecord::RecordNotFound
190
    nil
×
191
  end
192

193
  def resource=(resource)
1✔
194
    @resource = resource
1✔
195
    # Ensure that the metadata JSONB postgres field is persisted properly
196
    self.metadata = JSON.parse(resource.to_json)
1✔
197
  end
198

199
  def resource
1✔
200
    @resource ||= PDCMetadata::Resource.new_from_jsonb(metadata)
1✔
201
  end
202

203
  def url
1✔
204
    return unless persisted?
×
205

206
    @url ||= url_for(self)
×
207
  end
208

209
  def files_location_upload?
1✔
210
    files_location.blank? || files_location == "file_upload"
×
211
  end
212

213
  def files_location_cluster?
1✔
214
    files_location == "file_cluster"
×
215
  end
216

217
  def files_location_other?
1✔
218
    files_location == "file_other"
×
219
  end
220

221
  def change_curator(curator_user_id, current_user)
1✔
222
    if curator_user_id == "no-one"
×
223
      clear_curator(current_user)
×
224
    else
225
      update_curator(curator_user_id, current_user)
×
226
    end
227
  end
228

229
  def clear_curator(current_user)
1✔
230
    # Update the curator on the Work
231
    self.curator_user_id = nil
×
232
    save!
×
233

234
    # ...and log the activity
235
    WorkActivity.add_work_activity(id, "Unassigned existing curator", current_user.id, activity_type: WorkActivity::SYSTEM)
×
236
  end
237

238
  def update_curator(curator_user_id, current_user)
1✔
239
    # Update the curator on the Work
240
    self.curator_user_id = curator_user_id
×
241
    save!
×
242

243
    # ...and log the activity
244
    new_curator = User.find(curator_user_id)
×
245

246
    work_url = "[#{title}](#{Rails.application.routes.url_helpers.work_url(self)})"
×
247

248
    # Troubleshooting https://github.com/pulibrary/pdc_describe/issues/1783
249
    if work_url.include?("/describe/describe/")
×
250
      Rails.logger.error("URL #{work_url} included /describe/describe/ and was fixed. See https://github.com/pulibrary/pdc_describe/issues/1783")
×
251
      work_url = work_url.gsub("/describe/describe/", "/describe/")
×
252
    end
253

254
    message = if curator_user_id.to_i == current_user.id
×
255
                "Self-assigned @#{current_user.uid} as curator for work #{work_url}"
×
256
              else
257
                "Set curator to @#{new_curator.uid} for work #{work_url}"
×
258
              end
259
    WorkActivity.add_work_activity(id, message, current_user.id, activity_type: WorkActivity::SYSTEM)
×
260
  end
261

262
  def add_message(message, current_user_id)
1✔
263
    WorkActivity.add_work_activity(id, message, current_user_id, activity_type: WorkActivity::MESSAGE)
×
264
  end
265

266
  def add_provenance_note(date, note, current_user_id, change_label = "")
1✔
267
    WorkActivity.add_work_activity(id, { note:, change_label: }.to_json, current_user_id, activity_type: WorkActivity::PROVENANCE_NOTES, created_at: date)
×
268
  end
269

270
  def log_changes(resource_compare, current_user_id)
1✔
271
    return if resource_compare.identical?
×
272
    WorkActivity.add_work_activity(id, resource_compare.differences.to_json, current_user_id, activity_type: WorkActivity::CHANGES)
×
273
  end
274

275
  def log_file_changes(current_user_id)
1✔
276
    return if changes.count == 0
×
277
    WorkActivity.add_work_activity(id, changes.to_json, current_user_id, activity_type: WorkActivity::FILE_CHANGES)
×
278
  end
279

280
  def activities
1✔
281
    WorkActivity.activities_for_work(id, WorkActivity::MESSAGE_ACTIVITY_TYPES + WorkActivity::CHANGE_LOG_ACTIVITY_TYPES)
×
282
  end
283

284
  def new_notification_count_for_user(user_id)
1✔
285
    WorkActivityNotification.joins(:work_activity)
×
286
                            .where(user_id:, read_at: nil)
287
                            .where(work_activity: { work_id: id })
288
                            .count
289
  end
290

291
  # Marks as read the notifications for the given user_id in this work.
292
  # In practice, the user_id is the id of the current user and therefore this method marks the current's user
293
  # notifications as read.
294
  def mark_new_notifications_as_read(user_id)
1✔
295
    # Notice that we fetch and update the information in batches
296
    # so that we don't issue individual SQL SELECT + SQL UPDATE
297
    # for each notification.
298
    #
299
    # Rails batching information:
300
    #   https://guides.rubyonrails.org/active_record_querying.html
301
    #   https://api.rubyonrails.org/classes/ActiveRecord/Batches.html
302

303
    # Disable this validation since we want to force a SQL UPDATE.
304
    # rubocop:disable Rails/SkipsModelValidations
305
    now_utc = Time.now.utc
×
306
    WorkActivityNotification.joins(:work_activity).where("user_id=? and work_id=?", user_id, id).in_batches(of: 1000).update_all(read_at: now_utc)
×
307
    # rubocop:enable Rails/SkipsModelValidations
308
  end
309

310
  def current_transition
1✔
311
    aasm.current_event.to_s.humanize.delete("!")
×
312
  end
313

314
  # Retrieve the S3 file uploads associated with the Work
315
  # @return [Array<S3File>]
316
  def uploads
1✔
317
    return post_curation_uploads if approved?
×
318

319
    pre_curation_uploads
×
320
  end
321

322
  # Retrieve the S3 file uploads named "README"
323
  # @return [Array<S3File>]
324
  def readme_uploads
1✔
325
    uploads.select { |s3_file| s3_file.filename.include?("README") }
×
326
  end
327

328
  # Retrieve the S3 file uploads which are research artifacts proper (not README or other files providing metadata/documentation)
329
  # @return [Array<S3File>]
330
  def artifact_uploads
1✔
331
    uploads.reject { |s3_file| s3_file.filename.include?("README") }
×
332
  end
333

334
  # Returns the list of files for the work with some basic information about each of them.
335
  # This method is much faster than `uploads` because it does not return the actual S3File
336
  # objects to the client, instead it returns just a few selected data elements.
337
  # rubocop:disable Metrics/MethodLength
338
  def file_list
1✔
339
    start = Time.zone.now
×
340
    s3_files = approved? ? post_curation_uploads : pre_curation_uploads
×
341
    files_info = s3_files.map do |s3_file|
×
342
      {
343
        "safe_id": s3_file.safe_id,
×
344
        "filename": s3_file.filename,
345
        "filename_display": s3_file.filename_display,
346
        "last_modified": s3_file.last_modified,
347
        "last_modified_display": s3_file.last_modified_display,
348
        "size": s3_file.size,
349
        "display_size": s3_file.display_size,
350
        "url": s3_file.url,
351
        "is_folder": s3_file.is_folder
352
      }
353
    end
354
    log_performance(start, "file_list called for #{id}")
×
355
    files_info
×
356
  end
357
  # rubocop:enable Metrics/MethodLength
358

359
  def total_file_size
1✔
360
    total_size = 0
×
361
    file_list.each do |file|
×
362
      total_size += file[:size]
×
363
    end
364
    total_size
×
365
  end
366

367
  # Calculates the total file size from a given list of files
368
  # This is so that we don't fetch the list twice from AWS since it can be expensive when
369
  # there are thousands of files on the work.
370
  def total_file_size_from_list(files)
1✔
371
    files.sum { |file| file[:size] }
×
372
  end
373

374
  # Fetches the data from S3 directly bypassing ActiveStorage
375
  def pre_curation_uploads
1✔
376
    s3_query_service.client_s3_files.sort_by(&:filename)
×
377
  end
378

379
  # Accesses post-curation S3 Bucket Objects
380
  def post_curation_s3_resources
1✔
381
    if approved?
×
382
      s3_resources
×
383
    else
384
      []
×
385
    end
386
  end
387

388
  # Returns the files in post-curation for the work
389
  def post_curation_uploads(force_post_curation: false)
1✔
390
    if force_post_curation
×
391
      # Always use the post-curation data regardless of the work's status
392
      post_curation_s3_query_service = S3QueryService.new(self, "postcuration")
×
393
      post_curation_s3_query_service.data_profile.fetch(:objects, [])
×
394
    else
395
      # Return the list based of files honoring the work status
396
      post_curation_s3_resources
×
397
    end
398
  end
399

400
  def s3_files
1✔
401
    pre_curation_uploads
×
402
  end
403

404
  def s3_client
1✔
405
    s3_query_service.client
×
406
  end
407

408
  delegate :bucket_name, :prefix, to: :s3_query_service
1✔
409
  delegate :doi_attribute_url, :curator_or_current_uid, to: :datacite_service
1✔
410

411
  # Generates the S3 Object key
412
  # @return [String]
413
  def s3_object_key
1✔
414
    "#{doi}/#{id}"
×
415
  end
416

417
  # Transmit a HEAD request for the S3 Bucket directory for this Work
418
  # @param bucket_name location to be checked to be found
419
  # @return [Aws::S3::Types::HeadObjectOutput]
420
  def find_post_curation_s3_dir(bucket_name:)
1✔
421
    # TODO: Directories really do not exists in S3
422
    #      if we really need this check then we need to do something else to check the bucket
423
    s3_client.head_object({
×
424
                            bucket: bucket_name,
425
                            key: s3_object_key
426
                          })
427
    true
×
428
  rescue Aws::S3::Errors::NotFound
429
    nil
×
430
  end
431

432
  # Generates the JSON serialized expression of the Work
433
  # @param args [Array<Hash>]
434
  # @option args [Boolean] :force_post_curation Force the request of AWS S3
435
  #   Resources, clearing the in-memory cache
436
  # @return [String]
437
  def as_json(*args)
1✔
438
    files = files_as_json(*args)
×
439

440
    # to_json returns a string of serialized JSON.
441
    # as_json returns the corresponding hash.
442
    {
443
      "resource" => resource.as_json,
×
444
      "files" => files,
445
      "group" => group.as_json.except("id"),
446
      "embargo_date" => embargo_date_as_json,
447
      "created_at" => format_date_for_solr(created_at),
448
      "updated_at" => format_date_for_solr(updated_at)
449
    }
450
  end
451

452
  # Format the date for Apache Solr
453
  # @param date [ActiveSupport::TimeWithZone]
454
  # @return [String]
455
  def format_date_for_solr(date)
1✔
456
    date.strftime("%Y-%m-%dT%H:%M:%SZ")
×
457
  end
458

459
  def pre_curation_uploads_count
1✔
460
    s3_query_service.file_count
×
461
  end
462

463
  delegate :ark, :doi, :resource_type, :resource_type=, :resource_type_general, :resource_type_general=,
1✔
464
           :to_xml, to: :resource
465

466
  # S3QueryService object associated with this Work
467
  # @return [S3QueryService]
468
  def s3_query_service
1✔
469
    mode = approved? ? "postcuration" : "precuration"
×
470
    @s3_query_service ||= S3QueryService.new(self, mode)
×
471
  end
472

473
  def past_snapshots
1✔
474
    UploadSnapshot.where(work: self)
×
475
  end
476

477
  # Build or find persisted UploadSnapshot models for this Work
478
  # @param [integer] user_id optional user to assign the snapshot to
479
  # @return [UploadSnapshot]
480
  def reload_snapshots(user_id: nil)
1✔
481
    work_changes = []
×
482
    s3_files = pre_curation_uploads
×
483
    s3_filenames = s3_files.map(&:filename)
×
484

485
    upload_snapshot = latest_snapshot
×
486

487
    upload_snapshot.snapshot_deletions(work_changes, s3_filenames)
×
488

489
    upload_snapshot.snapshot_modifications(work_changes, s3_files)
×
490

491
    # Create WorkActivity models with the set of changes
492
    unless work_changes.empty?
×
493
      new_snapshot = UploadSnapshot.new(work: self, url: s3_query_service.prefix)
×
494
      new_snapshot.store_files(s3_files)
×
495
      new_snapshot.save!
×
496
      WorkActivity.add_work_activity(id, work_changes.to_json, user_id, activity_type: WorkActivity::FILE_CHANGES)
×
497
    end
498
  end
499

500
  def self.presenter_class
1✔
501
    WorkPresenter
×
502
  end
503

504
  def presenter
1✔
505
    self.class.presenter_class.new(work: self)
×
506
  end
507

508
  def changes
1✔
509
    @changes ||= []
×
510
  end
511

512
  def track_change(action, filename)
1✔
513
    changes << { action:, filename: }
×
514
  end
515

516
  # rubocop:disable Naming/PredicateName
517
  def has_rights?(rights_id)
1✔
518
    resource.rights_many.index { |rights| rights.identifier == rights_id } != nil
×
519
  end
520
  # rubocop:enable Naming/PredicateName
521

522
  # This is the solr id / work show page in PDC Discovery
523
  def pdc_discovery_url
1✔
524
    "https://datacommons.princeton.edu/discovery/catalog/doi-#{doi.tr('/', '-').tr('.', '-')}"
×
525
  end
526

527
  # Determine whether or not the Work is under active embargo
528
  # @return [Boolean]
529
  def embargoed?
1✔
530
    return false if embargo_date.blank?
×
531

532
    current_date = Time.zone.now
×
533
    embargo_date >= current_date
×
534
  end
535

536
  def upload_count
1✔
537
    @upload_count ||= s3_query_service.count_objects
×
538
  end
539

540
  protected
1✔
541

542
    def work_validator
1✔
543
      @work_validator ||= WorkValidator.new(self)
×
544
    end
545

546
    # This must be protected, NOT private for ActiveRecord to work properly with this attribute.
547
    #   Protected will still keep others from setting the metatdata, but allows ActiveRecord the access it needs
548
    def metadata=(metadata)
1✔
549
      super
1✔
550
      @resource = PDCMetadata::Resource.new_from_jsonb(metadata)
1✔
551
    end
552

553
  private
1✔
554

555
    def publish(user)
1✔
556
      datacite_service.publish_doi(user)
×
557
      update_ark_information
×
558
      publish_precurated_files(user)
×
559
      save!
×
560
    end
561

562
    # Update EZID (our provider of ARKs) with the new information for this work.
563
    def update_ark_information
1✔
564
      # We only want to update the ark url under certain conditions.
565
      # Set this value in config/update_ark_url.yml
566
      if Rails.configuration.update_ark_url
×
567
        if ark.present?
×
568
          Ark.update(ark, datacite_service.doi_attribute_url)
×
569
        end
570
      end
571
    end
572

573
    def track_state_change(user, state = aasm.to_state)
1✔
574
      uw = UserWork.new(user_id: user.id, work_id: id, state:)
×
575
      uw.save!
×
576
      WorkActivity.add_work_activity(id, "marked as #{state.to_s.titleize}", user.id, activity_type: WorkActivity::SYSTEM)
×
577
      WorkStateTransitionNotification.new(self, user.id).send
×
578
    end
579

580
    # Request S3 Bucket Objects associated with this Work
581
    # @return [Array<S3File>]
582
    def s3_resources
1✔
583
      data_profile = s3_query_service.data_profile
×
584
      data_profile.fetch(:objects, [])
×
585
    end
586
    alias pre_curation_s3_resources s3_resources
1✔
587

588
    def s3_object_persisted?(s3_file)
1✔
589
      uploads_keys = uploads.map(&:key)
×
590
      uploads_keys.include?(s3_file.key)
×
591
    end
592

593
    def publish_precurated_files(user)
1✔
594
      # We need to explicitly check the to post-curation bucket here.
595
      s3_post_curation_query_service = S3QueryService.new(self, "postcuration")
×
596

597
      s3_dir = find_post_curation_s3_dir(bucket_name: s3_post_curation_query_service.bucket_name)
×
598
      raise(StandardError, "Attempting to publish a Work with an existing S3 Bucket directory for: #{s3_object_key}") unless s3_dir.nil?
×
599

600
      # Copy the pre-curation S3 Objects to the post-curation S3 Bucket...
601
      s3_query_service.publish_files(user)
×
602
    end
603

604
    def latest_snapshot
1✔
605
      return upload_snapshots.first unless upload_snapshots.empty?
×
606

607
      UploadSnapshot.new(work: self, files: [])
×
608
    end
609

610
    def datacite_service
1✔
611
      @datacite_service ||= PULDatacite.new(self)
×
612
    end
613

614
    def files_as_json(*args)
1✔
615
      return [] if embargoed?
×
616

617
      force_post_curation = args.any? { |arg| arg[:force_post_curation] == true }
×
618

619
      # Pre-curation files are not accessible externally,
620
      # so we are not interested in listing them in JSON.
621
      post_curation_uploads(force_post_curation:).map do |upload|
×
622
        {
623
          "filename": upload.filename,
×
624
          "size": upload.size,
625
          "display_size": upload.display_size,
626
          "url": upload.globus_url
627
        }
628
      end
629
    end
630

631
    def embargo_date_as_json
1✔
632
      if embargo_date.present?
×
633
        embargo_datetime = embargo_date.to_datetime
×
634
        embargo_date_iso8601 = embargo_datetime.iso8601
×
635
        # Apache Solr timestamps require the following format:
636
        # 1972-05-20T17:33:18Z
637
        # https://solr.apache.org/guide/solr/latest/indexing-guide/date-formatting-math.html
638
        embargo_date_iso8601.gsub(/\+.+$/, "Z")
×
639
      end
640
    end
641

642
    def log_performance(start, message)
1✔
643
      elapsed = Time.zone.now - start
×
644
      if elapsed > 20
×
645
        Rails.logger.warn("PERFORMANCE: #{message}. Elapsed: #{elapsed} seconds")
×
646
      else
647
        Rails.logger.info("PERFORMANCE: #{message}. Elapsed: #{elapsed} seconds")
×
648
      end
649
    end
650
end
651
# rubocop:enable Metrics/ClassLength
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc