modsvaskr 0.0.4 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9342c8943f1a2deb5b824f349319dae072d8bfa69cbc1e68af95fc518805f35b
4
- data.tar.gz: 7cfcdb9b25f93b670e516bf8e992fae6bd51249981df565be2024429c48a2957
3
+ metadata.gz: a9da018a3ec7ff9aa0b326ab9ba345da5ae001d20c3a158ccb6e0b00f7a09b71
4
+ data.tar.gz: 04f068e2b37bdebe8162365034fb9acfd50c1d664fbba0f2a08c5e6acb929464
5
5
  SHA512:
6
- metadata.gz: 104e7a71646fecf55debb31fff6e7a7498b5a2ce263c3a6e1fbafe21fed84f54c09134376bfc9534b20d52f73db262ed0a2c42a3d03a747cad1f1f36b6bd62eb
7
- data.tar.gz: 867b8bee8c3efe11380693cbfebc17c869094d344a3ba6e0aa9681807b298a00d6c5981a85fa75e4fa8b118dc4d687a4d24b8161f0ac640486eb2d9c92bff593
6
+ metadata.gz: 26e7a236ac7b40df6b3298f02ef6ec9b774e7abee85fa0222af1891423202ae9b76cfbc9e195b34bb311f6f03e48bc35b28d57dd5bbbcf4fdd672917b68b69e0
7
+ data.tar.gz: 5503901acf312ce5b509760ee3af77b68262a537727498522cdea69e35f207eac1f1f7fec0868f3173ccb27ba273b590103c4c8171fae2b249c8badaf76f7355
@@ -12,7 +12,19 @@ module Modsvaskr
12
12
  # Parameters::
13
13
  # * *file* (String): File containing configuration
14
14
  def initialize(file)
15
- @config = YAML.load(File.read(file))
15
+ @config = YAML.load(File.read(file)) || {}
16
+ # Parse all game types plugins
17
+ # Hash<Symbol, Class>
18
+ @game_types = Hash[
19
+ Dir.glob("#{__dir__}/games/*.rb").map do |game_type_file|
20
+ require game_type_file
21
+ base_name = File.basename(game_type_file, '.rb')
22
+ [
23
+ base_name.to_sym,
24
+ Games.const_get(base_name.split('_').collect(&:capitalize).join.to_sym)
25
+ ]
26
+ end
27
+ ]
16
28
  end
17
29
 
18
30
  # Get the games list
@@ -21,9 +33,10 @@ module Modsvaskr
21
33
  # * Array<Game>: List of games
22
34
  def games
23
35
  unless defined?(@games)
24
- @games = @config['games'].map do |game_info|
25
- require "#{__dir__}/games/#{game_info['type']}.rb"
26
- Games.const_get(game_info['type'].to_s.split('_').collect(&:capitalize).join.to_sym).new(self, game_info)
36
+ @games = (@config['games'] || []).map do |game_info|
37
+ game_type = game_info['type'].to_sym
38
+ raise "Unknown game type: #{game_type}. Available ones are #{@game_types.keys.join(', ')}" unless @game_types.key?(game_type)
39
+ @game_types[game_type].new(self, game_info)
27
40
  end
28
41
  end
29
42
  @games
@@ -37,6 +50,30 @@ module Modsvaskr
37
50
  @config['xedit']
38
51
  end
39
52
 
53
+ # Return the 7-Zip path
54
+ #
55
+ # Result::
56
+ # * String: The 7-Zip path
57
+ def seven_zip_path
58
+ @config['7zip']
59
+ end
60
+
61
+ # Return the automated keys to apply
62
+ #
63
+ # Result::
64
+ # * Array<String>: The list of automated keys
65
+ def auto_keys
66
+ @config['auto_keys'] || []
67
+ end
68
+
69
+ # Return the no_prompt flag
70
+ #
71
+ # Result::
72
+ # * Boolean: no_prompt flag
73
+ def no_prompt
74
+ @config['no_prompt'] || false
75
+ end
76
+
40
77
  end
41
78
 
42
79
  end
@@ -16,10 +16,22 @@ module Modsvaskr
16
16
  # * *game_info* (Hash<String,Object>): Game info:
17
17
  # * *name* (String): Game name
18
18
  # * *path* (String): Game installation dir
