modsvaskr 0.0.4 → 0.1.0

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.
@@ -5,21 +5,40 @@ module Modsvaskr
5
5
 
6
6
  class << self
7
7
  attr_accessor :log_file
8
+ attr_accessor :stdout_io
8
9
  end
9
10
  @log_file = File.expand_path('Modsvaskr.log')
11
+ @stdout_io = $stdout
10
12
 
11
13
  # Log on screen and in log file
12
14
  #
13
15
  # Parameters::
14
16
  # * *msg* (String): Message to log
15
17
  def log(msg)
16
- complete_msg = "[ #{Time.now.strftime('%F %T')} ] - #{msg}"
17
- puts complete_msg
18
+ complete_msg = "[ #{Time.now.strftime('%F %T')} ] - [ #{self.class.name.split('::').last} ] - #{msg}"
19
+ Logger.stdout_io << "#{complete_msg}\n"
18
20
  File.open(Logger.log_file, 'a') do |f|
19
21
  f.puts complete_msg
20
22
  end
21
23
  end
22
24
 
25
+ # Display an output to the user.
26
+ # This is not a log.
27
+ #
28
+ # Parameters::
29
+ # * *msg* (String): Message to output
30
+ def out(msg)
31
+ Logger.stdout_io << "#{msg}\n"
32
+ end
33
+
34
+ # Wait for the user to enter a line and hit Enter
35
+ #
36
+ # Result::
37
+ # * String: The line entered by the user
38
+ def wait_for_user_enter
39
+ @config.no_prompt ? "\n" : $stdin.gets
40
+ end
41
+
23
42
  end
24
43
 
25
44
  end
@@ -12,8 +12,8 @@ module Modsvaskr
12
12
  # * *args* (Array<String>): Additional arguments to give the command [default: []]
13
13
  def run_cmd(cmd, args: [])
14
14
  Dir.chdir cmd[:dir] do
15
- cmd_line = "#{cmd[:exe]} #{((cmd.key?(:args) ? cmd[:args] : []) + args).join(' ')}"
16
- raise "Unable to execute command line from \"#{dir}\": #{cmd_line}" unless system cmd_line
15
+ cmd_line = "\"#{cmd[:exe]}\" #{((cmd.key?(:args) ? cmd[:args] : []) + args).join(' ')}".strip
16
+ raise "Unable to execute command line from \"#{cmd[:dir]}\": #{cmd_line}" unless system cmd_line
17
17
  end
18
18
  end
19
19
 
@@ -1,6 +1,8 @@
1
1
  require 'json'
2
2
  require 'time'
3
+ require 'tmpdir'
3
4
  require 'modsvaskr/tests_suite'
5
+ require 'modsvaskr/in_game_tests_runner'
4
6
 
5
7
  module Modsvaskr
6
8
 
@@ -12,17 +14,11 @@ module Modsvaskr
12
14
  # Default values are for a standard Skyrim SE installation.
13
15
  #
14
16
  # Parameters::
17
+ # * *config* (Config): Main configuration
15
18
  # * *game* (Game): Game for which we run tests
16
- # * *tests_poll_secs* (Integer): Number of seconds to poll for the game run and tests changes [default: 5]
17
- # * *timeout_frozen_tests_secs* (Integer): Time out (in seconds) between tests updates: if no test has been updated before this timeout, consider the game has crashed. [default: 300]
18
- def initialize(
19
- game,
20
- tests_poll_secs: 5,
21
- timeout_frozen_tests_secs: 300
22
- )
19
+ def initialize(config, game)
20
+ @config = config
23
21
  @game = game
24
- @tests_poll_secs = tests_poll_secs
25
- @timeout_frozen_tests_secs = timeout_frozen_tests_secs
26
22
  # Parse tests suites
