modsvaskr 0.1.8 → 0.1.12

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,92 +1,106 @@
1
- require 'open-uri'
2
- require 'tmpdir'
3
- require 'nokogiri'
4
-
5
- module Modsvaskr
6
-
7
- module Games
8
-
9
- # Handle a Skyrim installation
10
- class SkyrimSe < Game
11
-
12
- # Initialize the game
13
- # [API] - This method is optional
14
- def init
15
- @tmp_dir = "#{Dir.tmpdir}/modsvaskr"
16
- end
17
-
18
- # Complete the game menu
19
- # [API] - This method is optional
20
- #
21
- # Parameters::
22
- # * *menu* (CursesMenu): Menu to complete
23
- def complete_game_menu(menu)
24
- menu.item 'Install SKSE64' do
25
- install_skse64
26
- out 'Press Enter to continue...'
27
- wait_for_user_enter
28
- end
29
- end
30
-
31
- # Get the game running executable name (that can be found in a tasks manager)
32
- # [API] - This method is mandatory
33
- #
34
- # Result::
35
- # * String: The running exe name
36
- def running_exe
37
- 'SkyrimSE.exe'
38
- end
39
-
40
- # List of default esps present in the game (the ones in the Data folder when 0 mod is being used)
41
- #
42
- # Result::
43
- # * Array<String>: List of esp/esm/esl base file names.
44
- def game_esps
45
- %w[
46
- skyrim.esm
47
- update.esm
48
- dawnguard.esm
49
- hearthfires.esm
50
- dragonborn.esm
51
- ]
52
- end
53
-
54
- private
55
-
56
- # Install SKSE64 corresponding to our game
57
- def install_skse64
58
- doc = Nokogiri::HTML(URI.open('https://skse.silverlock.org/'))
59
- p_element = doc.css('p').find { |el| el.text.strip =~ /^Current SE build .+: 7z archive$/ }
60
- if p_element.nil?
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.'
62
- else
63
- url = "https://skse.silverlock.org/#{p_element.at('a')['href']}"
64
- path = "#{@tmp_dir}/skse64.7z"
65
- FileUtils.mkdir_p File.dirname(path)
66
- log "Download from #{url} => #{path}..."
67
- URI.open(url, 'rb') do |web_io|
68
- File.write(path, web_io.read, mode: 'wb')
69
- end
70
- skse64_tmp_dir = "#{@tmp_dir}/skse64"
71
- log "Unzip into #{skse64_tmp_dir}..."
72
- FileUtils.rm_rf skse64_tmp_dir
73
- FileUtils.mkdir_p skse64_tmp_dir
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
- )
81
- skse64_subdir = Dir.glob("#{skse64_tmp_dir}/*").first
82
- log "Move files from #{skse64_subdir} to #{self.path}..."
83
- FileUtils.cp_r "#{skse64_subdir}/.", self.path, remove_destination: true
84
- log 'SKSE64 installed successfully.'
85
- end
86
- end
87
-
88
- end
89
-
90
- end
91
-
92
- end
1
+ require 'open-uri'
2
+ require 'tmpdir'
3
+ require 'nokogiri'
4
+
5
+ module Modsvaskr
6
+
7
+ module Games
8
+
9
+ # Handle a Skyrim installation
10
+ class SkyrimSe < Game
11
+
12
+ # Initialize the game
13
+ # [API] - This method is optional
14
+ def init
15
+ @tmp_dir = "#{Dir.tmpdir}/modsvaskr"
16
+ end
17
+
18
+ # Complete the game menu
19
+ # [API] - This method is optional
20
+ #
21
+ # Parameters::
22
+ # * *menu* (CursesMenu): Menu to complete
23
+ def complete_game_menu(menu)
24
+ menu.item 'Install SKSE64' do
25
+ install_skse64
26
+ out 'Press Enter to continue...'
27
+ wait_for_user_enter
28
+ end
29
+ end
30
+
31
+ # Get the game running executable name (that can be found in a tasks manager)
32
+ # [API] - This method is mandatory
33
+ #
34
+ # Result::
35
+ # * String: The running exe name
36
+ def running_exe
37
+ 'SkyrimSE.exe'
38
+ end
39
+
40
+ # Ordered list of default esps present in the game (the ones in the Data folder when 0 mod is being used).
41
+ # The list is ordered according to the game's load order.
42
+ # [API] - This method is mandatory
43
+ #
44
+ # Result::
45
+ # * Array<String>: List of esp/esm/esl base file names.
46
+ def game_esps
47
+ %w[
48
+ skyrim.esm
49
+ update.esm
50
+ dawnguard.esm
51
+ hearthfires.esm
52
+ dragonborn.esm
53
+ ]
54
+ end
55
+
56
+ # Read the load order.
57
+ # [API] - This method is mandatory
58
+ #
59
+ # Result::
60
+ # * Array<String>: List of all active plugins, including masters
61
+ def read_load_order
62
+ game_esps +
63
+ File.read("#{ENV['USERPROFILE']}/AppData/Local/Skyrim Special Edition/plugins.txt").split("\n").map do |line|
64
+ line =~ /^\*(.+)$/ ? Regexp.last_match(1).downcase : nil
65
+ end.compact
66
+ end
67
+
68
+ private
69
+
70
+ # Install SKSE64 corresponding to our game
71
+ def install_skse64
72
+ doc = Nokogiri::HTML(URI.open('https://skse.silverlock.org/'))
73
+ p_element = doc.css('p').find { |el| el.text.strip =~ /^Current SE build .+: 7z archive$/ }
74
+ if p_element.nil?
75
+ 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.'
76
+ else
77
+ url = "https://skse.silverlock.org/#{p_element.at('a')['href']}"
78
+ path = "#{@tmp_dir}/skse64.7z"
79
+ FileUtils.mkdir_p File.dirname(path)
80
+ log "Download from #{url} => #{path}..."
81
+ URI.parse(url).open('rb') do |web_io|
82
+ File.write(path, web_io.read, mode: 'wb')
83
+ end
84
+ skse64_tmp_dir = "#{@tmp_dir}/skse64"
85
+ log "Unzip into #{skse64_tmp_dir}..."
86
+ FileUtils.rm_rf skse64_tmp_dir
87
+ FileUtils.mkdir_p skse64_tmp_dir
88
+ run_cmd(
89
+ {
90
+ dir: @config.seven_zip_path,
91
+ exe: '7z.exe'
92
+ },
93
+ args: ['x', "\"#{path}\"", "-o\"#{skse64_tmp_dir}\"", '-r']
94
+ )
95
+ skse64_subdir = Dir.glob("#{skse64_tmp_dir}/*").first
96
+ log "Move files from #{skse64_subdir} to #{self.path}..."
97
+ FileUtils.cp_r "#{skse64_subdir}/.", self.path, remove_destination: true
98
+ log 'SKSE64 installed successfully.'
99
+ end
100
+ end
101
+
102
+ end
103
+
104
+ end
105
+
106
+ end
@@ -1,348 +1,348 @@
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
- if @config.no_prompt
215
- out 'Start again automatically as no_prompt has been set.'
216
- else
217
- # First, flush stdin of any pending character
218
- $stdin.getc while !select([$stdin], nil, nil, 2).nil?
219
- out "We are going to start again in #{@game.timeout_interrupt_tests_secs} seconds. Press Enter now to interrupt it."
220
- key_pressed =
221
- begin
222
- Timeout.timeout(@game.timeout_interrupt_tests_secs) { $stdin.gets }
223
- rescue Timeout::Error
224
- nil
225
- end
226
- if key_pressed
227
- log "[ In-game testing #{@game.name} ] - Run interrupted by user."
228
- # 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.
229
- break
230
- end
231
- end
232
- end
233
- end
234
- end
235
- end
236
- end
237
-
238
- private
239
-
240
- # Start an AutoTest monitoring session.
241
- # This allows checking for test statuses differences easily.
242
- #
243
- # Parameters::
244
- # * *on_auto_test_statuses_diffs* (Proc): Code called when a in-game test status has changed
245
- # * Parameters::
246
- # * *in_game_tests_suite* (Symbol): The in-game tests suite for which test statuses have changed
247
- # * *in_game_tests_statuses* (Hash<String,String>): Tests statuses, per test name
248
- # * Proc: Code called with monitoring on
249
- def with_auto_test_monitoring(on_auto_test_statuses_diffs:)
250
- @last_auto_test_statuses = {}
251
- @on_auto_test_statuses_diffs = on_auto_test_statuses_diffs
252
- yield
253
- end
254
-
255
- # Check AutoTest test statuses differences, and call some code in case of changes in those statuses.
256
- # Remember the last checked statuses to only find diffs on next call.
257
- # Prerequisites: To be called inside a with_auto_test_monitoring block
258
- #
259
- # Result::
260
- # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
261
- def check_auto_test_statuses
262
- # Log a diff in tests
263
- new_statuses = auto_test_statuses
264
- diff_statuses = diff_statuses(@last_auto_test_statuses, new_statuses)
265
- unless diff_statuses.empty?
266
- # Tests have progressed
267
- log "[ In-game testing #{@game.name} ] - #{diff_statuses.size} tests suites have statuses changes:"
268
- diff_statuses.each do |tests_suite, tests_statuses|
269
- log "[ In-game testing #{@game.name} ] * #{tests_suite}:"
270
- tests_statuses.each do |(test_name, test_status)|
271
- log "[ In-game testing #{@game.name} ] * #{test_name}: #{test_status}"
272
- end
273
- @on_auto_test_statuses_diffs.call(tests_suite, Hash[tests_statuses])
274
- end
275
- end
276
- # Remember the current statuses
277
- @last_auto_test_statuses = new_statuses
278
- @last_auto_test_statuses
279
- end
280
-
281
- # Get the list of AutoTest statuses
282
- #
283
- # Result::
284
- # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
285
- def auto_test_statuses
286
- statuses = {}
287
- `dir "#{@game.path}/Data/SKSE/Plugins/StorageUtilData" /B`.split("\n").each do |file|
288
- if file =~ /^AutoTest_(.+)_Statuses\.json$/
289
- auto_test_suite = $1.downcase.to_sym
290
- # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
291
- # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
292
- statuses[auto_test_suite] = JSON.parse(File.read("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/#{file}"))['string'].map do |test_name, test_status|
293
- [test_name.downcase, test_status.downcase]
294
- end
295
- end
296
- end
297
- statuses
298
- end
299
-
300
- # Diff between test statuses.
301
- #
302
- # Parameters::
303
- # * *statuses1* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
304
- # * *statuses2* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
305
- # Result::
306
- # * Hash<Symbol, Array<[String, String]> >: statuses2 - statuses1
307
- def diff_statuses(statuses1, statuses2)
308
- statuses = {}
309
- statuses1.each do |tests_suite, tests_info|
310
- if statuses2.key?(tests_suite)
311
- # Handle Hashes as it will be faster
312
- statuses1_for_test = Hash[tests_info]
313
- statuses2_for_test = Hash[statuses2[tests_suite]]
314
- statuses1_for_test.each do |test_name, status1|
315
- if statuses2_for_test.key?(test_name)
316
- if statuses2_for_test[test_name] != status1
317
- # Change in status
318
- statuses[tests_suite] = [] unless statuses.key?(tests_suite)
319
- statuses[tests_suite] << [test_name, statuses2_for_test[test_name]]
320
- end
321
- else
322
- # This test has been removed
323
- statuses[tests_suite] = [] unless statuses.key?(tests_suite)
324
- statuses[tests_suite] << [test_name, 'deleted']
325
- end
326
- end
327
- statuses2_for_test.each do |test_name, status2|
328
- unless statuses1_for_test.key?(test_name)
329
- # This test has been added
330
- statuses[tests_suite] = [] unless statuses.key?(tests_suite)
331
- statuses[tests_suite] << [test_name, status2]
332
- end
333
- end
334
- else
335
- # All test statuses have been removed
336
- statuses[tests_suite] = tests_info.map { |(test_name, _test_status)| [test_name, 'deleted'] }
337
- end
338
- end
339
- statuses2.each do |tests_suite, tests_info|
340
- # All test statuses have been added
341
- statuses[tests_suite] = tests_info unless statuses1.key?(tests_suite)
342
- end
343
- statuses
344
- end
345
-
346
- end
347
-
348
- end
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.reject { |tests_suite, _tests| unknown_tests_suites.include?(tests_suite) }
58
+ return if tests_to_run.empty?
59
+
60
+ FileUtils.mkdir_p "#{@game.path}/Data/SKSE/Plugins/StorageUtilData"
61
+ tests_to_run.each do |tests_suite, tests|
62
+ # Write the JSON file that contains the list of tests to run
63
+ File.write(
64
+ "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{tests_suite}_Run.json",
65
+ JSON.pretty_generate(
66
+ 'stringList' => {
67
+ 'tests_to_run' => tests
68
+ }
69
+ )
70
+ )
71
+ # Clear the AutoTest test statuses that we are going to run
72
+ statuses_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{tests_suite}_Statuses.json"
73
+ next unless File.exist?(statuses_file)
74
+
75
+ File.write(
76
+ statuses_file,
77
+ JSON.pretty_generate('string' => JSON.parse(File.read(statuses_file))['string'].delete_if { |test_name, _test_status| tests.include?(test_name) })
78
+ )
79
+ end
80
+ auto_test_config_file = "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_Config.json"
81
+ # Write the JSON file that contains the configuration of the AutoTest tests runner
82
+ File.write(
83
+ auto_test_config_file,
84
+ JSON.pretty_generate(
85
+ 'string' => {
86
+ 'on_start' => 'run',
87
+ 'on_stop' => 'exit'
88
+ }
89
+ )
90
+ )
91
+ out ''
92
+ out '=========================================='
93
+ out '= In-game tests are about to be launched ='
94
+ out '=========================================='
95
+ out ''
96
+ 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):'
97
+ out '* Load the game save you want to test (or start a new game).'
98
+ out ''
99
+ out 'This will execute all in-game tests automatically.'
100
+ out ''
101
+ out 'It is possible that the game crashes during tests:'
102
+ 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.'
103
+ out '* In case of game crash (CTD), the Modsvaskr test framework will relaunch it automatically and resume testing from when it crashed.'
104
+ out '* In case of repeated CTD on the same test, the Modsvaskr test framework will detect it and skip the crashing test automatically.'
105
+ 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.'
106
+ out ''
107
+ out 'If you want to interrupt in-game testing: invoke the console with ~ key and type stop_tests followed by Enter.'
108
+ out ''
109
+ out 'Press enter to start in-game testing (this will lauch your game automatically)...'
110
+ wait_for_user_enter
111
+ last_time_tests_changed = nil
112
+ with_auto_test_monitoring(
113
+ on_auto_test_statuses_diffs: proc do |in_game_tests_suite, in_game_tests_statuses|
114
+ yield in_game_tests_suite, in_game_tests_statuses
115
+ last_time_tests_changed = Time.now
116
+ end
117
+ ) do
118
+ # Loop on (re-)launching the game when we still have tests to perform
119
+ idx_launch = 0
120
+ loop do
121
+ # Check which test is supposed to run first, as it will help in knowing if it fails or not.
122
+ first_tests_suite_to_run = nil
123
+ first_test_to_run = nil
124
+ current_tests_statuses = check_auto_test_statuses
125
+ @available_tests_suites.each do |tests_suite|
126
+ next unless tests_to_run.key?(tests_suite)
127
+
128
+ found_test_ok =
129
+ if current_tests_statuses.key?(tests_suite)
130
+ # Find the first test that would be run (meaning the first one having no status, or status 'started')
131
+ tests_to_run[tests_suite].find do |test_name|
132
+ found_test_name, found_test_status = current_tests_statuses[tests_suite].find { |(current_test_name, _current_test_status)| current_test_name == test_name }
133
+ found_test_name.nil? || found_test_status == 'started'
134
+ end
135
+ else
136
+ # For sure the first test of this suite will be the first one to run
137
+ tests_to_run[tests_suite].first
138
+ end
139
+ next unless found_test_ok
140
+
141
+ first_tests_suite_to_run = tests_suite
142
+ first_test_to_run = found_test_ok
143
+ break
144
+ end
145
+ if first_tests_suite_to_run.nil?
146
+ log "[ In-game testing #{@game.name} ] - No more test to be run."
147
+ break
148
+ else
149
+ log "[ In-game testing #{@game.name} ] - First test to run should be #{first_tests_suite_to_run} / #{first_test_to_run}."
150
+ # Launch the game to execute AutoTest
151
+ @game.launch(autoload: idx_launch.zero? ? false : 'auto_test')
152
+ idx_launch += 1
153
+ log "[ In-game testing #{@game.name} ] - Start monitoring in-game testing..."
154
+ last_time_tests_changed = Time.now
155
+ while @game.running?
156
+ check_auto_test_statuses
157
+ # If the tests haven't changed for too long, consider the game has frozen, but not crashed. So kill it.
158
+ if Time.now - last_time_tests_changed > @game.timeout_frozen_tests_secs
159
+ 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."
160
+ @game.kill
161
+ else
162
+ sleep @game.tests_poll_secs
163
+ end
164
+ end
165
+ last_test_statuses = check_auto_test_statuses
166
+ # Log latest statuses
167
+ log "[ In-game testing #{@game.name} ] - End monitoring in-game testing. In-game test statuses after game run:"
168
+ last_test_statuses.each do |tests_suite, statuses_for_type|
169
+ log "[ In-game testing #{@game.name} ] - [ #{tests_suite} ] - #{statuses_for_type.select { |(_name, status)| status == 'ok' }.size} / #{statuses_for_type.size}"
170
+ end
171
+ # Check for which reason the game has stopped, and eventually end the testing session.
172
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
173
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
174
+ auto_test_config = JSON.parse(File.read(auto_test_config_file))['string'].map { |key, value| [key.downcase, value.downcase] }.to_h
175
+ if auto_test_config['stopped_by'] == 'user'
176
+ log "[ In-game testing #{@game.name} ] - Tests have been stopped by user."
177
+ break
178
+ end
179
+ if auto_test_config['tests_execution'] == 'end'
180
+ log "[ In-game testing #{@game.name} ] - Tests have finished running."
181
+ break
182
+ end
183
+ # From here we know that the game has either crashed or has been killed.
184
+ # This is an abnormal termination of the game.
185
+ # We have to know if this is due to a specific test that fails deterministically, or if it is the engine being unstable.
186
+ # Check the status of the first test that should have been run to know about it.
187
+ first_test_status = nil
188
+ _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)
189
+ if first_test_status == 'ok'
190
+ # It's not necessarily deterministic.
191
+ # We just have to go on executing next tests.
192
+ log "[ In-game testing #{@game.name} ] - Tests session has finished in error, certainly due to the game's normal instability. Will resume testing."
193
+ else
194
+ # The first test doesn't pass.
195
+ # We need to mark it as failed, then remove it from the runs.
196
+ 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."
197
+ # 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.
198
+ if first_test_status == 'started' || first_test_status == '' || first_test_status.nil?
199
+ File.write(
200
+ "#{@game.path}/Data/SKSE/Plugins/StorageUtilData/AutoTest_#{first_tests_suite_to_run}_Statuses.json",
201
+ JSON.pretty_generate(
202
+ 'string' => ((last_test_statuses[first_tests_suite_to_run] || []) + [[first_test_to_run, '']]).map do |(test_name, test_status)|
203
+ [
204
+ test_name,
205
+ test_name == first_test_to_run ? 'failed_ctd' : test_status
206
+ ]
207
+ end.to_h
208
+ )
209
+ )
210
+ # Notify the callbacks updating test statuses
211
+ check_auto_test_statuses
212
+ end
213
+ end
214
+ # We will start again. Leave some time to interrupt if we want.
215
+ if @config.no_prompt
216
+ out 'Start again automatically as no_prompt has been set.'
217
+ else
218
+ # First, flush stdin of any pending character
219
+ $stdin.getc until select([$stdin], nil, nil, 2).nil?
220
+ out "We are going to start again in #{@game.timeout_interrupt_tests_secs} seconds. Press Enter now to interrupt it."
221
+ key_pressed =
222
+ begin
223
+ Timeout.timeout(@game.timeout_interrupt_tests_secs) { $stdin.gets }
224
+ rescue Timeout::Error
225
+ nil
226
+ end
227
+ if key_pressed
228
+ log "[ In-game testing #{@game.name} ] - Run interrupted by user."
229
+ # 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.
230
+ break
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
237
+
238
+ private
239
+
240
+ # Start an AutoTest monitoring session.
241
+ # This allows checking for test statuses differences easily.
242
+ #
243
+ # Parameters::
244
+ # * *on_auto_test_statuses_diffs* (Proc): Code called when a in-game test status has changed
245
+ # * Parameters::
246
+ # * *in_game_tests_suite* (Symbol): The in-game tests suite for which test statuses have changed
247
+ # * *in_game_tests_statuses* (Hash<String,String>): Tests statuses, per test name
248
+ # * Proc: Code called with monitoring on
249
+ def with_auto_test_monitoring(on_auto_test_statuses_diffs:)
250
+ @last_auto_test_statuses = {}
251
+ @on_auto_test_statuses_diffs = on_auto_test_statuses_diffs
252
+ yield
253
+ end
254
+
255
+ # Check AutoTest test statuses differences, and call some code in case of changes in those statuses.
256
+ # Remember the last checked statuses to only find diffs on next call.
257
+ # Prerequisites: To be called inside a with_auto_test_monitoring block
258
+ #
259
+ # Result::
260
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
261
+ def check_auto_test_statuses
262
+ # Log a diff in tests
263
+ new_statuses = auto_test_statuses
264
+ diff_statuses = diff_statuses(@last_auto_test_statuses, new_statuses)
265
+ unless diff_statuses.empty?
266
+ # Tests have progressed
267
+ log "[ In-game testing #{@game.name} ] - #{diff_statuses.size} tests suites have statuses changes:"
268
+ diff_statuses.each do |tests_suite, tests_statuses|
269
+ log "[ In-game testing #{@game.name} ] * #{tests_suite}:"
270
+ tests_statuses.each do |(test_name, test_status)|
271
+ log "[ In-game testing #{@game.name} ] * #{test_name}: #{test_status}"
272
+ end
273
+ @on_auto_test_statuses_diffs.call(tests_suite, tests_statuses.to_h)
274
+ end
275
+ end
276
+ # Remember the current statuses
277
+ @last_auto_test_statuses = new_statuses
278
+ @last_auto_test_statuses
279
+ end
280
+
281
+ # Get the list of AutoTest statuses
282
+ #
283
+ # Result::
284
+ # * Hash<Symbol, Array<[String, String]> >: Ordered list of AutoTest [test name, test status], per AutoTest tests suite
285
+ def auto_test_statuses
286
+ statuses = {}
287
+ `dir "#{@game.path}/Data/SKSE/Plugins/StorageUtilData" /B`.split("\n").each do |file|
288
+ next unless file =~ /^AutoTest_(.+)_Statuses\.json$/
289
+
290
+ auto_test_suite = Regexp.last_match(1).downcase.to_sym
291
+ # Careful as this JSON file can be written by Papyrus that treat strings as case insensitive.
292
+ # cf. https://github.com/xanderdunn/skaar/wiki/Common-Tasks
293
+ statuses[auto_test_suite] = JSON.parse(File.read("#{@game.path}/Data/SKSE/Plugins/StorageUtilData/#{file}"))['string'].map do |test_name, test_status|
294
+ [test_name.downcase, test_status.downcase]
295
+ end
296
+ end
297
+ statuses
298
+ end
299
+
300
+ # Diff between test statuses.
301
+ #
302
+ # Parameters::
303
+ # * *statuses_1* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
304
+ # * *statuses_2* (Hash<Symbol, Array<[String, String]> >): Test statuses, per test name (in a sorted list), per tests suite
305
+ # Result::
306
+ # * Hash<Symbol, Array<[String, String]> >: statuses_2 - statuses_1
307
+ def diff_statuses(statuses_1, statuses_2)
308
+ statuses = {}
309
+ statuses_1.each do |tests_suite, tests_info|
310
+ if statuses_2.key?(tests_suite)
311
+ # Handle Hashes as it will be faster
312
+ statuses_1_for_test = tests_info.to_h
313
+ statuses_2_for_test = (statuses_2[tests_suite]).to_h
314
+ statuses_1_for_test.each do |test_name, status_1|
315
+ if statuses_2_for_test.key?(test_name)
316
+ if statuses_2_for_test[test_name] != status_1
317
+ # Change in status
318
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
319
+ statuses[tests_suite] << [test_name, statuses_2_for_test[test_name]]
320
+ end
321
+ else
322
+ # This test has been removed
323
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
324
+ statuses[tests_suite] << [test_name, 'deleted']
325
+ end
326
+ end
327
+ statuses_2_for_test.each do |test_name, status_2|
328
+ next if statuses_1_for_test.key?(test_name)
329
+
330
+ # This test has been added
331
+ statuses[tests_suite] = [] unless statuses.key?(tests_suite)
332
+ statuses[tests_suite] << [test_name, status_2]
333
+ end
334
+ else
335
+ # All test statuses have been removed
336
+ statuses[tests_suite] = tests_info.map { |(test_name, _test_status)| [test_name, 'deleted'] }
337
+ end
338
+ end
339
+ statuses_2.each do |tests_suite, tests_info|
340
+ # All test statuses have been added
341
+ statuses[tests_suite] = tests_info unless statuses_1.key?(tests_suite)
342
+ end
343
+ statuses
344
+ end
345
+
346
+ end
347
+
348
+ end