cukedep 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.rspec +1 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.simplecov +7 -0
- data/.travis.yml +15 -0
- data/.yardopts +6 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +12 -0
- data/LICENSE.txt +19 -0
- data/README.md +64 -0
- data/Rakefile +32 -0
- data/bin/cukedep +14 -0
- data/cucumber.yml +10 -0
- data/lib/cukedep.rb +9 -0
- data/lib/cukedep/application.rb +112 -0
- data/lib/cukedep/cli/application.rb +0 -0
- data/lib/cukedep/cli/cmd-line.rb +115 -0
- data/lib/cukedep/config.rb +31 -0
- data/lib/cukedep/constants.rb +29 -0
- data/lib/cukedep/feature-model.rb +285 -0
- data/lib/cukedep/feature-rep.rb +49 -0
- data/lib/cukedep/gherkin-listener.rb +98 -0
- data/lib/macros4cuke.rb +8 -0
- data/sample/features/step_definitions/steps.rb +105 -0
- data/sample/features/support/env.rb +12 -0
- data/sample/model/model.rb +216 -0
- data/spec/cukedep/application_spec.rb +81 -0
- data/spec/cukedep/cli/cmd-line_spec.rb +91 -0
- data/spec/cukedep/feature-model_spec.rb +103 -0
- data/spec/cukedep/feature-rep_spec.rb +53 -0
- data/spec/cukedep/file-parsing.rb +40 -0
- data/spec/cukedep/gherkin-listener_spec.rb +59 -0
- data/spec/cukedep/sample_features/a_few_tests.feature +24 -0
- data/spec/cukedep/sample_features/more_tests.feature +24 -0
- data/spec/cukedep/sample_features/other_tests.feature +15 -0
- data/spec/cukedep/sample_features/some_tests.feature +13 -0
- data/spec/cukedep/sample_features/standalone.feature +19 -0
- data/spec/cukedep/sample_features/still_other_tests.feature +24 -0
- data/spec/cukedep/sample_features/yet_other_tests.feature +19 -0
- data/spec/spec_helper.rb +18 -0
- data/templates/rake.erb +163 -0
- metadata +165 -0
@@ -0,0 +1,31 @@
|
|
1
|
+
# File: config.rb
|
2
|
+
|
3
|
+
module Cukedep # Module used as a namespace
|
4
|
+
|
5
|
+
FileMetaData = Struct.new(:name)
|
6
|
+
|
7
|
+
Config = Struct.new(
|
8
|
+
:proj_dir, # The directory of the cucumber project
|
9
|
+
:feature2id, # Meta-data about the feature => feature id report
|
10
|
+
:id2feature, # Meta-data about the feature id => feature report
|
11
|
+
:graph_file, # Meta-data about the dependency graph file
|
12
|
+
:rake_file, # Name of the output rake file
|
13
|
+
:cucumber_args # Command-line syntax to use for the cucumber application
|
14
|
+
)
|
15
|
+
|
16
|
+
# Re-open the class for further customisation
|
17
|
+
|
18
|
+
# Configuration object for the Cukedep application.
|
19
|
+
class Config
|
20
|
+
# Factory method. Build a config object with default settings.
|
21
|
+
def self.default()
|
22
|
+
Config.new(nil, FileMetaData.new('feature2id.csv'),
|
23
|
+
FileMetaData.new('feature2id.csv'), FileMetaData.new('dependencies.dot'),
|
24
|
+
'cukedep.rake', [])
|
25
|
+
end
|
26
|
+
|
27
|
+
end # class
|
28
|
+
|
29
|
+
end # module
|
30
|
+
|
31
|
+
# End of file
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# File: constants.rb
|
2
|
+
# Purpose: definition of Cukedep constants.
|
3
|
+
|
4
|
+
module Cukedep # Module used as a namespace
|
5
|
+
# The version number of the gem.
|
6
|
+
Version = '0.0.1'
|
7
|
+
|
8
|
+
# Brief description of the gem.
|
9
|
+
Description = 'Manage dependencies between Cucumber feature files'
|
10
|
+
|
11
|
+
# Constant Cukedep::RootDir contains the absolute path of Rodent's
|
12
|
+
# root directory. Note: it also ends with a slash character.
|
13
|
+
unless defined?(RootDir)
|
14
|
+
# The initialisation of constant RootDir is guarded in order
|
15
|
+
# to avoid multiple initialisation (not allowed for constants)
|
16
|
+
|
17
|
+
# The root folder of Cukedep.
|
18
|
+
RootDir = begin
|
19
|
+
require 'pathname' # Load Pathname class from standard library
|
20
|
+
rootdir = Pathname(__FILE__).dirname.parent.parent.expand_path
|
21
|
+
rootdir.to_s + '/' # Append trailing slash character to it
|
22
|
+
end
|
23
|
+
|
24
|
+
# The file name for the user's settings
|
25
|
+
YMLFilename = '.cukedep.yml'
|
26
|
+
end
|
27
|
+
end # module
|
28
|
+
|
29
|
+
# End of file
|
@@ -0,0 +1,285 @@
|
|
1
|
+
# File: feature-model.rb
|
2
|
+
|
3
|
+
require 'tsort'
|
4
|
+
require 'csv'
|
5
|
+
require 'erb'
|
6
|
+
require 'pathname'
|
7
|
+
|
8
|
+
module Cukedep # This module is used as a namespace
|
9
|
+
|
10
|
+
|
11
|
+
# The internal representation of a set of feature files.
|
12
|
+
# Dependencies: use topological sort
|
13
|
+
# TSort module http://ruby-doc.org/stdlib-1.9.3/libdoc/tsort/rdoc/TSort.html
|
14
|
+
# See also: Is this topological sort in Ruby flawed?
|
15
|
+
class FeatureModel
|
16
|
+
|
17
|
+
FeatureDependencies = Struct.new(:dependee, :dependents)
|
18
|
+
|
19
|
+
|
20
|
+
class DepGraph
|
21
|
+
include TSort # Mix-in module for topological sorting
|
22
|
+
|
23
|
+
attr_reader(:dependencies)
|
24
|
+
|
25
|
+
# Inverse lookup: from the feature file => FeatureDependencies
|
26
|
+
attr_reader(:lookup)
|
27
|
+
|
28
|
+
def initialize(theDependencies)
|
29
|
+
@dependencies = theDependencies
|
30
|
+
@lookup = dependencies.each_with_object({}) do |f_dependencies, sub_result|
|
31
|
+
sub_result[f_dependencies.dependee] = f_dependencies
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Method required by TSort module.
|
36
|
+
# It is used to iterate over all the nodes of the dependency graph
|
37
|
+
def tsort_each_node(&aBlock)
|
38
|
+
return dependencies.each(&aBlock)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Method required by TSort module.
|
42
|
+
# It is used to iterate over all the children nodes of the given node.
|
43
|
+
def tsort_each_child(aDependency, &aBlock)
|
44
|
+
dependents = aDependency.dependents
|
45
|
+
children = dependents.map { |feature| lookup[feature] }
|
46
|
+
children.each(&aBlock)
|
47
|
+
end
|
48
|
+
|
49
|
+
end # class
|
50
|
+
|
51
|
+
|
52
|
+
attr_reader(:feature_files)
|
53
|
+
|
54
|
+
# An Array of FeatureDependencies
|
55
|
+
attr_reader(:dependencies)
|
56
|
+
|
57
|
+
def initialize(theFeatureFiles)
|
58
|
+
@feature_files = validated_model(theFeatureFiles)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Retrieve the feature file matching the given feature identifiers
|
62
|
+
# theIds one or more Strings, each being one feature identifier
|
63
|
+
def select_by_ids(*theIds)
|
64
|
+
features_by_ids = id2features()
|
65
|
+
selection = theIds.each_with_object([]) do |an_id, sub_result|
|
66
|
+
found_feature = features_by_ids[an_id]
|
67
|
+
if found_feature.nil?
|
68
|
+
fail(StandardError, "No feature file with identifier '#{an_id}'.")
|
69
|
+
end
|
70
|
+
sub_result << found_feature
|
71
|
+
end
|
72
|
+
|
73
|
+
return selection
|
74
|
+
end
|
75
|
+
|
76
|
+
# The list of feature files without identifiers
|
77
|
+
def anonymous_features()
|
78
|
+
feature_files.select { |ff| ff.feature.anonymous? }
|
79
|
+
end
|
80
|
+
|
81
|
+
# Build an array of FileDependencies objects.
|
82
|
+
def dependency_links()
|
83
|
+
if @dependencies.nil?
|
84
|
+
# Build the mapping: feature identifier => feature
|
85
|
+
features_by_id = id2features()
|
86
|
+
|
87
|
+
# Resolve the dependency tags
|
88
|
+
resolve_dependencies(features_by_id)
|
89
|
+
end
|
90
|
+
|
91
|
+
return @dependencies
|
92
|
+
end
|
93
|
+
|
94
|
+
|
95
|
+
# Sort the feature files by dependency order.
|
96
|
+
def sort_features_by_dep()
|
97
|
+
dep_links = dependency_links()
|
98
|
+
graph = DepGraph.new(dep_links)
|
99
|
+
sorted_deps = graph.tsort()
|
100
|
+
|
101
|
+
all_sorted = sorted_deps.map(&:dependee)
|
102
|
+
@sorted_features = all_sorted.reject { |f| f.feature.anonymous? }
|
103
|
+
end
|
104
|
+
|
105
|
+
# Generate CSV files detailing the feature to identifier mapping
|
106
|
+
# and vise versa
|
107
|
+
def mapping_reports(fileFeature2id, fileId2Feature, isVerbose = false)
|
108
|
+
puts " #{fileFeature2id}" if isVerbose
|
109
|
+
# Generate the feature file name => feature identifier report
|
110
|
+
CSV.open(fileFeature2id, 'wb') do |f|
|
111
|
+
f << ['Feature file', 'Identifier']
|
112
|
+
feature_files.each do |ff|
|
113
|
+
identifier = ff.feature.identifier
|
114
|
+
filename = File.basename(ff.filepath)
|
115
|
+
f << [filename, identifier.nil? ? 'nil' : identifier]
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Generate the feature file name => feature identifier report
|
120
|
+
puts " #{fileId2Feature}" if isVerbose
|
121
|
+
CSV.open(fileId2Feature, 'wb') do |f|
|
122
|
+
f << ['identifier', 'feature file']
|
123
|
+
feature_files.each do |ff|
|
124
|
+
identifier = ff.feature.identifier
|
125
|
+
filename = File.basename(ff.filepath)
|
126
|
+
f << [identifier, filename] unless identifier.nil?
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Create a graphical representation of the dependencies.
|
132
|
+
# The result is a DOT file that can be rendered via the DOT
|
133
|
+
# application from the GraphViz distribution.
|
134
|
+
def draw_dependency_graph(theDOTfile, isVerbose = false)
|
135
|
+
puts " #{theDOTfile}" if isVerbose
|
136
|
+
dot_file = File.open(theDOTfile, 'w')
|
137
|
+
emit_heading(dot_file)
|
138
|
+
emit_body(dot_file)
|
139
|
+
emit_trailing(dot_file)
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
def emit_heading(anIO)
|
144
|
+
dir = File.dirname(File.absolute_path(feature_files[0].filepath))
|
145
|
+
heading =<<-EOS
|
146
|
+
// Graph of dependencies of feature files in directory:
|
147
|
+
// '#{dir}'
|
148
|
+
// This file uses the DOT syntax, a free utility from the Graphviz toolset.
|
149
|
+
// Graphviz is available at: www.graphviz.org
|
150
|
+
// File generated on #{Time.now.asctime}.
|
151
|
+
|
152
|
+
digraph g {
|
153
|
+
size = "7, 11"; // Dimensions in inches...
|
154
|
+
center = true;
|
155
|
+
rankdir = BT; // Draw from bottom to top
|
156
|
+
label = "\\nDependency graph of '#{dir}'";
|
157
|
+
|
158
|
+
// Nodes represent feature files
|
159
|
+
EOS
|
160
|
+
anIO.write heading
|
161
|
+
end
|
162
|
+
|
163
|
+
# Output the nodes as graph vertices + their edges with parent node
|
164
|
+
def emit_body(anIO)
|
165
|
+
anIO.puts <<-EOS
|
166
|
+
subgraph island {
|
167
|
+
node [shape = box, style=filled, color=lightgray];
|
168
|
+
EOS
|
169
|
+
feature_files.each_with_index { |ff, i| draw_node(anIO, ff, i) if ff.feature.anonymous? }
|
170
|
+
|
171
|
+
anIO.puts <<-EOS
|
172
|
+
label = "Isolated features";
|
173
|
+
}
|
174
|
+
|
175
|
+
subgraph dependencies {
|
176
|
+
node [shape = box, fillcolor = none];
|
177
|
+
EOS
|
178
|
+
feature_files.each_with_index { |ff, i| draw_node(anIO, ff, i) unless ff.feature.anonymous? }
|
179
|
+
anIO.puts <<-EOS
|
180
|
+
label = "Dependencies";
|
181
|
+
}
|
182
|
+
|
183
|
+
// The edges represent dependencies
|
184
|
+
EOS
|
185
|
+
dependencies.each {|a_dep| draw_edge(anIO, a_dep) }
|
186
|
+
end
|
187
|
+
|
188
|
+
# Output the closing part of the graph drawing
|
189
|
+
def emit_trailing(anIO)
|
190
|
+
anIO.puts "} // End of graph"
|
191
|
+
end
|
192
|
+
|
193
|
+
# Draw a refinement node in DOT format
|
194
|
+
def draw_node(anIO, aFeatureFile, anIndex)
|
195
|
+
basename = File.basename(aFeatureFile.filepath, '.feature')
|
196
|
+
identifier_suffix = aFeatureFile.feature.anonymous? ? '' : " -- #{aFeatureFile.feature.identifier}"
|
197
|
+
anIO.puts %Q| node_#{anIndex} [label = "#{basename}#{identifier_suffix}"];|
|
198
|
+
end
|
199
|
+
|
200
|
+
# Draw an edge between feature files having dependencies.
|
201
|
+
def draw_edge(anIO, aDependency)
|
202
|
+
source_id = feature_files.find_index(aDependency.dependee)
|
203
|
+
target_ids = aDependency.dependents.map do |a_target|
|
204
|
+
feature_files.find_index(a_target)
|
205
|
+
end
|
206
|
+
|
207
|
+
target_ids.each do |t_id|
|
208
|
+
anIO.puts "\tnode_#{source_id} -> node_#{t_id};"
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
|
213
|
+
def generate_rake_tasks(rakefile, theProjDir)
|
214
|
+
puts " #{rakefile}"
|
215
|
+
template_filepath = Pathname.new(File.dirname(__FILE__)).parent.parent + './templates/rake.erb'
|
216
|
+
template_source = File.read(template_filepath)
|
217
|
+
|
218
|
+
# Create one template engine instance
|
219
|
+
engine = ERB.new(template_source)
|
220
|
+
|
221
|
+
source_dir = File.absolute_path(Dir.getwd())
|
222
|
+
proj_dir = File.absolute_path(theProjDir)
|
223
|
+
anonymous = anonymous_features.map {|ff| ff.basename}
|
224
|
+
feature_ids = feature_files.map { |ff| ff.feature.identifier }
|
225
|
+
feature_ids.compact!
|
226
|
+
deps = dependencies.reject {|dep| dep.dependee.feature.anonymous?}
|
227
|
+
|
228
|
+
# Generate the text representation with given context
|
229
|
+
file_source = engine.result(binding)
|
230
|
+
File.open(rakefile, 'w') { |f| f.write(file_source) }
|
231
|
+
end
|
232
|
+
|
233
|
+
|
234
|
+
protected
|
235
|
+
def validated_model(theFeatureFiles)
|
236
|
+
return theFeatureFiles
|
237
|
+
end
|
238
|
+
|
239
|
+
# Build the mapping: feature identifier => feature
|
240
|
+
def id2features()
|
241
|
+
mapping = feature_files.each_with_object({}) do |file, mp|
|
242
|
+
feature_id = file.feature.identifier
|
243
|
+
mp[feature_id] = file unless feature_id.nil?
|
244
|
+
end
|
245
|
+
|
246
|
+
return mapping
|
247
|
+
end
|
248
|
+
|
249
|
+
# Given a feature identifier => feature mapping,
|
250
|
+
# resolve the dependency tags; that is,
|
251
|
+
# Establish links between a feature file object and its
|
252
|
+
# dependent feature file objects.
|
253
|
+
def resolve_dependencies(aMapping)
|
254
|
+
@dependencies = []
|
255
|
+
|
256
|
+
feature_files.each do |feature_file|
|
257
|
+
feature = feature_file.feature
|
258
|
+
its_id = feature.identifier
|
259
|
+
dep_tags = feature.dependency_tags
|
260
|
+
# Complain when self dependency detected
|
261
|
+
if dep_tags.include?(its_id)
|
262
|
+
msg = "Feature #{} with identifier #{its_id} depends on itself!"
|
263
|
+
fail(StandardError, msg)
|
264
|
+
end
|
265
|
+
|
266
|
+
# Complain when dependency tag refers to an unknown feature
|
267
|
+
dependents = dep_tags.map do |a_tag|
|
268
|
+
unless aMapping.include?(a_tag)
|
269
|
+
msg = "Feature with identifier '#{its_id}' depends on unknown feature '#{a_tag}'"
|
270
|
+
fail(StandardError, msg)
|
271
|
+
end
|
272
|
+
aMapping[a_tag]
|
273
|
+
end
|
274
|
+
|
275
|
+
@dependencies << FeatureDependencies.new(feature_file, dependents)
|
276
|
+
end
|
277
|
+
|
278
|
+
return @dependencies
|
279
|
+
end
|
280
|
+
|
281
|
+
end # class
|
282
|
+
|
283
|
+
end # module
|
284
|
+
|
285
|
+
# end of file
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# File: feature-rep.rb
|
2
|
+
|
3
|
+
module Cukedep # This module is used as a namespace
|
4
|
+
|
5
|
+
|
6
|
+
# A FeatureRep is the internal representation of a Gherkin feature.
|
7
|
+
class FeatureRep
|
8
|
+
# Constant that specifies how feature identifier tags should begin
|
9
|
+
FeatureIdPrefix = /^feature:/
|
10
|
+
|
11
|
+
# Constant that specifies how dependency tags should begin
|
12
|
+
DependencyPrefix = /^depends_on:/
|
13
|
+
|
14
|
+
# The sorted list of all tags of the feature.
|
15
|
+
# The @ prefix is stripped from each tag text.
|
16
|
+
attr_reader(:tags)
|
17
|
+
|
18
|
+
# The identifier of the feature.
|
19
|
+
# It comes from a tag with the following syntax '@feature_' + identifier.
|
20
|
+
# Note that the @feature_ prefix is removed.
|
21
|
+
attr_reader(:identifier)
|
22
|
+
|
23
|
+
|
24
|
+
# theTags the tags objects from the Gherkin parser
|
25
|
+
def initialize(theTags)
|
26
|
+
# Strip first character of tag literal.
|
27
|
+
@tags = theTags.map { |t| t[1..-1] }
|
28
|
+
|
29
|
+
@identifier = tags.find { |tg| tg =~ FeatureIdPrefix }
|
30
|
+
@identifier = @identifier.sub(FeatureIdPrefix, '') unless identifier.nil?
|
31
|
+
end
|
32
|
+
|
33
|
+
public
|
34
|
+
# The list of all feature identifiers retrieved from the dependency tags
|
35
|
+
def dependency_tags()
|
36
|
+
dep_tags = tags.select { |t| t =~ DependencyPrefix }
|
37
|
+
return dep_tags.map { |t| t.sub(DependencyPrefix, '') }
|
38
|
+
end
|
39
|
+
|
40
|
+
# Return true iff the identifier of the feature is nil.
|
41
|
+
def anonymous?()
|
42
|
+
return identifier.nil?
|
43
|
+
end
|
44
|
+
|
45
|
+
end # class
|
46
|
+
|
47
|
+
end # module
|
48
|
+
|
49
|
+
# End of file
|
@@ -0,0 +1,98 @@
|
|
1
|
+
# File: gherkin-listener.rb
|
2
|
+
|
3
|
+
require_relative 'feature-rep'
|
4
|
+
|
5
|
+
|
6
|
+
module Cukedep # This module is used as a namespace
|
7
|
+
|
8
|
+
|
9
|
+
class FeatureFileRep
|
10
|
+
attr_reader(:filepath)
|
11
|
+
attr(:feature, true)
|
12
|
+
|
13
|
+
def initialize(aFilepath)
|
14
|
+
@filepath = aFilepath
|
15
|
+
end
|
16
|
+
|
17
|
+
def basename()
|
18
|
+
File.basename(filepath)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# A ParserListener listens to all the formatting events
|
23
|
+
# emitted by the Gherkin parser.
|
24
|
+
# It converts the received the feature file elements and builds
|
25
|
+
# a representation of the feature files that is appropriate
|
26
|
+
# for the Cukedep application.
|
27
|
+
class GherkinListener
|
28
|
+
# The list of feature files encountered so far
|
29
|
+
attr_reader(:feature_files)
|
30
|
+
|
31
|
+
# Internal representation of the feature being parsed
|
32
|
+
attr(:current_feature, true)
|
33
|
+
|
34
|
+
def initialize()
|
35
|
+
@feature_files = []
|
36
|
+
end
|
37
|
+
|
38
|
+
######################################
|
39
|
+
# Event listening methods
|
40
|
+
######################################
|
41
|
+
|
42
|
+
# Called when beginning the parsing of a feature file.
|
43
|
+
# featureURI: path + filename of feature file.
|
44
|
+
def uri(featureURI)
|
45
|
+
new_file = FeatureFileRep.new(featureURI)
|
46
|
+
feature_files << new_file
|
47
|
+
end
|
48
|
+
|
49
|
+
# aFeature is a Gherkin::Formatter::Model::Feature instance
|
50
|
+
def feature(aFeature)
|
51
|
+
tag_names = aFeature.tags.map(&:name)
|
52
|
+
@current_feature = feature_files.last.feature = FeatureRep.new(tag_names)
|
53
|
+
end
|
54
|
+
|
55
|
+
# aBackground is a Gherkin::Formatter::Model::Background instance
|
56
|
+
def background(aBackground)
|
57
|
+
; # Do nothing
|
58
|
+
end
|
59
|
+
|
60
|
+
# aScenario is a Gherkin::Formatter::Model::Scenario instance
|
61
|
+
def scenario(aScenario)
|
62
|
+
; # Do nothing
|
63
|
+
end
|
64
|
+
|
65
|
+
# aScenarioOutline is a Gherkin::Formatter::Model::ScenarioOutline instance
|
66
|
+
def scenario_outline(aScenarioOutline)
|
67
|
+
; # Do nothing
|
68
|
+
end
|
69
|
+
|
70
|
+
# theExamples is a Gherkin::Formatter::Model::Examples instance
|
71
|
+
def examples(theExamples)
|
72
|
+
; # Do nothing
|
73
|
+
end
|
74
|
+
|
75
|
+
# aStep is a Gherkin::Formatter::Model::Step instance
|
76
|
+
def step(aStep)
|
77
|
+
; # Do nothing
|
78
|
+
end
|
79
|
+
|
80
|
+
# End of feature file notification.
|
81
|
+
def eof()
|
82
|
+
; # Do nothing
|
83
|
+
end
|
84
|
+
|
85
|
+
|
86
|
+
# Catch all method
|
87
|
+
def method_missing(message, *args)
|
88
|
+
puts "Method #{message} is not implemented (yet)."
|
89
|
+
end
|
90
|
+
|
91
|
+
end # class
|
92
|
+
|
93
|
+
end # module
|
94
|
+
|
95
|
+
|
96
|
+
|
97
|
+
|
98
|
+
# End of file
|