19
+ # * *launch_exe* (String): Executable to be launched
20
+ # * *min_launch_time_secs* (Integer): Minimum expected lauch time for the game, in seconds [default: 10]
21
+ # * *tests_poll_secs* (Integer): Interval in seconds to be respected between 2 test statuses polling [default: 5]
22
+ # * *timeout_frozen_tests_secs* (Integer): Timeout in seconds of a frozen game [default: 300]
23
+ # * *timeout_interrupt_tests_secs* (Integer): Timeout in seconds for the player to interrupt a tests session before restarting the game [default: 10]
19
24
  def initialize(config, game_info)
20
25
  @config = config
21
- @game_info = game_info
26
+ # Set default values here
27
+ @game_info = {
28
+ 'min_launch_time_secs' => 10,
29
+ 'tests_poll_secs' => 5,
30
+ 'timeout_frozen_tests_secs' => 300,
31
+ 'timeout_interrupt_tests_secs' => 10
32
+ }.merge(game_info)
22
33
  @name = name
34
+ @pid = nil
23
35
  init if self.respond_to?(:init)
24
36
  end
25
37
 
@@ -47,6 +59,30 @@ module Modsvaskr
47
59
  @game_info['launch_exe']
48
60
  end
49
61
 
62
+ # Return the tests polling interval
63
+ #
64
+ # Result::
65
+ # * Integer: Tests polling interval
66
+ def tests_poll_secs
67
+ @game_info['tests_poll_secs']
68
+ end
69
+
70
+ # Return the timeout to detect a frozen game
71
+ #
72
+ # Result::
73
+ # * Integer: Timeout to detect a frozen game
74
+ def timeout_frozen_tests_secs
75
+ @game_info['timeout_frozen_tests_secs']
76
+ end
77
+
78
+ # Return the timeout before restarting a game tests session
79
+ #
80
+ # Result::
81
+ # * Integer: Timeout before restarting a game tests session
82
+ def timeout_interrupt_tests_secs
83
+ @game_info['timeout_interrupt_tests_secs']
84
+ end
85
+
50
86
  # Return an xEdit instance for this game
51
87
  #
52
88
  # Result::
@@ -56,6 +92,88 @@ module Modsvaskr
56
92
  @xedit
57
93
  end
58
94
 
95
+ # Launch the game, and wait for launch to be successful
96
+ #
97
+ # Parameters::
98
+ # * *autoload* (Boolean or String): If false, then launch the game using the normal launcher. If String, then use AutoLoad to load a given saved file (or empty to continue latest save) [default: false].
99
+ def launch(autoload: false)
100
+ # Launch the game
101
+ @idx_launch = 0 unless defined?(@idx_launch)
102
+ if autoload
103
+ log "[ Game #{name} ] - Launch game (##{@idx_launch}) using AutoLoad #{autoload}..."
104
+ autoload_file = "#{path}/Data/AutoLoad.cmd"
105
+ if File.exist?(autoload_file)
106
+ run_cmd({
107
+ dir: path,
108
+ exe: 'Data\AutoLoad.cmd',
109
+ args: [autoload]
110
+ })
111
+ else
112
+ log "[ Game #{name} ] - Missing file #{autoload_file}. Can't use AutoLoad to load game automatically. Please install the AutoLoad mod."
113
+ end
114
+ else
115
+ log "[ Game #{name} ] - Launch game (##{@idx_launch}) using configured launcher (#{launch_exe})..."
116
+ run_cmd({
117
+ dir: path,
118
+ exe: launch_exe
119
+ })
120
+ end
121
+ @idx_launch += 1
122
+ # The game launches asynchronously, so just wait a little bit and check for the process existence
123
+ sleep @game_info['min_launch_time_secs']
124
+ tasklist_stdout = nil
125
+ loop do
126
+ tasklist_stdout = `tasklist | find "#{running_exe}"`.strip
127
+ break unless tasklist_stdout.empty?
128
+ log "[ Game #{name} ] - #{running_exe} is not running. Wait for its startup..."
129
+ sleep 1
130
+ end
131
+ @pid = Integer(tasklist_stdout.split(' ')[1])
132
+ log "[ Game #{name} ] - #{running_exe} has started with PID #{@pid}"
133
+ end
134
+
135
+ # Is the game currently running?
136
+ #
137
+ # Result::
138
+ # * Boolean: Is the game currently running?
139
+ def running?
140
+ if @pid
141
+ running = true
142
+ begin
143
+ # Process.kill does not work when the game has crashed (the process is still detected as zombie)
144
+ # running = Process.kill(0, @pid) == 1
145
+ tasklist_stdout = `tasklist | find "#{running_exe}"`.strip
146
+ running = !tasklist_stdout.empty?
147
+ # log "[ Game #{name} ] - Tasklist returned no #{running_exe}:\n#{tasklist_stdout}" unless running
148
+ rescue Errno::ESRCH
149
+ log "[ Game #{name} ] - Got error while waiting for #{running_exe} PID #{@pid}: #{$!}"
150
+ running = false
151
+ end
152
+ @pid = nil unless running
153
+ running
154
+ else
155
+ false
156
+ end
157
+ end
158
+
159
+ # Kill the game, and wait till it is killed
160
+ def kill
161
+ if @pid
162
+ first_time = true
163
+ while @pid do
164
+ system "taskkill #{first_time ? '' : '/F '}/pid #{@pid}"
165
+ first_time = false
166
+ sleep 1
167
+ if running?
168
+ log "[ Game #{name} ] - #{running_exe} is still running (PID #{@pid}). Wait for its kill..."
169
+ sleep 5
170
+ end
171
+ end
172
+ else
173
+ log "[ Game #{name} ] - Game not started, so nothing to kill."
174
+ end
175
+ end
176
+
59
177
  end
