ExternalTools.cpp 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468
  1. /*
  2. * Copyright (C) 2022 The V-Gears Team
  3. *
  4. * This file is part of V-Gears
  5. *
  6. * V-Gears is free software: you can redistribute it and/or modify it under
  7. * terms of the GNU General Public License as published by the Free Software
  8. * Foundation, version 3.0 (GPLv3) of the License.
  9. *
  10. * V-Gears is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU General Public License for more details.
  14. */
  15. #include <boost/test/unit_test.hpp>
  16. #include <boost/filesystem.hpp>
  17. #include <QtCore/QCoreApplication>
  18. #include <fstream>
  19. #include <sstream>
  20. #include "installer/ExternalTools.h"
  21. namespace fs = boost::filesystem;
  22. using namespace ExternalTools;
  23. namespace{
  24. /**
  25. * The programs are run with Qt, which needs an application object.
  26. */
  27. struct QtApplication{
  28. int argc = 1;
  29. char name[6] = "tests";
  30. char* argv[2] = {name, nullptr};
  31. QCoreApplication application;
  32. QtApplication(): application(argc, argv){}
  33. };
  34. /**
  35. * A temporary directory that is removed on destruction.
  36. */
  37. struct Dir{
  38. fs::path path;
  39. explicit Dir(const std::string& name = ""): path(fs::temp_directory_path() / fs::unique_path("vgears-tools-%%%%-%%%%")){
  40. if (!name.empty()) path /= name;
  41. fs::create_directories(path);
  42. }
  43. ~Dir(){
  44. boost::system::error_code ec;
  45. fs::remove_all(path.parent_path().filename().string().compare(0, 13, "vgears-tools-") == 0 ? path.parent_path() : path, ec);
  46. }
  47. std::string Str() const{return path.string();}
  48. /**
  49. * Creates a file. With a mode of executable it can be run.
  50. */
  51. std::string File(const std::string& name, const std::string& content = "x", bool executable = true) const{
  52. const fs::path file = path / name;
  53. fs::create_directories(file.parent_path());
  54. std::ofstream(file.string()) << content;
  55. fs::permissions(file, executable ? fs::owner_all | fs::group_read | fs::others_read : fs::owner_read | fs::owner_write);
  56. return file.string();
  57. }
  58. };
  59. Environment Unix(const std::string& path){
  60. Environment env;
  61. env.path = path;
  62. env.windows = false;
  63. return env;
  64. }
  65. Environment Windows(const std::string& path, const std::string& pathext = ".COM;.EXE;.BAT;.CMD"){
  66. Environment env;
  67. env.path = path;
  68. env.pathext = pathext;
  69. env.windows = true;
  70. return env;
  71. }
  72. #ifndef _WIN32
  73. /**
  74. * A shell script that behaves like FFmpeg.
  75. *
  76. * @param[in] vorbis True if it says it has the Vorbis encoder.
  77. */
  78. std::string FakeFfmpeg(const Dir& dir, bool vorbis = true, const std::string& name = "ffmpeg"){
  79. std::ostringstream script;
  80. script << "#!/bin/sh\n"
  81. << "if [ \"$1\" = \"-version\" ]; then echo \"ffmpeg version 6.0 Copyright (c) the FFmpeg developers\"; exit 0; fi\n"
  82. << "if [ \"$2\" = \"-encoders\" ]; then echo \" A....D " << (vorbis ? "libvorbis" : "libmp3lame")
  83. << " encoder\"; exit 0; fi\n"
  84. // A conversion: -hide_banner -loglevel panic -y -i INPUT OUTPUT.
  85. << "input=\"$6\"\nfor last; do :; done\n"
  86. << "if [ \"$input\" = \"-\" ]; then cat > \"$last\"; else cat \"$input\" > \"$last\"; fi\n";
  87. return dir.File(name, script.str());
  88. }
  89. /**
  90. * A shell script that behaves like TiMidity: it "renders" the file by printing it with a header.
  91. */
  92. std::string FakeTimidity(const Dir& dir, bool works = true){
  93. std::ostringstream script;
  94. script << "#!/bin/sh\n"
  95. << "if [ \"$1\" = \"-v\" ]; then echo \"TiMidity++ version 2.15.0\"; exit 0; fi\n"
  96. << (works ? "echo RENDERED; cat \"$2\"; exit 0\n" : "exit 3\n");
  97. return dir.File("timidity", script.str());
  98. }
  99. std::string Read(const std::string& path){
  100. std::ifstream file(path);
  101. return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
  102. }
  103. #endif
  104. }
  105. BOOST_GLOBAL_FIXTURE(QtApplication);
  106. // ---------------------------------------------------------------------------------------------------------------
  107. // Searching.
  108. // ---------------------------------------------------------------------------------------------------------------
  109. BOOST_AUTO_TEST_SUITE(ExternalToolsSearchTests)
  110. BOOST_AUTO_TEST_CASE(SearchesThePathInOrder){
  111. Dir first("a");
  112. Dir second("b");
  113. const std::string in_first = first.File("ffmpeg");
  114. const std::string in_second = second.File("ffmpeg");
  115. const auto found = Candidates("ffmpeg", "", {}, Unix(first.Str() + ":" + second.Str()));
  116. BOOST_REQUIRE_EQUAL(found.size(), 2u);
  117. BOOST_CHECK_EQUAL(found[0], in_first);
  118. BOOST_CHECK_EQUAL(found[1], in_second);
  119. }
  120. BOOST_AUTO_TEST_CASE(DirectoriesComeBeforeThePathAndTheSavedPathBeforeBoth){
  121. Dir saved;
  122. Dir tools;
  123. Dir path_dir;
  124. const std::string chosen = saved.File("my-ffmpeg");
  125. const std::string in_tools = tools.File("ffmpeg");
  126. const std::string in_path = path_dir.File("ffmpeg");
  127. const auto found = Candidates("ffmpeg", chosen, {tools.Str()}, Unix(path_dir.Str()));
  128. BOOST_REQUIRE_EQUAL(found.size(), 3u);
  129. BOOST_CHECK_EQUAL(found[0], chosen);
  130. BOOST_CHECK_EQUAL(found[1], in_tools);
  131. BOOST_CHECK_EQUAL(found[2], in_path);
  132. }
  133. BOOST_AUTO_TEST_CASE(SkipsWhatCantBeRun){
  134. Dir dir;
  135. dir.File("not-executable", "x", false);
  136. fs::create_directories(dir.path / "ffmpeg");
  137. // A directory with the name, and a file without the permission to run it.
  138. BOOST_CHECK(Candidates("ffmpeg", "", {}, Unix(dir.Str())).empty());
  139. BOOST_CHECK(Candidates("not-executable", "", {}, Unix(dir.Str())).empty());
  140. // A saved path that doesn't exist is ignored, not returned.
  141. BOOST_CHECK(Candidates("ffmpeg", dir.Str() + "/missing", {}, Unix("")).empty());
  142. }
  143. BOOST_AUTO_TEST_CASE(IgnoresEmptyAndRelativePathEntries){
  144. Dir dir;
  145. dir.File("ffmpeg");
  146. const std::string relative = fs::relative(dir.path, fs::current_path()).string();
  147. // An empty entry means the current directory, and relative ones depend on where the installer is run from.
  148. BOOST_CHECK(Candidates("ffmpeg", "", {}, Unix(":" + relative + "::")).empty());
  149. BOOST_CHECK_EQUAL(Candidates("ffmpeg", "", {}, Unix(":" + dir.Str() + ":")).size(), 1u);
  150. }
  151. BOOST_AUTO_TEST_CASE(NoDuplicates){
  152. Dir dir;
  153. dir.File("ffmpeg");
  154. BOOST_CHECK_EQUAL(Candidates("ffmpeg", dir.Str() + "/ffmpeg", {dir.Str()}, Unix(dir.Str())).size(), 1u);
  155. }
  156. BOOST_AUTO_TEST_CASE(PathsWithSpaces){
  157. Dir dir("Program Files (x86)/with space");
  158. const std::string file = dir.File("ffmpeg");
  159. const auto found = Candidates("ffmpeg", "", {}, Unix(dir.Str()));
  160. BOOST_REQUIRE_EQUAL(found.size(), 1u);
  161. BOOST_CHECK_EQUAL(found[0], file);
  162. }
  163. BOOST_AUTO_TEST_CASE(WindowsUsesPathExt){
  164. Dir bin;
  165. const std::string exe = bin.File("ffmpeg.exe", "x", false);
  166. bin.File("ffmpeg.txt", "x", false);
  167. // Windows doesn't have the executable permission: the extension decides.
  168. const auto found = Candidates("ffmpeg", "", {}, Windows(bin.Str()));
  169. BOOST_REQUIRE_EQUAL(found.size(), 1u);
  170. BOOST_CHECK_EQUAL(found[0], exe);
  171. }
  172. BOOST_AUTO_TEST_CASE(WindowsExtensionsAreTriedInOrderAndCaseDoesNotMatter){
  173. Dir bin;
  174. const std::string cmd = bin.File("timidity.cmd", "x", false);
  175. const std::string upper = bin.File("TIMIDITY.EXE", "x", false);
  176. const auto found = Candidates("timidity", "", {}, Windows(bin.Str(), ".exe;.cmd"));
  177. BOOST_REQUIRE_EQUAL(found.size(), 2u);
  178. BOOST_CHECK_EQUAL(found[0], upper);
  179. BOOST_CHECK_EQUAL(found[1], cmd);
  180. }
  181. BOOST_AUTO_TEST_CASE(WindowsUsesSemicolons){
  182. Dir a("a");
  183. Dir b("b");
  184. a.File("ffmpeg.exe", "x", false);
  185. b.File("ffmpeg.exe", "x", false);
  186. // A drive letter has a colon in it: entries are split with ';' only.
  187. BOOST_CHECK_EQUAL(Candidates("ffmpeg", "", {}, Windows(a.Str() + ";" + b.Str())).size(), 2u);
  188. BOOST_CHECK_EQUAL(Candidates("ffmpeg", "", {}, Windows("")).size(), 0u);
  189. }
  190. BOOST_AUTO_TEST_CASE(EnvironmentOfTheSystem){
  191. const Environment env = Environment::FromSystem();
  192. #ifdef _WIN32
  193. BOOST_CHECK(env.windows);
  194. BOOST_CHECK(!env.pathext.empty());
  195. #else
  196. BOOST_CHECK(!env.windows);
  197. #endif
  198. }
  199. BOOST_AUTO_TEST_SUITE_END()
  200. // ---------------------------------------------------------------------------------------------------------------
  201. // Arguments.
  202. // ---------------------------------------------------------------------------------------------------------------
  203. BOOST_AUTO_TEST_SUITE(ExternalToolsArgumentTests)
  204. BOOST_AUTO_TEST_CASE(FfmpegArgumentsDontNeedQuoting){
  205. const std::vector<std::string> expected = {
  206. "-hide_banner", "-loglevel", "panic", "-y", "-i", "/path with spaces/in put.wav", "/out dir/o'ut.ogg"
  207. };
  208. BOOST_CHECK(FfmpegArguments("/path with spaces/in put.wav", "/out dir/o'ut.ogg") == expected);
  209. }
  210. BOOST_AUTO_TEST_CASE(FfmpegReadsStdin){
  211. const std::vector<std::string> expected = {"-hide_banner", "-loglevel", "panic", "-y", "-i", "-", "out.ogg"};
  212. BOOST_CHECK(FfmpegStdinArguments("out.ogg") == expected);
  213. }
  214. BOOST_AUTO_TEST_CASE(ShortVersions){
  215. Tool ffmpeg;
  216. ffmpeg.version = "ffmpeg version 9.0.2-1 Copyright (c) 2000-2026 the FFmpeg developers";
  217. BOOST_CHECK_EQUAL(ShortVersion(ffmpeg), "9.0.2-1");
  218. Tool timidity;
  219. timidity.version = "TiMidity++ version 2.14.0";
  220. BOOST_CHECK_EQUAL(ShortVersion(timidity), "2.14.0");
  221. // Builds from git have a long version, and some tools say it with another case.
  222. Tool git;
  223. git.version = "FFmpeg Version N-12345-gabcdef Copyright";
  224. BOOST_CHECK_EQUAL(ShortVersion(git), "N-12345-gabcdef");
  225. // Without the word it is the line as it is, and nothing is nothing.
  226. Tool odd;
  227. odd.version = "something 1.0";
  228. BOOST_CHECK_EQUAL(ShortVersion(odd), "something 1.0");
  229. BOOST_CHECK_EQUAL(ShortVersion(Tool()), "");
  230. }
  231. BOOST_AUTO_TEST_CASE(TimidityWritesToStdout){
  232. const std::vector<std::string> expected = {"--quiet=3", "my song.mid", "-Ow", "-o", "-"};
  233. BOOST_CHECK(TimidityArguments("my song.mid") == expected);
  234. }
  235. BOOST_AUTO_TEST_SUITE_END()
  236. #ifndef _WIN32
  237. // ---------------------------------------------------------------------------------------------------------------
  238. // Running programs (with scripts that behave like them).
  239. // ---------------------------------------------------------------------------------------------------------------
  240. BOOST_AUTO_TEST_SUITE(ExternalToolsRunTests)
  241. BOOST_AUTO_TEST_CASE(RunsAndCapturesOutput){
  242. Dir dir;
  243. const std::string script = dir.File("hello", "#!/bin/sh\necho \"hello $1\"\nexit 4\n");
  244. const RunResult result = Run(script, {"world"}, 10000);
  245. BOOST_CHECK(result.started && result.finished);
  246. BOOST_CHECK_EQUAL(result.exit_code, 4);
  247. BOOST_CHECK_EQUAL(result.output, "hello world\n");
  248. }
  249. BOOST_AUTO_TEST_CASE(ArgumentsArriveUnchanged){
  250. Dir dir;
  251. const std::string script = dir.File("args", "#!/bin/sh\nfor a in \"$@\"; do echo \"[$a]\"; done\n");
  252. const RunResult result = Run(script, {"a b", "c'd", "$HOME", "e;f"}, 10000);
  253. // No shell is involved: spaces, quotes and special characters are not interpreted.
  254. BOOST_CHECK_EQUAL(result.output, "[a b]\n[c'd]\n[$HOME]\n[e;f]\n");
  255. }
  256. BOOST_AUTO_TEST_CASE(ProgramThatDoesNotExist){
  257. const RunResult result = Run("/nonexistent/program", {}, 10000);
  258. BOOST_CHECK(!result.started);
  259. }
  260. BOOST_AUTO_TEST_CASE(KillsWhatTakesTooLong){
  261. Dir dir;
  262. const std::string script = dir.File("slow", "#!/bin/sh\nsleep 30\n");
  263. const RunResult result = Run(script, {}, 200);
  264. BOOST_CHECK(result.started);
  265. BOOST_CHECK(!result.finished);
  266. }
  267. BOOST_AUTO_TEST_SUITE_END()
  268. // ---------------------------------------------------------------------------------------------------------------
  269. // Probing and finding.
  270. // ---------------------------------------------------------------------------------------------------------------
  271. BOOST_AUTO_TEST_SUITE(ExternalToolsProbeTests)
  272. BOOST_AUTO_TEST_CASE(AGoodFfmpegIsUsable){
  273. Dir dir;
  274. const Tool tool = ProbeFfmpeg(FakeFfmpeg(dir));
  275. BOOST_CHECK(tool.usable);
  276. BOOST_CHECK(tool.problem.empty());
  277. BOOST_CHECK_EQUAL(tool.version, "ffmpeg version 6.0 Copyright (c) the FFmpeg developers");
  278. }
  279. BOOST_AUTO_TEST_CASE(FfmpegWithoutVorbisIsNotUsable){
  280. Dir dir;
  281. const Tool tool = ProbeFfmpeg(FakeFfmpeg(dir, false));
  282. BOOST_CHECK(!tool.usable);
  283. BOOST_CHECK(tool.problem.find("libvorbis") != std::string::npos);
  284. }
  285. BOOST_AUTO_TEST_CASE(ProgramsThatAreNotFfmpeg){
  286. Dir dir;
  287. BOOST_CHECK(!ProbeFfmpeg(dir.File("fake", "#!/bin/sh\nexit 1\n")).usable);
  288. BOOST_CHECK(!ProbeFfmpeg(dir.File("other", "#!/bin/sh\necho hello\n")).usable);
  289. BOOST_CHECK(!ProbeFfmpeg(dir.File("garbage", "not a program")).usable);
  290. BOOST_CHECK(!ProbeFfmpeg(dir.Str() + "/missing").usable);
  291. }
  292. BOOST_AUTO_TEST_CASE(TimidityProbe){
  293. Dir dir;
  294. const Tool tool = ProbeTimidity(FakeTimidity(dir));
  295. BOOST_CHECK(tool.usable);
  296. BOOST_CHECK_EQUAL(tool.version, "TiMidity++ version 2.15.0");
  297. BOOST_CHECK(!ProbeTimidity(dir.File("fake", "#!/bin/sh\nexit 1\n")).usable);
  298. BOOST_CHECK(!ProbeTimidity(FakeFfmpeg(dir)).usable);
  299. }
  300. BOOST_AUTO_TEST_CASE(FindingFollowsThePriorityAndSkipsBrokenOnes){
  301. Dir broken_dir("broken");
  302. Dir good_dir("good");
  303. const std::string broken = FakeFfmpeg(broken_dir, false);
  304. const std::string good = FakeFfmpeg(good_dir);
  305. Locations locations;
  306. locations.ffmpeg = broken;
  307. // The user's choice doesn't work (no Vorbis), so the next one is used.
  308. Tool tool = FindFfmpeg(locations, Unix(good_dir.Str()));
  309. BOOST_CHECK(tool.usable);
  310. BOOST_CHECK_EQUAL(tool.path, good);
  311. // A choice that works wins over the PATH.
  312. locations.ffmpeg = good;
  313. BOOST_CHECK_EQUAL(FindFfmpeg(locations, Unix(broken_dir.Str())).path, good);
  314. }
  315. BOOST_AUTO_TEST_CASE(FindingInTheToolsDirectories){
  316. Dir tools;
  317. const std::string ffmpeg = FakeFfmpeg(tools);
  318. const std::string timidity = FakeTimidity(tools);
  319. Locations locations;
  320. locations.directories = {tools.Str()};
  321. BOOST_CHECK_EQUAL(FindFfmpeg(locations, Unix("")).path, ffmpeg);
  322. BOOST_CHECK_EQUAL(FindTimidity(locations, Unix("")).path, timidity);
  323. }
  324. BOOST_AUTO_TEST_CASE(NothingFound){
  325. Dir empty;
  326. Locations locations;
  327. const Tool tool = FindFfmpeg(locations, Unix(empty.Str()));
  328. BOOST_CHECK(!tool.usable);
  329. BOOST_CHECK(tool.path.empty());
  330. BOOST_CHECK(tool.problem.find("not found") != std::string::npos);
  331. }
  332. BOOST_AUTO_TEST_CASE(ABrokenProgramIsReportedInsteadOfNotFound){
  333. Dir dir;
  334. FakeFfmpeg(dir, false);
  335. const Tool tool = FindFfmpeg(Locations(), Unix(dir.Str()));
  336. BOOST_CHECK(!tool.usable);
  337. BOOST_CHECK(!tool.path.empty());
  338. BOOST_CHECK(tool.problem.find("libvorbis") != std::string::npos);
  339. }
  340. BOOST_AUTO_TEST_SUITE_END()
  341. // ---------------------------------------------------------------------------------------------------------------
  342. // Converting.
  343. // ---------------------------------------------------------------------------------------------------------------
  344. BOOST_AUTO_TEST_SUITE(ExternalToolsConvertTests)
  345. BOOST_AUTO_TEST_CASE(ConvertsToOgg){
  346. Dir tools;
  347. Dir files("Final Fantasy VII (PC, 1998)");
  348. const std::string ffmpeg = FakeFfmpeg(tools);
  349. const std::string input = files.File("1 sound.wav", "SOUNDDATA");
  350. const std::string output = (files.path / "1 sound.ogg").string();
  351. std::string error;
  352. BOOST_CHECK_MESSAGE(ConvertToOgg(ffmpeg, input, output, &error), error);
  353. BOOST_CHECK_EQUAL(Read(output), "SOUNDDATA");
  354. }
  355. BOOST_AUTO_TEST_CASE(FailedConversionLeavesNothingBehind){
  356. Dir tools;
  357. Dir files;
  358. const std::string failing = tools.File("ffmpeg", "#!/bin/sh\nfor last; do :; done\necho partial > \"$last\"\nexit 1\n");
  359. const std::string input = files.File("a.wav", "x");
  360. const std::string output = (files.path / "a.ogg").string();
  361. std::string error;
  362. BOOST_CHECK(!ConvertToOgg(failing, input, output, &error));
  363. BOOST_CHECK(!fs::exists(output));
  364. BOOST_CHECK(error.find("exit code 1") != std::string::npos);
  365. }
  366. BOOST_AUTO_TEST_CASE(EmptyOutputIsAFailure){
  367. Dir tools;
  368. Dir files;
  369. const std::string silent = tools.File("ffmpeg", "#!/bin/sh\nfor last; do :; done\n: > \"$last\"\nexit 0\n");
  370. const std::string output = (files.path / "a.ogg").string();
  371. BOOST_CHECK(!ConvertToOgg(silent, files.File("a.wav"), output));
  372. BOOST_CHECK(!fs::exists(output));
  373. }
  374. BOOST_AUTO_TEST_CASE(ConvertsMidiThroughAPipe){
  375. Dir tools;
  376. Dir files("my music");
  377. const std::string timidity = FakeTimidity(tools);
  378. const std::string ffmpeg = FakeFfmpeg(tools);
  379. const std::string midi = files.File("5.mid", "MIDIDATA");
  380. const std::string output = (files.path / "5.ogg").string();
  381. std::string error;
  382. BOOST_CHECK_MESSAGE(ConvertMidiToOgg(timidity, ffmpeg, midi, output, &error), error);
  383. // What TiMidity wrote reached FFmpeg, which wrote the file.
  384. BOOST_CHECK_EQUAL(Read(output), "RENDERED\nMIDIDATA");
  385. }
  386. BOOST_AUTO_TEST_CASE(MidiConversionFailsIfTimidityFails){
  387. Dir tools;
  388. Dir files;
  389. const std::string timidity = FakeTimidity(tools, false);
  390. const std::string ffmpeg = FakeFfmpeg(tools);
  391. const std::string output = (files.path / "5.ogg").string();
  392. std::string error;
  393. BOOST_CHECK(!ConvertMidiToOgg(timidity, ffmpeg, files.File("5.mid"), output, &error));
  394. BOOST_CHECK(!fs::exists(output));
  395. BOOST_CHECK(error.find("timidity failed") != std::string::npos);
  396. }
  397. BOOST_AUTO_TEST_CASE(MidiConversionFailsIfProgramsAreMissing){
  398. Dir files;
  399. const std::string output = (files.path / "5.ogg").string();
  400. std::string error;
  401. BOOST_CHECK(!ConvertMidiToOgg("/nonexistent/timidity", "/nonexistent/ffmpeg", files.File("5.mid"), output, &error));
  402. BOOST_CHECK(!fs::exists(output));
  403. BOOST_CHECK(!error.empty());
  404. }
  405. BOOST_AUTO_TEST_SUITE_END()
  406. #endif