rpfm_extensions/lua/harness/
mod.rs1use getset::{Getters, Setters};
25use mlua::{HookTriggers, Lua, LuaOptions, StdLib, Table, VmState};
26use serde_derive::{Deserialize, Serialize};
27
28use std::cell::Cell;
29use std::collections::{BTreeMap, HashMap};
30use std::rc::Rc;
31use std::sync::Arc;
32
33use rpfm_lib::error::{RLibError, Result};
34use rpfm_lib::files::{Container, FileType, pack::Pack, RFileDecoded};
35
36use crate::dependencies::Dependencies;
37
38use super::{vanilla_scripts, LuaApi, LuaType};
39
40#[cfg(test)] mod tests;
41
42const ENGINE_SCRIPT: &str = include_str!("engine.lua");
44
45const CAMPAIGN_BOOT_SCRIPT: &str = "script/campaign_scripted.lua";
47
48const TEST_FILE_CHUNK: &str = "@test";
50
51const INSTRUCTION_LIMIT: u64 = 2_000_000_000;
53
54const INSTRUCTION_CHECK_INTERVAL: u32 = 100_000;
56
57#[derive(Clone, Debug, Default)]
65pub struct LuaScripts {
66
67 scripts: Arc<HashMap<String, (String, String)>>,
69
70 pack_paths: Arc<Vec<String>>,
72}
73
74#[derive(Clone, Debug, Default, PartialEq, Getters, Setters, Serialize, Deserialize)]
76#[getset(get = "pub", set = "pub")]
77pub struct LuaTestOptions {
78
79 campaign: Option<String>,
82
83 faction_keys: Vec<String>,
85
86 region_keys: Vec<String>,
88}
89
90#[derive(Clone, Debug, Default, PartialEq, Getters, Serialize, Deserialize)]
92#[getset(get = "pub")]
93pub struct LuaTestReport {
94
95 tests: Vec<LuaTestResult>,
97
98 boot_errors: Vec<String>,
101}
102
103#[derive(Clone, Debug, Default, PartialEq, Getters, Serialize, Deserialize)]
105#[getset(get = "pub")]
106pub struct LuaTestResult {
107
108 name: String,
110
111 passed: bool,
113
114 errors: Vec<String>,
117
118 unmocked_calls: Vec<String>,
121
122 log: Vec<String>,
124}
125
126impl LuaScripts {
131
132 pub fn from_game_and_packs(dependencies: &Dependencies, packs: &BTreeMap<String, Pack>) -> Self {
143 let mut scripts = Self::default();
144 for (path, source) in vanilla_scripts(dependencies) {
145 scripts.add(&path, source, false);
146 }
147
148 for file in packs.values().flat_map(|pack| pack.files_by_type(&[FileType::Text])) {
149 let path = file.path_in_container_raw();
150 if !path.ends_with(".lua") {
151 continue;
152 }
153
154 let source = match file.decoded() {
156 Ok(RFileDecoded::Text(text)) => Some(text.contents().to_owned()),
157 _ => {
158 let mut file = file.clone();
159 file.load().ok().and_then(|_| file.cached().ok().map(|data| String::from_utf8_lossy(data).to_string()))
160 }
161 };
162
163 if let Some(source) = source {
164 scripts.add(path, source, true);
165 }
166 }
167
168 scripts
169 }
170
171 pub fn add(&mut self, path: &str, source: String, from_tested_pack: bool) {
179 let path = path.replace('\\', "/");
180 let key = path.to_lowercase();
181 let source = source.strip_prefix('\u{feff}').map(str::to_owned).unwrap_or(source);
182 if from_tested_pack {
183 Arc::make_mut(&mut self.pack_paths).push(key.to_owned());
184 }
185
186 Arc::make_mut(&mut self.scripts).insert(key, (path, source));
187 }
188
189 fn get(&self, path: &str) -> Option<&(String, String)> {
191 self.scripts.get(&path.to_lowercase())
192 }
193
194 fn list(&self, folder: &str, filter: &str) -> String {
205 let folder = folder.trim_matches('/').to_lowercase() + "/";
206 let extension = filter.strip_prefix("*.").filter(|extension| *extension != "*").map(|extension| format!(".{}", extension.to_lowercase()));
207
208 let mut paths = self.scripts.iter()
209 .filter(|(key, _)| key.strip_prefix(&folder).is_some_and(|name| !name.contains('/')))
210 .filter(|(key, _)| extension.as_ref().is_none_or(|extension| key.ends_with(extension)))
211 .map(|(_, (path, _))| path.as_str())
212 .collect::<Vec<_>>();
213
214 paths.sort_unstable();
215 paths.join(",")
216 }
217
218 fn is_pack_error(&self, error: &str) -> bool {
222 let error = error.to_lowercase();
223 self.pack_paths.iter().any(|path| error.contains(path.as_str()))
224 }
225}
226
227pub fn run_tests(api: &LuaApi, scripts: &LuaScripts, test_source: &str, options: &LuaTestOptions) -> Result<LuaTestReport> {
244 if scripts.get(CAMPAIGN_BOOT_SCRIPT).is_none() {
245 return Err(RLibError::LuaTestGameScriptsNotFound);
246 }
247
248 let mut report = LuaTestReport::default();
249 let mut index = 1;
250
251 loop {
253 let lua = new_state(api, scripts, options)?;
254 load_test_file(&lua, test_source)?;
255
256 let rpfm: Table = lua.globals().get("rpfm").map_err(lua_error)?;
257 let tests: Table = rpfm.get("tests").map_err(lua_error)?;
258 if index > tests.raw_len() {
259 break;
260 }
261
262 let boot: mlua::Function = rpfm.get("__boot").map_err(lua_error)?;
263 let boot_error = boot.call::<Option<String>>(options.campaign.as_deref()).map_err(lua_error)?;
264
265 let (pack_errors, other_errors): (Vec<_>, Vec<_>) = string_list(&rpfm, "errors")?.into_iter().partition(|error| scripts.is_pack_error(error));
266 if index == 1 {
267 report.boot_errors = other_errors;
268 }
269
270 let test: Table = tests.get(index).map_err(lua_error)?;
271 let name: String = test.get("name").map_err(lua_error)?;
272
273 let run_test: mlua::Function = rpfm.get("__run_test").map_err(lua_error)?;
274 let (passed, test_error): (bool, Option<String>) = match run_test.call::<(bool, Option<String>)>(index) {
275 Ok(result) => result,
276 Err(error) => (false, Some(error.to_string())),
277 };
278
279 let mut errors = boot_error.map(|error| vec![format!("The game's script libraries failed to load: {error}")]).unwrap_or_default();
281 errors.extend(pack_errors);
282 errors.extend(test_error);
283 errors.extend(string_list(&rpfm, "errors")?);
284
285 let mut unmocked_calls = rpfm.get::<Table>("unmocked").map_err(lua_error)?
286 .pairs::<String, bool>()
287 .filter_map(|pair| pair.ok().map(|(call, _)| call))
288 .collect::<Vec<_>>();
289 unmocked_calls.sort_unstable();
290
291 report.tests.push(LuaTestResult {
292 name,
293 passed: passed && errors.is_empty(),
294 errors,
295 unmocked_calls,
296 log: string_list(&rpfm, "log")?,
297 });
298
299 index += 1;
300 }
301
302 Ok(report)
303}
304
305fn new_state(api: &LuaApi, scripts: &LuaScripts, options: &LuaTestOptions) -> Result<Lua> {
307
308 let lua = unsafe { Lua::unsafe_new_with(StdLib::ALL_SAFE | StdLib::DEBUG, LuaOptions::default()) };
311
312 let instructions = Rc::new(Cell::new(0u64));
313 lua.set_hook(HookTriggers::new().every_nth_instruction(INSTRUCTION_CHECK_INTERVAL), move |_, _| {
314 instructions.set(instructions.get() + u64::from(INSTRUCTION_CHECK_INTERVAL));
315 if instructions.get() > INSTRUCTION_LIMIT {
316 return Err(mlua::Error::runtime("the scripts ran for too long, they may be stuck in a loop"));
317 }
318 Ok(VmState::Continue)
319 }).map_err(lua_error)?;
320
321 let globals = lua.globals();
322 let read_scripts = scripts.clone();
323 let read_script = lua.create_function(move |_, path: String| Ok(read_scripts.get(&path).map(|(_, source)| source.to_owned()))).map_err(lua_error)?;
324 let list_scripts_map = scripts.clone();
325 let list_scripts = lua.create_function(move |_, (folder, filter): (String, String)| Ok(list_scripts_map.list(&folder, &filter))).map_err(lua_error)?;
326
327 globals.set("rpfm_read_script", read_script).map_err(lua_error)?;
328 globals.set("rpfm_list_scripts", list_scripts).map_err(lua_error)?;
329 globals.set("rpfm_returns", returns_table(&lua, api)?).map_err(lua_error)?;
330 globals.set("rpfm_accessors", accessors_table(&lua, api)?).map_err(lua_error)?;
331 globals.set("rpfm_faction_keys", key_set(&lua, options.faction_keys())?).map_err(lua_error)?;
332 globals.set("rpfm_region_keys", key_set(&lua, options.region_keys())?).map_err(lua_error)?;
333
334 lua.load(ENGINE_SCRIPT).set_name("@engine").exec().map_err(lua_error)?;
335 Ok(lua)
336}
337
338fn load_test_file(lua: &Lua, test_source: &str) -> Result<()> {
340 lua.load(test_source).set_name(TEST_FILE_CHUNK).exec().map_err(|error| RLibError::LuaTestFileError(error.to_string()))
341}
342
343fn returns_table(lua: &Lua, api: &LuaApi) -> Result<Table> {
348 let owners = lua.create_table().map_err(lua_error)?;
349 for (owner, functions) in api.owners() {
350 let methods = lua.create_table().map_err(lua_error)?;
351 for (name, function) in functions {
352 let return_type = function.returns().first().map_or_else(|| "nil".to_owned(), |returned| type_name(api, returned.lua_type()));
353 methods.set(name.as_str(), return_type).map_err(lua_error)?;
354 }
355 owners.set(owner.as_str(), methods).map_err(lua_error)?;
356 }
357
358 Ok(owners)
359}
360
361fn accessors_table(lua: &Lua, api: &LuaApi) -> Result<Table> {
363 let events = lua.create_table().map_err(lua_error)?;
364 for (event, accessors) in api.events() {
365 let types = lua.create_table().map_err(lua_error)?;
366 for (name, accessor) in accessors {
367 types.set(name.as_str(), type_name(api, accessor.lua_type())).map_err(lua_error)?;
368 }
369 events.set(event.as_str(), types).map_err(lua_error)?;
370 }
371
372 Ok(events)
373}
374
375fn key_set(lua: &Lua, keys: &[String]) -> Result<Table> {
377 let set = lua.create_table_with_capacity(0, keys.len()).map_err(lua_error)?;
378 for key in keys {
379 set.set(key.as_str(), true).map_err(lua_error)?;
380 }
381
382 Ok(set)
383}
384
385fn type_name(api: &LuaApi, lua_type: &LuaType) -> String {
387 match lua_type {
388 LuaType::Object(object) => {
389 let interface = format!("{}_SCRIPT_INTERFACE", object.to_uppercase());
390 if api.owners().contains_key(&interface) { interface } else { "any".to_owned() }
391 }
392 _ => lua_type.name().to_owned(),
393 }
394}
395
396fn string_list(table: &Table, field: &str) -> Result<Vec<String>> {
398 let list: Table = table.get(field).map_err(lua_error)?;
399 list.sequence_values::<String>().collect::<mlua::Result<Vec<_>>>().map_err(lua_error)
400}
401
402fn lua_error(error: mlua::Error) -> RLibError {
404 RLibError::LuaRuntimeError(error.to_string())
405}