60
178
 
61
179
  end
@@ -9,11 +9,6 @@ module Modsvaskr
9
9
  # Handle a Skyrim installation
10
10
  class SkyrimSe < Game
11
11
 
12
- SEVEN_ZIP_CMD = {
13
- dir: 'C:\Program Files\7-Zip',
14
- exe: '7z.exe'
15
- }
16
-
17
12
  # Initialize the game
18
13
  # [API] - This method is optional
19
14
  def init
@@ -28,8 +23,8 @@ module Modsvaskr
28
23
  def complete_game_menu(menu)
29
24
  menu.item 'Install SKSE64' do
30
25
  install_skse64
31
- puts 'Press Enter to continue...'
32
- $stdin.gets
26
+ out 'Press Enter to continue...'
27
+ wait_for_user_enter
33
28
  end
34
29
  end
35
30
 
@@ -60,7 +55,7 @@ module Modsvaskr
60
55
 
61
56
  # Install SKSE64 corresponding to our game
62
57
  def install_skse64
63
- doc = Nokogiri::HTML(open('https://skse.silverlock.org/'))
58
+ doc = Nokogiri::HTML(URI.open('https://skse.silverlock.org/'))
64
59
  p_element = doc.css('p').find { |el| el.text.strip =~ /^Current SE build .+: 7z archive$/ }
65
60
  if p_element.nil?
66
61
  log '!!! Can\'t get SKSE64 from https://skse.silverlock.org/. It looks like the page structure has changed. Please update the code or install it manually.'
@@ -69,14 +64,20 @@ module Modsvaskr
69
64
  path = "#{@tmp_dir}/skse64.7z"
70
65
  FileUtils.mkdir_p File.dirname(path)
71
66
  log "Download from #{url} => #{path}..."
72
- open(url, 'rb') do |web_io|
67
+ URI.open(url, 'rb') do |web_io|
73
68
  File.write(path, web_io.read, mode: 'wb')
74
69
  end
75
70
  skse64_tmp_dir = "#{@tmp_dir}/skse64"
76
71
  log "Unzip into #{skse64_tmp_dir}..."
77
72
  FileUtils.rm_rf skse64_tmp_dir
78
73
  FileUtils.mkdir_p skse64_tmp_dir
79
- run_cmd SEVEN_ZIP_CMD, args: ['x', "\"#{path}\"", "-o\"#{skse64_tmp_dir}\"", '-r']
74
+ run_cmd(
75
+ {
76
+ dir: @config.seven_zip_path,
77
+ exe: '7z.exe'
78
+ },
79
+ args: ['x', "\"#{path}\"", "-o\"#{skse64_tmp_dir}\"", '-r']
80
+ )
80
81
  skse64_subdir = Dir.glob("#{skse64_tmp_dir}/*").first
