modsvaskr 0.0.4 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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