27
23
  @tests_suites = Hash[Dir.glob("#{__dir__}/tests_suites/*.rb").map do |tests_suite_file|
28
24
  tests_suite = File.basename(tests_suite_file, '.rb').to_sym
@@ -40,7 +36,7 @@ module Modsvaskr
40
36
  # Result::
41
37
  # * Array<Symbol>: List of tests suites
42
38
  def tests_suites
43
- @tests_suites.keys
39
+ @tests_suites.keys.sort
44
40
  end
45
41
 
46
42
  # Return test names for a given tests suite
@@ -50,6 +46,7 @@ module Modsvaskr
50
46
  # Result::
51
47
  # * Array<String>: Test names of this suite
52
48
  def discover_tests_for(tests_suite)
49
+ log "Discover tests for #{tests_suite}"
53
50
  discovered_tests = @tests_suites[tests_suite].discover_tests
54
51
  # Complete our tests information
55
52
  complete_info = tests_info
@@ -108,115 +105,57 @@ module Modsvaskr
108
105
  # Test names (ordered) to be performed in game, per tests suite
109
106
  # Hash< Symbol, Array<String> >
110
107
  in_game_tests = {}
111
- selected_tests.each do |tests_suite, suite_selected_tests|
108
+ selected_tests.each do |tests_suite, selected_tests|
112
109
  if @tests_suites[tests_suite].respond_to?(:run_test)
113
110
  # Simple synchronous tests
114
- suite_selected_tests.each do |test_name|
111
+ selected_tests.each do |test_name|
115
112
  # Store statuses after each test just in case of crash
116
113
  set_statuses_for(tests_suite, [[test_name, @tests_suites[tests_suite].run_test(test_name)]])
117
114
  end
118
115
  end
119
- if @tests_suites[tests_suite].respond_to?(:auto_tests_for)
120
- # We run the tests from the game itself, using AutoTest mod.
121
- in_game_tests[tests_suite] = suite_selected_tests
122
- end
116
+ # We run the tests from the game itself.
117
+ in_game_tests[tests_suite] = selected_tests if @tests_suites[tests_suite].respond_to?(:in_game_tests_for)
123
118
  end
124
119
  unless in_game_tests.empty?
125
- # Get the list of AutoTest we have to run and that we will monitor
126
- in_game_auto_tests = in_game_tests.inject({}) do |merged_auto_tests, (tests_suite, suite_selected_tests)|
127
- merged_auto_tests.merge(@tests_suites[tests_suite].auto_tests_for(suite_selected_tests)) do |auto_test_suite, auto_test_tests1, auto_test_tests2|
128
- (auto_test_tests1 + auto_test_tests2).uniq
129
- end
130
- end
131
- in_game_auto_tests.each do |auto_test_suite, auto_test_tests|
132
- # Write the JSON file that contains the list of tests to run
133
- File.write(
134
- "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{auto_test_suite}_Run.json",
135
- JSON.pretty_generate(
136
- 'stringList' => {
137
- 'tests_to_run' => auto_test_tests
138
- }
139
- )
140
- )
141
- # Clear the AutoTest test statuses
142
- File.unlink("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{auto_test_suite}_Statuses.json") if File.exist?("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{auto_test_suite}_Statuses.json")
143
- end
144
- auto_test_config_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_Config.json"
145
- # Write the JSON file that contains the configuration of the AutoTest tests runner
146
- File.write(
147
- auto_test_config_file,
148
- JSON.pretty_generate(
149
- 'string' => {
150
- 'on_start' => 'run',
151
- 'on_stop' => 'exit'
120
+ # Keep track of the mapping between tests suites and in-game tests, per in-game tests suite.
121
+ # Associated info is:
122
+ # * *tests_suite* (Symbol): The tests suite that has subscribed to the statuses of some in-game tests of the in-game tests suite.
123
+ # * *in_game_tests* (Array<String>): List of in-game tests that the tests suite is interested in.
124
+ # * *selected_tests* (Array<String>): List of selected tests for which in-game tests are useful.
125
+ # Hash< Symbol, Array< Hash< Symbol, Object > > >
126
+ in_game_tests_subscriptions = {}
127
+ # List of all in-game tests to perform, per in-game tests suite
128
+ # Hash< Symbol, Array< String > >
129
+ merged_in_game_tests = {}
130
+ # Get the list of in-game tests we have to run and that we will monitor
131
+ in_game_tests.each do |tests_suite, selected_tests|
132
+ in_game_tests_to_subscribe = @tests_suites[tests_suite].in_game_tests_for(selected_tests)
133
+ in_game_tests_to_subscribe.each do |in_game_tests_suite, selected_in_game_tests|
134
+ in_game_tests_subscriptions[in_game_tests_suite] = [] unless in_game_tests_subscriptions.key?(in_game_tests_suite)
135
+ in_game_tests_subscriptions[in_game_tests_suite] << {
136
+ tests_suite: tests_suite,
137
+ in_game_tests: selected_in_game_tests,
138
+ selected_tests: selected_tests
152
139
  }
153
- )
154
- )
155
- puts ''
156
- puts '=========================================='
157
- puts '= In-game tests are about to be launched ='
158
- puts '=========================================='
159
- puts ''
160
- puts 'Here is what you need to do once the game will be launched (don\'t launch it by yourself, the test framework will launch it for you):'
161
- puts '* Load the game save you want to test (or start a new game).'
162
- puts ''
163
- puts 'This will execute all in-game tests automatically.'
164
- puts ''
165
- puts 'It is possible that the game crashes during tests:'
166
- puts '* That\'s a normal situation, as tests don\'t mimick a realistic gaming experience, and the Bethesda engine is not meant to be stressed like that.'
167
- puts '* In case of game crash (CTD), the Modsvaskr test framework will relaunch it automatically and resume testing from when it crashed.'
168
- puts '* In case of repeated CTD on the same test, the Modsvaskr test framework will detect it and skip the crashing test automatically.'
169
- puts '* In case of a game freeze without CTD, the Modsvaskr test framework will detect it after a few minutes and automatically kill the game before re-launching it to resume testing.'
170
- puts ''
171
- puts 'If you want to interrupt in-game testing: invoke the console with ~ key and type stop_tests followed by Enter.'
172
- puts ''
173
- puts 'Press enter to start in-game testing (this will lauch your game automatically)...'
174
- $stdin.gets
175
- idx_launch = 0
176
- old_statuses = auto_test_statuses
177
- loop do
178
- launch_game(use_autoload: idx_launch > 0)
179
- idx_launch += 1
180
- monitor_game_tests
181
- # Check tests again
182
- new_statuses = auto_test_statuses
183
- # Update the tests results based on what has been run in-game
184
- in_game_tests.each do |tests_suite, suite_selected_tests|
185
- @tests_suites[tests_suite].set_statuses(
186
- @tests_suites[tests_suite].
187
- parse_auto_tests_statuses_for(suite_selected_tests, new_statuses).
188
- select { |(test_name, _test_status)| suite_selected_tests.include?(test_name) }
189
- )
190
- end
191
- log 'Test statuses after game run:'
192
- print_test_statuses(new_statuses)
193
- # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
194
- # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
195
- auto_test_config = Hash[JSON.parse(File.read(auto_test_config_file))['string'].map { |key, value| [key.downcase, value.downcase] }]
196
- if auto_test_config.dig('stopped_by') == 'user'
197
- log 'Tests have been stopped by user. Stop looping.'
198
- break
199
- end
200
- if auto_test_config.dig('tests_execution') == 'end'
201
- log 'Tests have finished running. Stop looping.'
202
- break
203
- end
204
- if new_statuses == old_statuses
205
- log 'No changes in AutoTest tests statuses in the game run. Stop looping.'
206
- break
140
+ merged_in_game_tests[in_game_tests_suite] = [] unless merged_in_game_tests.key?(in_game_tests_suite)
141
+ merged_in_game_tests[in_game_tests_suite] = (merged_in_game_tests[in_game_tests_suite] + selected_in_game_tests).uniq
207
142
  end
208
- old_statuses = new_statuses
209
- # We will start again. Leave some time to interrupt if we want.
210
- log 'We are going to start again in 10 seconds. Press Enter now to interrupt it.'
211
- key_pressed =
212
- begin
213
- Timeout.timeout(10) { $stdin.gets }
214
- rescue
215
- nil
143
+ end
144
+ in_game_tests_runner = InGameTestsRunner.new(@config, @game)
145
+ in_game_tests_runner.run(merged_in_game_tests) do |in_game_tests_suite, in_game_tests_statuses|
146
+ # This is a callback called for each in-game test status change.
147
+ # Update the tests results based on what has been run in-game.
148
+ # Find all tests suites that are subscribed to those in-game tests.
149
+ in_game_tests_subscriptions[in_game_tests_suite].each do |tests_suite_subscription|
150
+ selected_in_game_tests_statuses = in_game_tests_statuses.slice(*tests_suite_subscription[:in_game_tests])
151
+ unless selected_in_game_tests_statuses.empty?
152
+ tests_suite = @tests_suites[tests_suite_subscription[:tests_suite]]
153
+ tests_suite.set_statuses(
154
+ tests_suite.
155
+ parse_auto_tests_statuses_for(tests_suite_subscription[:selected_tests], { in_game_tests_suite => selected_in_game_tests_statuses }).
156
+ select { |(test_name, _test_status)| tests_suite_subscription[:selected_tests].include?(test_name) }
157
+ )
216
158
  end
217
- if key_pressed
218
- log 'Run interrupted by user.'
219
- break
220
159
  end
221
160
  end
222
161
  end
@@ -257,174 +196,6 @@ module Modsvaskr
257
196
  @tests_info_cache = tests_info
258
197
  end
259
198
 
260
- # Launch the game, and wait for launch to be successful (get a PID)
261
- #
262
- # Parameters::
263
- # * *use_autoload* (Boolean): If true, then launch the game using AutoLoad
264
- def launch_game(use_autoload:)
265
- # Launch the game
266
- @idx_launch = 0 unless defined?(@idx_launch)
267
- Dir.chdir(@game.path) do
268
- if use_autoload
269
- log "Launch Game (##{@idx_launch}) using AutoLoad..."
270
- system "\"#{@game.path}/Data/AutoLoad.cmd\" auto_test"
271
- else
272
- log "Launch Game (##{@idx_launch}) using configured launcher..."
273
- system "\"#{@game.path}/#{@game.launch_exe}\""
274
- end
275
- end
276
- @idx_launch += 1
277
- # The game launches asynchronously, so just wait a little bit and check for the process
278
- sleep 10
279
- tasklist_stdout = nil
280
- loop do
281
- tasklist_stdout = `tasklist | find "#{@game.running_exe}"`.strip
282
- break unless tasklist_stdout.empty?
283
- log "#{@game.running_exe} is not running. Wait for its startup..."
284
- sleep 1
285
- end
286
- @game_pid = Integer(tasklist_stdout.split(' ')[1])
287
- log "#{@game.running_exe} has started with PID #{@game_pid}"
288
- end
289
-
290
- # Kill the game, and wait till it is killed
291
- def kill_game
292
- first_time = true
293
- loop do
294
- system "taskkill #{first_time ? '' : '/F'} /pid #{@game_pid}"
295
- first_time = false
296
- sleep 1
297
- tasklist_stdout = `tasklist | find "#{@game.running_exe}"`.strip
298
- break if tasklist_stdout.empty?
299
- log "#{@game.running_exe} is still running. Wait for its kill..."
300
- sleep 5
301
- end
302
- end
303
-
304
- # Get the list of AutoTest statuses
305
- #
306
- # Result::
307
- # * Hash<String, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
308
- def auto_test_statuses
309
- statuses = {}
310
- `dir "#{@game.path}/Data/SKSE/Plugins/StorageUtilData" /B`.split("\n").each do |file|
311
- if file =~ /^AutoTest_(.+)_Statuses\.json$/
312
- auto_test_suite = $1
313
- # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
314
- # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
315
- statuses[auto_test_suite] = JSON.parse(File.read("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{auto_test_suite}_Statuses.json"))['string'].map do |test_name, test_status|
316
- [test_name.downcase, test_status.downcase]
317
- end
318
- end
319
- end
320
- statuses
321
- end
322
-
323
- # Loop while monitoring game tests progression.
324
- # If the game exits, then exit also the loop.
325
- # In case no test is being updated for a long period of time, kill the game (we consider it was frozen)
326
- # Prerequisite: launch_game has to be called before.
327
- def monitor_game_tests
328
- log 'Start monitoring game testing...'
329
- last_time_tests_changed = Time.now
330
- # Get a picture of current tests
331
- current_statuses = auto_test_statuses
332
- loop do
333
- still_running = true
334
- begin
335
- # Process.kill does not work when the game has crashed (the process is still detected as zombie)
336
- # still_running = Process.kill(0, @game_pid) == 1
337
- tasklist_stdout = `tasklist 2>&1`
338
- still_running = tasklist_stdout.split("\n").any? { |line| line =~ /#{Regexp.escape(@game.running_exe)}/ }
339
- # log "Tasklist returned no Skyrim:\n#{tasklist_stdout}" unless still_running
340
- rescue Errno::ESRCH
341
- log "Got error while waiting for #{@game.running_exe} PID: #{$!}"
342
- still_running = false
343
- end
344
- # Log a diff in tests
345
- new_statuses = auto_test_statuses
346
- diff_statuses = diff_statuses(current_statuses, new_statuses)
347
- unless diff_statuses.empty?
348
- # Tests have progressed
349
- last_time_tests_changed = Time.now
350
- log '===== Test statuses changes:'
351
- diff_statuses.each do |tests_suite, tests_statuses|
352
- log "* #{tests_suite}:"
353
- tests_statuses.each do |(test_name, test_status)|
354
- log " * #{test_name}: #{test_status}"
355
- end
356
- end
357
- end
358
- break unless still_running
359
- # If the tests haven't changed for too long, consider the game has frozen, but not crashed. So kill it.
360
- if Time.now - last_time_tests_changed > @timeout_frozen_tests_secs
361
- log "Last time test have changed is #{last_time_tests_changed.strftime('%F %T')}. Consider the game is frozen, so kill it."
362
- kill_game
363
- break
364
- end
365
- current_statuses = new_statuses
366
- sleep @tests_poll_secs
367
- end
368
- log 'End monitoring game testing.'
369
- end
370
-
371
- # Diff between test statuses.
372
- #
373
- # Parameters::
374
- # * *statuses1* (Hash<String, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
375
- # * *statuses2* (Hash<String, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
376
- # Result::
377
- # * Hash<String, Array<[String, String]> >: statuses2 - statuses1
378
- def diff_statuses(statuses1, statuses2)
379
- statuses = {}
380
- statuses1.each do |tests_suite, tests_info|
381
- if statuses2.key?(tests_suite)
382
- # Handle Hashes as it will be faster
383
- statuses1_for_test = Hash[tests_info]
384
- statuses2_for_test = Hash[statuses2[tests_suite]]
385
- statuses1_for_test.each do |test_name, status1|
386
- if statuses2_for_test.key?(test_name)
387
- if statuses2_for_test[test_name] != status1
388
- # Change in status
389
- statuses[tests_suite] = [] unless statuses.key?(tests_suite)
390
- statuses[tests_suite] << [test_name, statuses2_for_test[test_name]]
391
- end
392
- else
393
- # This test has been removed
394
- statuses[tests_suite] = [] unless statuses.key?(tests_suite)
395
- statuses[tests_suite] << [test_name, 'Deleted']
396
- end
397
- end
398
- statuses2_for_test.each do |test_name, status2|
399
- unless statuses1_for_test.key?(test_name)
400
- # This test has been added
401
- statuses[tests_suite] = [] unless statuses.key?(tests_suite)
402
- statuses[tests_suite] << [test_name, status2]
403
- end
404
- end
405
- else
406
- # All test statuses have been removed
407
- statuses[tests_suite] = tests_info.map { |(test_name, _test_status)| [test_name, 'Deleted'] }
408
- end
409
- end
410
- statuses2.each do |tests_suite, tests_info|
411
- # All test statuses have been added
412
- statuses[tests_suite] = tests_info unless statuses1.key?(tests_suite)
413
- end
414
- statuses
415
- end
416
-
417
- # Print test statuses
418
- #
419
- # Parameters::
420
- # * *statuses* (Hash<String, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
421
- def print_test_statuses(statuses)
422
- statuses.each do |tests_suite, statuses_for_type|
423
- next_test_name, _next_test_status = statuses_for_type.find { |(_name, status)| status != 'ok' }
424
- log "[ #{tests_suite} ] - #{statuses_for_type.select { |(_name, status)| status == 'ok' }.size} / #{statuses_for_type.size} - Next test to perform: #{next_test_name.nil? ? 'None' : next_test_name}"
425
- end
426
- end
427
-
428
199
  end
429
200
 
430
201
  end
File without changes
@@ -98,16 +98,15 @@ module Modsvaskr
98
98
  tests
99
99
  end
100
100
 
101
- # Get the list of tests to be run in the AutoTest mod for a given list of test names.
102
- # AutoTest names are case insensitive.
101
+ # Get the list of tests to be run in-game for a given list of test names.
103
102
  # [API] - This method is mandatory for tests needing to be run in-game.
104
103
  #
105
104
  # Parameters::
106
105
  # * *tests* (Array<String>): List of test names
107
106
  # Result::
108
- # * Hash<String, Array<String> >: List of AutoTest mod test names, per AutoTest mod tests suite
109
- def auto_tests_for(tests)
110
- { 'Locations' => tests }
107
+ # * Hash<Symbol, Array<String> >: List of in-game test names, per in-game tests suite
108
+ def in_game_tests_for(tests)
109
+ { locations: tests }
111
110
  end
112
111
 
113
112
  # Set statuses based on the result of AutoTest statuses.
@@ -116,11 +115,11 @@ module Modsvaskr
116
115
  #
117
116
  # Parameters::
118
117
  # * *tests* (Array<String>): List of test names
119
- # * *auto_test_statuses* (Hash<String, Array<[String, String]> >): Ordered list of AutoTest [test name, test status], per AutoTest tests suite
118
+ # * *auto_test_statuses* (Hash<Symbol, Array<[String, String]> >): Ordered list of AutoTest [test name, test status], per AutoTest tests suite
120
119
  # Result::
121
120
  # * Array<[String, String]>: Corresponding list of [test name, test status]
122
121
  def parse_auto_tests_statuses_for(tests, auto_test_statuses)
123
- auto_test_statuses.key?('Locations') ? auto_test_statuses['Locations'] : []
122
+ auto_test_statuses.key?(:locations) ? auto_test_statuses[:locations] : []
124
123
  end
125
124
 
126
125
  end