specguard-ruby 0.3.1
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 +7 -0
- data/.github/workflows/ci.yml +49 -0
- data/.github/workflows/release.yml +100 -0
- data/LICENSE +21 -0
- data/README.md +1036 -0
- data/Rakefile +28 -0
- data/assets/built-with-yatfa.png +0 -0
- data/bin/specguard-ingest +40 -0
- data/bin/specguard-lint +34 -0
- data/lib/minitest/specguard_plugin.rb +25 -0
- data/lib/specguard/minitest/reporter.rb +244 -0
- data/lib/specguard/rspec/annotation_lookup.rb +363 -0
- data/lib/specguard/rspec/annotation_scanner.rb +190 -0
- data/lib/specguard/rspec/cli.rb +375 -0
- data/lib/specguard/rspec/configuration.rb +472 -0
- data/lib/specguard/rspec/file_selector.rb +276 -0
- data/lib/specguard/rspec/finding.rb +74 -0
- data/lib/specguard/rspec/formatter.rb +1110 -0
- data/lib/specguard/rspec/ingest_cli.rb +942 -0
- data/lib/specguard/rspec/ingest_reporter.rb +280 -0
- data/lib/specguard/rspec/json_reporter.rb +169 -0
- data/lib/specguard/rspec/linter.rb +156 -0
- data/lib/specguard/rspec/payload_normalizer.rb +143 -0
- data/lib/specguard/rspec/scanner.rb +235 -0
- data/lib/specguard/rspec/schemas/open-test-intent.v1.json +15 -0
- data/lib/specguard/rspec/transport.rb +449 -0
- data/lib/specguard/rspec/validator_backend.rb +1450 -0
- data/lib/specguard/rspec/version.rb +14 -0
- data/lib/specguard/rspec.rb +71 -0
- data/lib/specguard/version.rb +11 -0
- data/script/bump-version.sh +128 -0
- metadata +96 -0
data/Rakefile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rspec/core/rake_task"
|
|
5
|
+
|
|
6
|
+
# The fixtures under spec/fixtures/ are linter *input* that happens to be named
|
|
7
|
+
# `*_spec.rb` — exactly what RSpec's default pattern loads. `.rspec` excludes
|
|
8
|
+
# them, and that is the source of truth for a bare `rspec` run.
|
|
9
|
+
#
|
|
10
|
+
# The rake task has to repeat it. RSpec::Core::RakeTask always puts an explicit
|
|
11
|
+
# `--pattern` on the command line, and a command-line `--pattern` discards the
|
|
12
|
+
# `--exclude-pattern` that came from `.rspec` — so the stock task loads the
|
|
13
|
+
# fixtures as examples and the suite dies with
|
|
14
|
+
# "cannot load such file -- rails_helper". Passing the exclusion on the command
|
|
15
|
+
# line too is what makes the two invocations agree. (Clearing `t.pattern`
|
|
16
|
+
# instead does not work: the task then passes an empty argument and matches
|
|
17
|
+
# nothing.)
|
|
18
|
+
#
|
|
19
|
+
# Keep this in step with `.rspec` — `rake` and `rspec` must select the same
|
|
20
|
+
# files. spec/spec_helper.rb fails the suite loudly if a fixture ever does get
|
|
21
|
+
# loaded as an example.
|
|
22
|
+
RSpec::Core::RakeTask.new(:spec) do |t|
|
|
23
|
+
t.rspec_opts = %(--exclude-pattern "spec/fixtures/**/*_spec.rb")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `rake` with no arguments used to do nothing at all, which made it a useless
|
|
27
|
+
# CI entrypoint. It now means "run the suite".
|
|
28
|
+
task default: %i[spec]
|
|
Binary file
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# specguard-ingest — replay a saved run through SpecGuard's ingest endpoint.
|
|
5
|
+
#
|
|
6
|
+
# The RSpec formatter writes each undelivered run to `log/test_results.jsonl`,
|
|
7
|
+
# one whole run per line and byte-for-byte the body the endpoint was offered.
|
|
8
|
+
# This is the command that sends it: fix the rotated key, then replay the run
|
|
9
|
+
# the suite already finished, instead of re-running the suite.
|
|
10
|
+
#
|
|
11
|
+
# Exit codes are the contract, and they are `specguard-lint`'s reasoning
|
|
12
|
+
# transferred (see SpecGuard::RSpec::IngestCLI):
|
|
13
|
+
#
|
|
14
|
+
# 0 every line was accepted
|
|
15
|
+
# 1 at least one line was refused by the endpoint
|
|
16
|
+
# 2 this tool could not do its job — misuse, or the tool itself broken
|
|
17
|
+
#
|
|
18
|
+
# SpecGuard::RSpec::IngestCLI#run is written to *return* one of those three and
|
|
19
|
+
# never to raise. The `require`s below are the one thing outside its reach, so
|
|
20
|
+
# they get the same treatment here: a gem that cannot even load is the tool
|
|
21
|
+
# being broken, which is a 2. Left bare, Ruby would exit 1 and report that the
|
|
22
|
+
# platform refused a run it was never offered.
|
|
23
|
+
|
|
24
|
+
# Allow running from a source checkout without bundler by putting the local
|
|
25
|
+
# lib/ on the load path. When the gem is installed (or run under
|
|
26
|
+
# `bundle exec`), specguard/rspec resolves through the normal load path.
|
|
27
|
+
source_lib = File.expand_path("../lib", __dir__)
|
|
28
|
+
$LOAD_PATH.unshift(source_lib) unless $LOAD_PATH.include?(source_lib)
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
require "specguard/rspec"
|
|
32
|
+
# By its own path, deliberately. `net/http`, `uri` and `zlib` are not on the
|
|
33
|
+
# linter's load chain and this command is the reason to pay for them.
|
|
34
|
+
require "specguard/rspec/ingest_cli"
|
|
35
|
+
rescue ScriptError, StandardError => e
|
|
36
|
+
warn "specguard-ingest: error: could not load specguard-ruby: #{e.class}: #{e.message}"
|
|
37
|
+
exit 2
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
exit SpecGuard::RSpec::IngestCLI.new.run(ARGV)
|
data/bin/specguard-lint
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# specguard-lint — the SpecGuard annotation linter entrypoint.
|
|
5
|
+
#
|
|
6
|
+
# Selects spec files, finds every `@intent:` annotation in them, validates each
|
|
7
|
+
# against the vendored OpenTestIntent schema, and reports every violation. Exit
|
|
8
|
+
# codes are the contract (SPGD-12 §1):
|
|
9
|
+
#
|
|
10
|
+
# 0 all annotations valid, or there were none ("lint, don't require")
|
|
11
|
+
# 1 one or more annotations are malformed
|
|
12
|
+
# 2 the linter could not do its job — misuse, or the tool itself broken
|
|
13
|
+
#
|
|
14
|
+
# SpecGuard::RSpec::CLI#run is written to *return* one of those three and never
|
|
15
|
+
# to raise; see its class comment for why that takes deliberate effort in Ruby.
|
|
16
|
+
# The `require` below is the one thing outside its reach, so it gets the same
|
|
17
|
+
# treatment here: a gem that cannot even load is the linter being broken, which
|
|
18
|
+
# is a 2. Left bare, Ruby would exit 1 and CI would report a malformed
|
|
19
|
+
# annotation nobody wrote.
|
|
20
|
+
|
|
21
|
+
# Allow running from a source checkout without bundler by putting the local
|
|
22
|
+
# lib/ on the load path. When the gem is installed (or run under
|
|
23
|
+
# `bundle exec`), specguard/rspec resolves through the normal load path.
|
|
24
|
+
source_lib = File.expand_path("../lib", __dir__)
|
|
25
|
+
$LOAD_PATH.unshift(source_lib) unless $LOAD_PATH.include?(source_lib)
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
require "specguard/rspec"
|
|
29
|
+
rescue ScriptError, StandardError => e
|
|
30
|
+
warn "specguard-lint: error: could not load specguard-rspec: #{e.class}: #{e.message}"
|
|
31
|
+
exit 2
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
exit SpecGuard::RSpec::CLI.new.run(ARGV)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Minitest's plugin discovery point. Minitest loads every `minitest/*_plugin.rb`
|
|
4
|
+
# it can find on the load path (`Minitest.load_plugins` → `Gem.find_files`), so
|
|
5
|
+
# a gem providing this file needs no require in the consumer's spec files —
|
|
6
|
+
# putting `gem "specguard-ruby"` in the Gemfile is the whole integration.
|
|
7
|
+
#
|
|
8
|
+
# This file exists to be FOUND; the reporter itself lives at
|
|
9
|
+
# `specguard/minitest/reporter` so it can be required explicitly (the
|
|
10
|
+
# deterministic path CI should use, for the same reason the RSpec formatter is
|
|
11
|
+
# registered by flags rather than magic: what posts your telemetry should be
|
|
12
|
+
# visible in the command that ran the tests).
|
|
13
|
+
#
|
|
14
|
+
# The hook name `plugin_specguard_init` is Minitest's convention: everything
|
|
15
|
+
# between `plugin_` and `_init` is the plugin's name. `Minitest.reporter` is
|
|
16
|
+
# the live CompositeReporter at this point — Minitest assigns it before
|
|
17
|
+
# initializing plugins precisely so plugins can append to it — and appending
|
|
18
|
+
# keeps the default Summary/Progress reporters untouched, so the suite's own
|
|
19
|
+
# output looks exactly as it did without telemetry.
|
|
20
|
+
module Minitest
|
|
21
|
+
def self.plugin_specguard_init(_options)
|
|
22
|
+
require "specguard/minitest/reporter"
|
|
23
|
+
reporter << SpecGuard::Minitest::Reporter.new
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
# Configuration and Transport are the framework-free halves of the client
|
|
7
|
+
# (env resolution, wire delivery, the never-raise contract); both live under
|
|
8
|
+
# the rspec namespace because rspec shipped first, neither contains any RSpec.
|
|
9
|
+
# Required here rather than assumed because nothing else loads them on a
|
|
10
|
+
# minitest-only consumer's machine.
|
|
11
|
+
require_relative "../rspec/configuration"
|
|
12
|
+
|
|
13
|
+
# The Minitest half of `specguard-ruby`. Everything the RSpec formatter knows
|
|
14
|
+
# about the platform — the envelope's field names, the transport's
|
|
15
|
+
# never-raise contract, the switch that a missing key is — is framework-free
|
|
16
|
+
# knowledge that already lives in `SpecGuard::RSpec::Configuration` and
|
|
17
|
+
# `SpecGuard::RSpec::Transport` (both named for the framework that happened to
|
|
18
|
+
# ship first, neither containing any RSpec). This adapter contributes only the
|
|
19
|
+
# part that is genuinely Minitest's: turning `Minitest::Result` objects into
|
|
20
|
+
# the same row shape the RSpec formatter emits, at the moment Minitest hands
|
|
21
|
+
# them to a reporter.
|
|
22
|
+
#
|
|
23
|
+
# It is a duck-typed Minitest reporter (`start` / `record` / `report` /
|
|
24
|
+
# `passed?`) rather than a subclass of `Minitest::AbstractReporter`, because
|
|
25
|
+
# the composite only ever calls those four and a superclass would add
|
|
26
|
+
# assertions about documentation we do not need.
|
|
27
|
+
#
|
|
28
|
+
# ## Telemetry never fails the suite
|
|
29
|
+
#
|
|
30
|
+
# The same guarantee the RSpec formatter gives, arrived at the same way: every
|
|
31
|
+
# step is wrapped, a failed delivery costs one line on stderr and a line in the
|
|
32
|
+
# local sink, and `#passed?` is a constant `true` because Minitest folds it
|
|
33
|
+
# into the run's own verdict via `CompositeReporter#passed?` — `all?` over its
|
|
34
|
+
# reporters — and a telemetry reporter that could redden a suite would be a
|
|
35
|
+
# telemetry reporter somebody deletes.
|
|
36
|
+
module SpecGuard
|
|
37
|
+
module Minitest
|
|
38
|
+
class Reporter
|
|
39
|
+
# Rows with no usable location are dropped rather than mangled: a row
|
|
40
|
+
# whose file cannot be named fits no by-file aggregate on the platform
|
|
41
|
+
# and would surface as noise. The RSpec formatter's `capture` makes the
|
|
42
|
+
# equivalent call about a metadata-less example.
|
|
43
|
+
class << self
|
|
44
|
+
# `Dir.pwd` at load time, not per row: a reporter constructed inside a
|
|
45
|
+
# test that changes the working directory would otherwise relativize
|
|
46
|
+
# one suite's rows against two different roots.
|
|
47
|
+
def repo_root
|
|
48
|
+
Dir.pwd
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(configuration: SpecGuard::RSpec.configuration,
|
|
53
|
+
transport: nil, output: $stderr,
|
|
54
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
55
|
+
@configuration = configuration
|
|
56
|
+
# Injected for the specs; the real thing is built the same way the
|
|
57
|
+
# RSpec formatter builds it, from the same configuration object.
|
|
58
|
+
@transport = transport
|
|
59
|
+
@output = output
|
|
60
|
+
@clock = clock
|
|
61
|
+
@rows = []
|
|
62
|
+
@started_at = nil
|
|
63
|
+
@warned = false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def start
|
|
67
|
+
@started_at = @clock.call
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Minitest's `CompositeReporter` calls this before every runnable, and
|
|
71
|
+
# minitest 6 requires it on every reporter in the composite — a duck
|
|
72
|
+
# type without it is not "a reporter that ignores prerecords", it is a
|
|
73
|
+
# crash inside `Runnable#run` that takes the suite's exit code with it,
|
|
74
|
+
# which is the one thing this class may never do. A no-op records
|
|
75
|
+
# nothing: the row this adapter wants is the finished `Result`, not the
|
|
76
|
+
# promise of one.
|
|
77
|
+
def prerecord(_klass, _name); end
|
|
78
|
+
|
|
79
|
+
# One `Minitest::Result` in, one wire row out. Guarded rather than
|
|
80
|
+
# trusted for the same reason the RSpec formatter guards `capture`:
|
|
81
|
+
# nothing a result object does while being read may cost the suite its
|
|
82
|
+
# exit code.
|
|
83
|
+
def record(result)
|
|
84
|
+
row = row_for(result)
|
|
85
|
+
@rows << row if row
|
|
86
|
+
rescue ScriptError, StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Minitest calls this after every runnable has reported, on the same
|
|
91
|
+
# reporter — the delivery point. A run that recorded nothing ships
|
|
92
|
+
# nothing, mirroring the RSpec formatter's stance on an empty suite.
|
|
93
|
+
def report
|
|
94
|
+
return if @rows.empty?
|
|
95
|
+
|
|
96
|
+
deliver(payload)
|
|
97
|
+
rescue ScriptError, StandardError => e
|
|
98
|
+
warn_once("could not ship test telemetry (#{e.class}: #{e.message}). The test run is unaffected.")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Constant by contract — see the class comment. Public because
|
|
102
|
+
# `CompositeReporter#passed?` is `all?` over its reporters and this one
|
|
103
|
+
# must never be the reporter that flips a green suite red.
|
|
104
|
+
def passed?
|
|
105
|
+
true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# The envelope, in the platform's own field names (`Ingest::Payload`:
|
|
111
|
+
# commit_sha / branch / ci_run_id / duration_seconds / specs) — the same
|
|
112
|
+
# rule the RSpec formatter states at its `#payload`: this gem's settings
|
|
113
|
+
# are named its way, every key in this Hash is named the platform's way,
|
|
114
|
+
# and nothing translates between them.
|
|
115
|
+
def payload
|
|
116
|
+
{
|
|
117
|
+
"commit_sha" => @configuration.commit_sha,
|
|
118
|
+
"branch" => @configuration.branch,
|
|
119
|
+
"ci_run_id" => @configuration.run_id,
|
|
120
|
+
"shard_id" => @configuration.shard_id,
|
|
121
|
+
"duration_seconds" => duration_seconds,
|
|
122
|
+
"specs" => @rows
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The delivery ladder, in the RSpec formatter's order, with the same two
|
|
127
|
+
# sinks for the same two reasons: no key means the local development
|
|
128
|
+
# record (`local_output_path` — a note that a run happened, not a queue);
|
|
129
|
+
# a refused or failed delivery means the replay queue (`output_path` —
|
|
130
|
+
# the file `specguard-ingest` re-delivers). A blank commit_sha means the
|
|
131
|
+
# platform would refuse the whole run, so it is caught before the wall
|
|
132
|
+
# clock is spent and the run goes to the replay queue. Anything else goes
|
|
133
|
+
# to the transport, whose own contract is that nothing escapes it — a
|
|
134
|
+
# non-success `Result` costs one stderr line and a queued run.
|
|
135
|
+
def deliver(data)
|
|
136
|
+
return append(data, @configuration.local_output_path) if @configuration.api_key.to_s.strip.empty?
|
|
137
|
+
|
|
138
|
+
if @configuration.commit_sha.to_s.strip.empty?
|
|
139
|
+
return fall_back(data, "no commit sha could be resolved (set SPECGUARD_COMMIT_SHA)")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
result = (@transport || transport_for).deliver(data)
|
|
143
|
+
return if result.success?
|
|
144
|
+
|
|
145
|
+
# The transport's `reason` is already the operator-facing sentence for
|
|
146
|
+
# both a refusal (status, what to do about it, the platform's own
|
|
147
|
+
# words) and a failure (exception class and message) — nothing to add.
|
|
148
|
+
fall_back(data, result.reason)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def transport_for
|
|
152
|
+
require_relative "../rspec/transport"
|
|
153
|
+
SpecGuard::RSpec::Transport.new(
|
|
154
|
+
endpoint: @configuration.endpoint,
|
|
155
|
+
api_key: @configuration.api_key,
|
|
156
|
+
timeout: @configuration.timeout
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def row_for(result)
|
|
161
|
+
# `source_location` is an Array `["path", line]` on minitest 6 and a
|
|
162
|
+
# "path:line" String on minitest 5 — both are read, because which one
|
|
163
|
+
# a consumer runs is not this gem's to choose.
|
|
164
|
+
location = result.source_location
|
|
165
|
+
file, line =
|
|
166
|
+
if location.is_a?(Array)
|
|
167
|
+
location
|
|
168
|
+
else
|
|
169
|
+
location.to_s.split(":", 2)
|
|
170
|
+
end
|
|
171
|
+
# A row without both halves of a location fits no by-file aggregate
|
|
172
|
+
# and no stable identity; dropped rather than mangled, the same call
|
|
173
|
+
# the RSpec formatter makes about a metadata-less example.
|
|
174
|
+
return nil if file.to_s.strip.empty? || line.to_i.zero?
|
|
175
|
+
|
|
176
|
+
file = relative_path(file)
|
|
177
|
+
name = "#{result.klass}##{result.name}"
|
|
178
|
+
{
|
|
179
|
+
# Minitest has no RSpec-style rerun path or nested ids, so the
|
|
180
|
+
# definition site is the identity: stable across runs (which is
|
|
181
|
+
# what the platform's per-test identity needs) and unique per
|
|
182
|
+
# definition (which is what a row needs).
|
|
183
|
+
"id" => "#{file}:#{line}",
|
|
184
|
+
"spec_file_path" => file,
|
|
185
|
+
"file_path" => file,
|
|
186
|
+
"line_number" => line&.to_i,
|
|
187
|
+
"name" => name,
|
|
188
|
+
"duration" => result.time,
|
|
189
|
+
# The platform's three outcomes, mapped from Minitest's questions:
|
|
190
|
+
# a skip is a pending (the suite's own vocabulary for "deliberately
|
|
191
|
+
# not answered"), and a failure and an error are both "failed" —
|
|
192
|
+
# Minitest distinguishes assertion failures from exceptions, the
|
|
193
|
+
# wire contract does not, and inventing a fourth outcome here would
|
|
194
|
+
# be a lie the schema refuses.
|
|
195
|
+
"outcome" => outcome_for(result)
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def outcome_for(result)
|
|
200
|
+
return "pending" if result.skipped?
|
|
201
|
+
return "failed" unless result.passed?
|
|
202
|
+
|
|
203
|
+
"passed"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def relative_path(path)
|
|
207
|
+
root = self.class.repo_root
|
|
208
|
+
return path unless path.start_with?("#{root}/")
|
|
209
|
+
|
|
210
|
+
path.delete_prefix("#{root}/")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def duration_seconds
|
|
214
|
+
return 0.0 if @started_at.nil?
|
|
215
|
+
|
|
216
|
+
(@clock.call - @started_at).round(3)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# One JSON run per line, to whichever file this delivery's outcome
|
|
220
|
+
# warrants (see `#deliver` for which and why).
|
|
221
|
+
def append(data, path)
|
|
222
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
223
|
+
File.open(path, "a") { |f| f.puts(JSON.generate(data)) }
|
|
224
|
+
rescue ScriptError, StandardError => e
|
|
225
|
+
warn_once("could not write telemetry to #{path} (#{e.class}: #{e.message}). The test run is unaffected.")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def fall_back(data, reason)
|
|
229
|
+
warn_once("could not deliver test telemetry (#{reason}). Falling back to the replay queue. " \
|
|
230
|
+
"The test run is unaffected.")
|
|
231
|
+
append(data, @configuration.output_path)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Once per process, like the formatter's `warn_once`: fifty failing
|
|
235
|
+
# deliveries is one problem, not fifty lines of it.
|
|
236
|
+
def warn_once(message)
|
|
237
|
+
return if @warned
|
|
238
|
+
|
|
239
|
+
@warned = true
|
|
240
|
+
@output.puts("SpecGuard: #{message}")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|