81
82
  log "Move files from #{skse64_subdir} to #{self.path}..."
82
83
  FileUtils.cp_r "#{skse64_subdir}/.", self.path, remove_destination: true
@@ -0,0 +1,346 @@
1
+ require 'base64'
2
+ require 'elder_scrolls_plugin'
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require 'timeout'
7
+ require 'modsvaskr/tests_suite'
8
+
9
+ module Modsvaskr
10
+
11
+ # Class getting a simple API to handle tests that are run in-game
12
+ class InGameTestsRunner
13
+
14
+ include Logger
15
+
16
+ # Constructor.
17
+ # Default values are for a standard Skyrim SE installation.
18
+ #
19
+ # Parameters::
20
+ # * *config* (Config): Main configuration
21
+ # * *game* (Game): Game for which we run tests
22
+ def initialize(config, game)
23
+ @config = config
24
+ @game = game
25
+ auto_test_esp = "#{@game.path}/Data/AutoTest.esp"
26
+ # Ordered list of available in-game test suites
27
+ # Array<Symbol>
28
+ @available_tests_suites =
29
+ if File.exist?(auto_test_esp)
30
+ Base64.decode64(
31
+ ElderScrollsPlugin.new(auto_test_esp).
32
+ to_json[:sub_chunks].
33
+ find { |chunk| chunk[:decoded_header][:label] == 'QUST' }[:sub_chunks].
34
+ find do |chunk|
35
+ chunk[:sub_chunks].any? { |sub_chunk| sub_chunk[:name] == 'EDID' && sub_chunk[:data] =~ /AutoTest_ScriptsQuest/ }
36
+ end[:sub_chunks].
37
+ find { |chunk| chunk[:name] == 'VMAD' }[:data]
38
+ ).scan(/AutoTest_Suite_(\w+)/).flatten.map { |tests_suite| tests_suite.downcase.to_sym }
39
+ else
40
+ log "[ In-game testing #{@game.name} ] - Missing file #{auto_test_esp}. In-game tests will be disabled. Please install the AutoTest mod."
41
+ []
42
+ end
43
+ log "[ In-game testing #{@game.name} ] - #{@available_tests_suites.size} available in-game tests suites: #{@available_tests_suites.join(', ')}"
44
+ end
45
+
46
+ # Run in-game tests in a loop until they are all tested
47
+ #
48
+ # Parameters::
49
+ # * *selected_tests* (Hash<Symbol, Array<String> >): Ordered list of in-game tests to be run, per in-game tests suite
50
+ # * Proc: Code called when a in-game test status has changed
51
+ # * Parameters::
52
+ # * *in_game_tests_suite* (Symbol): The in-game tests suite for which test statuses have changed
53
+ # * *in_game_tests_statuses* (Hash<String,String>): Tests statuses, per test name
54
+ def run(selected_tests)
55
+ unknown_tests_suites = selected_tests.keys - @available_tests_suites
56
+ log "[ In-game testing #{@game.name} ] - !!! The following in-game tests suites are not supported: #{unknown_tests_suites.join(', ')}" unless unknown_tests_suites.empty?
57
+ tests_to_run = selected_tests.select { |tests_suite, _tests| !unknown_tests_suites.include?(tests_suite) }
58
+ unless tests_to_run.empty?
59
+ FileUtils.mkdir_p "#{@game.path}/Data/SKSE/Plugins/StorageUtilData"
60
+ tests_to_run.each do |tests_suite, tests|
61
+ # Write the JSON file that contains the list of tests to run
62
+ File.write(
63
+ "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{tests_suite}_Run.json",
64
+ JSON.pretty_generate(
65
+ 'stringList' => {
66
+ 'tests_to_run' => tests
67
+ }
68
+ )
69
+ )
70
+ # Clear the AutoTest test statuses that we are going to run
71
+ statuses_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{tests_suite}_Statuses.json"
72
+ if File.exist?(statuses_file)
73
+ File.write(
74
+ statuses_file,
75
+ JSON.pretty_generate('string' => JSON.parse(File.read(statuses_file))['string'].delete_if { |test_name, _test_status| tests.include?(test_name) })
76
+ )
77
+ end
78
+ end
79
+ auto_test_config_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_Config.json"
80
+ # Write the JSON file that contains the configuration of the AutoTest tests runner
81
+ File.write(
82
+ auto_test_config_file,
83
+ JSON.pretty_generate(
84
+ 'string' => {
85
+ 'on_start' => 'run',
86
+ 'on_stop' => 'exit'
87
+ }
88
+ )
89
+ )
90
+ out ''
91
+ out '=========================================='
92
+ out '= In-game tests are about to be launched ='
93
+ out '=========================================='
94
+ out ''
95
+ out '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):'
96
+ out '* Load the game save you want to test (or start a new game).'
97
+ out ''
98
+ out 'This will execute all in-game tests automatically.'
99
+ out ''
100
+ out 'It is possible that the game crashes during tests:'
101
+ out '* 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.'
102
+ out '* In case of game crash (CTD), the Modsvaskr test framework will relaunch it automatically and resume testing from when it crashed.'
103
+ out '* In case of repeated CTD on the same test, the Modsvaskr test framework will detect it and skip the crashing test automatically.'
104
+ out '* 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.'
105
+ out ''
106
+ out 'If you want to interrupt in-game testing: invoke the console with ~ key and type stop_tests followed by Enter.'
107
+ out ''
108
+ out 'Press enter to start in-game testing (this will lauch your game automatically)...'
109
+ wait_for_user_enter
110
+ last_time_tests_changed = nil
111
+ with_auto_test_monitoring(
112
+ on_auto_test_statuses_diffs: proc do |in_game_tests_suite, in_game_tests_statuses|
113
+ yield in_game_tests_suite, in_game_tests_statuses
114
+ last_time_tests_changed = Time.now
115
+ end
116
+ ) do
117
+ # Loop on (re-)launching the game when we still have tests to perform
118
+ idx_launch = 0
119
+ loop do
120
+ # Check which test is supposed to run first, as it will help in knowing if it fails or not.
121
+ first_tests_suite_to_run = nil
122
+ first_test_to_run = nil
123
+ current_tests_statuses = check_auto_test_statuses
124
+ @available_tests_suites.each do |tests_suite|
125
+ if tests_to_run.key?(tests_suite)
126
+ found_test_ok =
127
+ if current_tests_statuses.key?(tests_suite)
128
+ # Find the first test that would be run (meaning the first one having no status, or status 'started')
129
+ tests_to_run[tests_suite].find do |test_name|
130
+ found_test_name, found_test_status = current_tests_statuses[tests_suite].find { |(current_test_name, _current_test_status)| current_test_name == test_name }
131
+ found_test_name.nil? || found_test_status == 'started'
132
+ end
133
+ else
134
+ # For sure the first test of this suite will be the first one to run
135
+ tests_to_run[tests_suite].first
136
+ end
137
+ if found_test_ok
138
+ first_tests_suite_to_run = tests_suite
139
+ first_test_to_run = found_test_ok
140
+ break
141
+ end
142
+ end
143
+ end
144
+ if first_tests_suite_to_run.nil?
145
+ log "[ In-game testing #{@game.name} ] - No more test to be run."
146
+ break
147
+ else
148
+ log "[ In-game testing #{@game.name} ] - First test to run should be #{first_tests_suite_to_run} / #{first_test_to_run}."
149
+ # Launch the game to execute AutoTest
150
+ @game.launch(autoload: idx_launch == 0 ? false : 'auto_test')
151
+ idx_launch += 1
152
+ log "[ In-game testing #{@game.name} ] - Start monitoring in-game testing..."
153
+ last_time_tests_changed = Time.now
154
+ while @game.running? do
155
+ check_auto_test_statuses
156
+ # If the tests haven't changed for too long, consider the game has frozen, but not crashed. So kill it.
157
+ if Time.now - last_time_tests_changed > @game.timeout_frozen_tests_secs
158
+ log "[ In-game testing #{@game.name} ] - Last time in-game tests statuses have changed is #{last_time_tests_changed.strftime('%F %T')}. Consider the game is frozen, so kill it."
159
+ @game.kill
160
+ else
161
+ sleep @game.tests_poll_secs
162
+ end
163
+ end
164
+ last_test_statuses = check_auto_test_statuses
165
+ # Log latest statuses
166
+ log "[ In-game testing #{@game.name} ] - End monitoring in-game testing. In-game test statuses after game run:"
167
+ last_test_statuses.each do |tests_suite, statuses_for_type|
168
+ log "[ In-game testing #{@game.name} ] - [ #{tests_suite} ] - #{statuses_for_type.select { |(_name, status)| status == 'ok' }.size} / #{statuses_for_type.size}"
169
+ end
170
+ # Check for which reason the game has stopped, and eventually end the testing session.
171
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
172
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
173
+ auto_test_config = Hash[JSON.parse(File.read(auto_test_config_file))['string'].map { |key, value| [key.downcase, value.downcase] }]
174
+ if auto_test_config.dig('stopped_by') == 'user'
175
+ log "[ In-game testing #{@game.name} ] - Tests have been stopped by user."
176
+ break
177
+ end
178
+ if auto_test_config.dig('tests_execution') == 'end'
179
+ log "[ In-game testing #{@game.name} ] - Tests have finished running."
180
+ break
181
+ end
182
+ # From here we know that the game has either crashed or has been killed.
183
+ # This is an abnormal termination of the game.
184
+ # We have to know if this is due to a specific test that fails deterministically, or if it is the engine being unstable.
185
+ # Check the status of the first test that should have been run to know about it.
186
+ first_test_status = nil
187
+ _found_test_name, first_test_status = last_test_statuses[first_tests_suite_to_run].find { |(current_test_name, _current_test_status)| current_test_name == first_test_to_run } if last_test_statuses.key?(first_tests_suite_to_run)
188
+ if first_test_status == 'ok'
189
+ # It's not necessarily deterministic.
190
+ # We just have to go on executing next tests.
191
+ log "[ In-game testing #{@game.name} ] - Tests session has finished in error, certainly due to the game's normal instability. Will resume testing."
192
+ else
193
+ # The first test doesn't pass.
194
+ # We need to mark it as failed, then remove it from the runs.
195
+ log "[ In-game testing #{@game.name} ] - First test #{first_tests_suite_to_run} / #{first_test_to_run} is in error status: #{first_test_status}. Consider it failed and skip it for next run."
196
+ # If the test was started but failed before setting its status to something else then change the test status in the JSON file directly so that AutoTest does not try to re-run it.
197
+ if first_test_status == 'started' || first_test_status == '' || first_test_status.nil?
198
+ File.write(
199
+ "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{first_tests_suite_to_run}_Statuses.json",
200
+ JSON.pretty_generate(
201
+ 'string' => Hash[((last_test_statuses[first_tests_suite_to_run] || []) + [[first_test_to_run, '']]).map do |(test_name, test_status)|
202
+ [
203
+ test_name,
204
+ test_name == first_test_to_run ? 'failed_ctd' : test_status
205
+ ]
206
+ end]
207
+ )
208
+ )
209
+ # Notify the callbacks updating test statuses
210
+ check_auto_test_statuses
211
+ end
212
+ end
213
+ # We will start again. Leave some time to interrupt if we want.
214
+ unless @config.no_prompt
215
+ # First, flush stdin of any pending character
216
+ $stdin.getc while !select([$stdin], nil, nil, 2).nil?
217
+ end
218
+ out "We are going to start again in #{@game.timeout_interrupt_tests_secs} seconds. Press Enter now to interrupt it."
219
+ key_pressed =
220
+ begin
221
+ Timeout.timeout(@game.timeout_interrupt_tests_secs) { $stdin.gets }
222
+ rescue Timeout::Error
223
+ nil
224
+ end
225
+ if key_pressed
226
+ log "[ In-game testing #{@game.name} ] - Run interrupted by user."
227
+ # TODO: Remove AutoTest start on load: it has been interrupted by the user, so we should not keep it in case the user launches the game by itself.
228
+ break
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
235
+
236
+ private
237
+
238
+ # Start an AutoTest monitoring session.
239
+ # This allows checking for test statuses differences easily.
240
+ #
241
+ # Parameters::
242
+ # * *on_auto_test_statuses_diffs* (Proc): Code called when a in-game test status has changed
243
+ # * Parameters::
244
+ # * *in_game_tests_suite* (Symbol): The in-game tests suite for which test statuses have changed
245
+ # * *in_game_tests_statuses* (Hash<String,String>): Tests statuses, per test name
246
+ # * Proc: Code called with monitoring on
247
+ def with_auto_test_monitoring(on_auto_test_statuses_diffs:)
248
+ @last_auto_test_statuses = {}
249
+ @on_auto_test_statuses_diffs = on_auto_test_statuses_diffs
250
+ yield
251
+ end
252
+
253
+ # Check AutoTest test statuses differences, and call some code in case of changes in those statuses.
254
+ # Remember the last checked statuses to only find diffs on next call.
255
+ # Prerequisites: To be called inside a with_auto_test_monitoring block
256
+ #
257
+ # Result::
258
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
259
+ def check_auto_test_statuses
260
+ # Log a diff in tests
261
+ new_statuses = auto_test_statuses
262
+ diff_statuses = diff_statuses(@last_auto_test_statuses, new_statuses)
263
+ unless diff_statuses.empty?
264
+ # Tests have progressed
265
+ log "[ In-game testing #{@game.name} ] - #{diff_statuses.size} tests suites have statuses changes:"
266
+ diff_statuses.each do |tests_suite, tests_statuses|
267
+ log "[ In-game testing #{@game.name} ] * #{tests_suite}:"
268
+ tests_statuses.each do |(test_name, test_status)|
269
+ log "[ In-game testing #{@game.name} ] * #{test_name}: #{test_status}"
270
+ end
271
+ @on_auto_test_statuses_diffs.call(tests_suite, Hash[tests_statuses])
272
+ end
273
+ end
274
+ # Remember the current statuses
275
+ @last_auto_test_statuses = new_statuses
276
+ @last_auto_test_statuses
277
+ end
278
+
279
+ # Get the list of AutoTest statuses
280
+ #
281
+ # Result::
282
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
283
+ def auto_test_statuses
284
+ statuses = {}
285
+ `dir "#{@game.path}/Data/SKSE/Plugins/StorageUtilData" /B`.split("\n").each do |file|
286
+ if file =~ /^AutoTest_(.+)_Statuses\.json$/
287
+ auto_test_suite = $1.downcase.to_sym
288
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
289
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
290
+ statuses[auto_test_suite] = JSON.parse(File.read("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/#{file}"))['string'].map do |test_name, test_status|
291
+ [test_name.downcase, test_status.downcase]
292
+ end
293
+ end
294
+ end
295
+ statuses
296
+ end
297
+
298
+ # Diff between test statuses.
299
+ #
300
+ # Parameters::
301
+ # * *statuses1* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
302
+ # * *statuses2* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
303
+ # Result::
304
+ # * Hash<Symbol, Array<[String, String]> >: statuses2 - statuses1
305
+ def diff_statuses(statuses1, statuses2)
306
+ statuses = {}
307
+ statuses1.each do |tests_suite, tests_info|
308
+ if statuses2.key?(tests_suite)
309
+ # Handle Hashes as it will be faster
310
+ statuses1_for_test = Hash[tests_info]
311
+ statuses2_for_test = Hash[statuses2[tests_suite]]
312
+ statuses1_for_test.each do |test_name, status1|
313
+ if statuses2_for_test.key?(test_name)
314
+ if statuses2_for_test[test_name] != status1
315
+ # Change in status
316
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
317
+ statuses[tests_suite] << [test_name, statuses2_for_test[test_name]]
318
+ end
319
+ else
320
+ # This test has been removed
321
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
322
+ statuses[tests_suite] << [test_name, 'deleted']
323
+ end
324
+ end
325
+ statuses2_for_test.each do |test_name, status2|
326
+ unless statuses1_for_test.key?(test_name)
327
+ # This test has been added
328
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
329
+ statuses[tests_suite] << [test_name, status2]
330
+ end
331
+ end
332
+ else
333
+ # All test statuses have been removed
334
+ statuses[tests_suite] = tests_info.map { |(test_name, _test_status)| [test_name, 'deleted'] }
335
+ end
336
+ end
337
+ statuses2.each do |tests_suite, tests_info|
338
+ # All test statuses have been added
339
+ statuses[tests_suite] = tests_info unless statuses1.key?(tests_suite)
340
+ end
341
+ statuses
342
+ end
343
+
344
+ end
345
+
346
+ end