Newer
Older
dub_jkp / source / dub / test / base.d
  1. /*******************************************************************************
  2.  
  3. Base utilities (types, functions) used in tests
  4.  
  5. The main type in this module is `TestDub`. `TestDub` is a class that
  6. inherits from `Dub` and inject dependencies in it to avoid relying on IO.
  7. First and foremost, by overriding `makePackageManager` and returning a
  8. `TestPackageManager` instead, we avoid hitting the local filesystem and
  9. instead present a view of the "local packages" that is fully in-memory.
  10. Likewise, by providing a `MockPackageSupplier`, we can imitate the behavior
  11. of the registry without relying on it.
  12.  
  13. Leftover_IO:
  14. Note that reliance on IO was originally all over the place in the Dub
  15. codebase. For this reason, **new tests might find themselves doing I/O**.
  16. When that happens, one should isolate the place which does I/O and refactor
  17. the code to make dependency injection possible and practical.
  18. An example of this is any place calling `Package.load`, `readPackageRecipe`,
  19. or `Package.findPackageFile`.
  20.  
  21. Supported_features:
  22. In order to make writing tests possible and practical, not every features
  23. where implemented in `TestDub`. Notably, path-based packages are not
  24. supported at the moment, as they would need a better filesystem abstraction.
  25. However, it would be desirable to add support for them at some point in the
  26. future.
  27.  
  28. Writing_tests:
  29. `TestDub` exposes a few extra features to make writing tests easier.
  30. Ideally, those extra features should be kept to a minimum, as a convenient
  31. API for writing tests is likely to be a convenient API for library and
  32. application developers as well.
  33. It is expected that most tests will be centered about the `Project`,
  34. also known as the "main package" that is loaded and drives Dub's logic
  35. when common operations such as `dub build` are performed.
  36. A minimalistic and documented unittest can be found in this module,
  37. showing the various features of the test framework.
  38.  
  39. Logging:
  40. Dub writes to stdout / stderr in various places. While it would be desirable
  41. to do dependency injection on it, the benefits brought by doing so currently
  42. doesn't justify the amount of work required. If unittests for some reason
  43. trigger messages being written to stdout/stderr, make sure that the logging
  44. functions are being used instead of bare `write` / `writeln`.
  45.  
  46. *******************************************************************************/
  47.  
  48. module dub.test.base;
  49.  
  50. version (unittest):
  51.  
  52. import std.array;
  53. public import std.algorithm;
  54. import std.datetime.systime;
  55. import std.exception;
  56. import std.format;
  57. import std.string;
  58.  
  59. import dub.data.settings;
  60. public import dub.dependency;
  61. public import dub.dub;
  62. public import dub.package_;
  63. import dub.internal.io.mockfs;
  64. import dub.internal.vibecompat.core.file : FileInfo;
  65. public import dub.internal.io.filesystem;
  66. import dub.packagemanager;
  67. import dub.packagesuppliers.packagesupplier;
  68. import dub.project;
  69. import dub.recipe.io : parsePackageRecipe;
  70. import dub.recipe.selection;
  71.  
  72. /// Example of a simple unittest for a project with a single dependency
  73. unittest
  74. {
  75. // Enabling this would provide some more verbose output, which makes
  76. // debugging a failing unittest much easier.
  77. version (none) {
  78. enableLogging();
  79. scope(exit) disableLogging();
  80. }
  81.  
  82. // Initialization is best done as a delegate passed to `TestDub` constructor,
  83. // which receives an `FSEntry` representing the root of the filesystem.
  84. // Various low-level functions are exposed (mkdir, writeFile, ...),
  85. // as well as higher-level functions (`writePackageFile`).
  86. scope dub = new TestDub((scope Filesystem root) {
  87. // `a` will be loaded as the project while `b` will be loaded
  88. // as a simple package. The recipe files can be in JSON or SDL format,
  89. // here we use both to demonstrate this.
  90. root.writeFile(TestDub.ProjectPath ~ "dub.json",
  91. `{ "name": "a", "dependencies": { "b": "~>1.0" } }`);
  92. root.writeFile(TestDub.ProjectPath ~ "dub.selections.json",
  93. `{"fileVersion": 1, "versions": {"b": "1.1.0"}}`);
  94. // Note that you currently need to add the `version` to the package
  95. root.writePackageFile("b", "1.0.0", `name "b"
  96. version "1.0.0"`, PackageFormat.sdl);
  97. root.writePackageFile("b", "1.1.0", `name "b"
  98. version "1.1.0"`, PackageFormat.sdl);
  99. root.writePackageFile("b", "1.2.0", `name "b"
  100. version "1.2.0"`, PackageFormat.sdl);
  101. });
  102.  
  103. // `Dub.loadPackage` will set this package as the project
  104. // While not required, it follows the common Dub use case.
  105. dub.loadPackage();
  106.  
  107. // Simple tests can be performed using the public API
  108. assert(dub.project.hasAllDependencies(), "project has missing dependencies");
  109. assert(dub.project.getDependency("b", true), "Missing 'b' dependency");
  110. assert(dub.project.getDependency("b", true).version_ == Version("1.1.0"));
  111. // While it is important to make your tests fail before you make them pass,
  112. // as is common with TDD, it can also be useful to test simple assumptions
  113. // as part of your basic tests. Here we want to make sure `getDependency`
  114. // doesn't always return something regardless of its first argument.
  115. // Note that this package segments modules by categories, e.g. dependencies,
  116. // and tests are run serially in a module, so one may rely on previous tests
  117. // having passed to avoid repeating some assumptions.
  118. assert(dub.project.getDependency("no", true) is null, "Returned unexpected dependency");
  119.  
  120. // This triggers the dependency resolution process that happens
  121. // when one does not have a selection file in the project.
  122. // Dub will resolve dependencies and generate the selection file
  123. // (in memory). If your test has set dependencies / no dependencies,
  124. // this will not be needed.
  125. dub.upgrade(UpgradeOptions.select);
  126. assert(dub.project.getDependency("b", true).version_ == Version("1.1.0"));
  127.  
  128. /// Now actually upgrade dependencies in memory
  129. dub.upgrade(UpgradeOptions.select | UpgradeOptions.upgrade);
  130. assert(dub.project.getDependency("b", true).version_ == Version("1.2.0"));
  131.  
  132. /// Adding a package to the registry require the version and at list a recipe
  133. dub.getRegistry().add(Version("1.3.0"), (scope Filesystem pkg) {
  134. // This is required
  135. pkg.writeFile(NativePath(`dub.sdl`), `name "b"`);
  136. // Any other files can be present, as a normal package
  137. pkg.mkdir(NativePath("source/b/"));
  138. pkg.writeFile(
  139. NativePath("main.d"), "module b.main; void main() {}");
  140. });
  141. // Fetch the package from the registry
  142. dub.upgrade(UpgradeOptions.select | UpgradeOptions.upgrade);
  143. assert(dub.project.getDependency("b", true).version_ == Version("1.3.0"));
  144. }
  145.  
  146. // TODO: Remove and handle logging the same way we handle other IO
  147. import dub.internal.logging;
  148.  
  149. public void enableLogging()
  150. {
  151. setLogLevel(LogLevel.debug_);
  152. }
  153.  
  154. public void disableLogging()
  155. {
  156. setLogLevel(LogLevel.none);
  157. }
  158.  
  159. /**
  160. * An instance of Dub that does not rely on the environment
  161. *
  162. * This instance of dub should not read any environment variables,
  163. * nor should it do any file IO, to make it usable and reliable in unittests.
  164. * Currently it reads environment variables but does not read the configuration.
  165. *
  166. * Note that since the design of Dub was centered on the file system for so long,
  167. * `NativePath` is still a core part of how one interacts with this class.
  168. * In order to be as close to the production code as possible, this class
  169. * use the following conventions:
  170. * - The project is located under `/dub/project/`;
  171. * - The user and system packages are under `/dub/user/packages/` and
  172. * `/dub/system/packages/`, respectively;
  173. * Those paths don't need to exists, but they are what one might see
  174. * when writing and debugging unittests.
  175. */
  176. public class TestDub : Dub
  177. {
  178. /// The virtual filesystem that this instance acts on
  179. public MockFS fs;
  180.  
  181. /**
  182. * Redundant reference to the registry
  183. *
  184. * We currently create 2 `MockPackageSupplier`s hidden behind a
  185. * `FallbackPackageSupplier` (see base implementation).
  186. * The fallback is never used, and we need to provide the user
  187. * a mean to access the registry so they can add packages to it.
  188. */
  189. protected MockPackageSupplier registry;
  190.  
  191. /// Convenience constants for use in unittests
  192. version (Windows)
  193. public static immutable Root = NativePath("T:\\dub\\");
  194. else
  195. public static immutable Root = NativePath("/dub/");
  196.  
  197. /// Ditto
  198. public static immutable ProjectPath = Root ~ "project";
  199.  
  200. /// Ditto
  201. public static immutable SpecialDirs Paths = {
  202. temp: Root ~ "temp/",
  203. systemSettings: Root ~ "system/",
  204. userSettings: Root ~ "user/",
  205. userPackages: Root ~ "user/",
  206. cache: Root ~ "user/" ~ "cache/",
  207. };
  208.  
  209. /***************************************************************************
  210.  
  211. Instantiate a new `TestDub` instance with the provided filesystem state
  212.  
  213. This exposes the raw virtual filesystem to the user, allowing any kind
  214. of customization to happen: Empty directory, non-writeable ones, etc...
  215.  
  216. Params:
  217. dg = Delegate to be called with the filesystem, before `TestDub`
  218. instantiation is performed;
  219. root = The root path for this instance (forwarded to Dub)
  220. extras = Extras `PackageSupplier`s (forwarded to Dub)
  221. skip = What `PackageSupplier`s to skip (forwarded to Dub)
  222.  
  223. ***************************************************************************/
  224.  
  225. public this (scope void delegate(scope Filesystem root) dg = null,
  226. string root = ProjectPath.toNativeString(),
  227. PackageSupplier[] extras = null,
  228. SkipPackageSuppliers skip = SkipPackageSuppliers.none)
  229. {
  230. /// Create the fs & its base structure
  231. auto fs_ = new MockFS();
  232. fs_.mkdir(Paths.temp);
  233. fs_.mkdir(Paths.systemSettings);
  234. fs_.mkdir(Paths.userSettings);
  235. fs_.mkdir(Paths.userPackages);
  236. fs_.mkdir(Paths.cache);
  237. fs_.mkdir(ProjectPath);
  238. if (dg !is null) dg(fs_);
  239. this(fs_, root, extras, skip);
  240. }
  241.  
  242. /// Workaround https://issues.dlang.org/show_bug.cgi?id=24388 when called
  243. /// when called with (null, ...).
  244. public this (typeof(null) _,
  245. string root = ProjectPath.toNativeString(),
  246. PackageSupplier[] extras = null,
  247. SkipPackageSuppliers skip = SkipPackageSuppliers.none)
  248. {
  249. alias TType = void delegate(scope Filesystem);
  250. this(TType.init, root, extras, skip);
  251. }
  252.  
  253. /// Internal constructor
  254. private this(MockFS fs_, string root, PackageSupplier[] extras,
  255. SkipPackageSuppliers skip)
  256. {
  257. this.fs = fs_;
  258. super(root, extras, skip);
  259. }
  260.  
  261. /***************************************************************************
  262.  
  263. Get a new `Dub` instance with the same filesystem
  264.  
  265. This creates a new `TestDub` instance with the existing filesystem,
  266. allowing one to write tests that would normally require multiple Dub
  267. instantiation (e.g. test that `fetch` is idempotent).
  268. Like the main `TestDub` constructor, it allows to do modifications to
  269. the filesystem before the new instantiation is made.
  270.  
  271. Params:
  272. dg = Delegate to be called with the filesystem, before `TestDub`
  273. instantiation is performed;
  274.  
  275. Returns:
  276. A new `TestDub` instance referencing the same filesystem as `this`.
  277.  
  278. ***************************************************************************/
  279.  
  280. public TestDub newTest (scope void delegate(scope Filesystem root) dg = null,
  281. string root = ProjectPath.toNativeString(),
  282. PackageSupplier[] extras = null,
  283. SkipPackageSuppliers skip = SkipPackageSuppliers.none)
  284. {
  285. if (dg !is null) dg(this.fs);
  286. return new TestDub(this.fs, root, extras, skip);
  287. }
  288.  
  289. /// Avoid loading user configuration
  290. protected override Settings loadConfig(ref SpecialDirs dirs) const
  291. {
  292. dirs = Paths;
  293. return Settings.init;
  294. }
  295.  
  296. ///
  297. protected override PackageManager makePackageManager()
  298. {
  299. assert(this.fs !is null);
  300. return new TestPackageManager(this.fs);
  301. }
  302.  
  303. /// See `MockPackageSupplier` documentation for this class' implementation
  304. protected override PackageSupplier makePackageSupplier(string url)
  305. {
  306. auto r = new MockPackageSupplier(url);
  307. if (this.registry is null)
  308. this.registry = r;
  309. return r;
  310. }
  311.  
  312. /**
  313. * Returns a fully typed `TestPackageManager`
  314. *
  315. * This exposes the fully typed `PackageManager`, so that client
  316. * can call convenience functions on it directly.
  317. */
  318. public override @property inout(TestPackageManager) packageManager() inout
  319. {
  320. return cast(inout(TestPackageManager)) this.m_packageManager;
  321. }
  322.  
  323. /**
  324. * Returns a fully-typed `MockPackageSupplier`
  325. *
  326. * This exposes the first (and usually sole) `PackageSupplier` if typed
  327. * as `MockPackageSupplier` so that client can call convenience functions
  328. * on it directly.
  329. */
  330. public @property inout(MockPackageSupplier) getRegistry() inout
  331. {
  332. // This will not work with `SkipPackageSupplier`.
  333. assert(this.registry !is null, "The registry hasn't been instantiated?");
  334. return this.registry;
  335. }
  336. }
  337.  
  338. /**
  339. * A `PackageManager` suitable to be used in unittests
  340. *
  341. * This `PackageManager` does not perform any IO. It imitates the base
  342. * `PackageManager`, exposing 3 locations, but loading of packages is not
  343. * automatic and needs to be done by passing a `Package` instance.
  344. */
  345. package class TestPackageManager : PackageManager
  346. {
  347. /// `loadSCMPackage` will strip some part of the remote / repository,
  348. /// which we need to mimic to provide a usable API.
  349. private struct GitReference {
  350. ///
  351. this (in Repository repo) {
  352. this.remote = repo.remote.chompPrefix("git+");
  353. this.ref_ = repo.ref_.chompPrefix("~");
  354. }
  355.  
  356. ///
  357. this (in string remote, in string gitref) {
  358. this.remote = remote;
  359. this.ref_ = gitref;
  360. }
  361.  
  362. string remote;
  363. string ref_;
  364. }
  365.  
  366.  
  367. /// List of all SCM packages that can be fetched by this instance
  368. protected string[GitReference] scm;
  369.  
  370. this(Filesystem filesystem)
  371. {
  372. NativePath local = TestDub.ProjectPath ~ ".dub/packages/";
  373. NativePath user = TestDub.Paths.userSettings ~ "packages/";
  374. NativePath system = TestDub.Paths.systemSettings ~ "packages/";
  375. super(filesystem, local, user, system);
  376. }
  377.  
  378. /**
  379. * Re-Implementation of `gitClone`.
  380. *
  381. * The base implementation will do a `git` clone, to the file-system.
  382. * We need to mock both the `git` part and the write to the file system.
  383. */
  384. protected override bool gitClone(string remote, string gitref, in NativePath dest)
  385. {
  386. if (auto pstr = GitReference(remote, gitref) in this.scm) {
  387. this.fs.mkdir(dest);
  388. this.fs.writeFile(dest ~ "dub.json", *pstr);
  389. return true;
  390. }
  391. return false;
  392. }
  393.  
  394. /// Add a reachable SCM package to this `PackageManager`
  395. public void addTestSCMPackage(in Repository repo, string dub_json)
  396. {
  397. this.scm[GitReference(repo)] = dub_json;
  398. }
  399.  
  400. /// Overriden because we currently don't have a way to do dependency
  401. /// injection on `dub.internal.utils : lockFile`.
  402. public override Package store(ubyte[] data, PlacementLocation dest,
  403. in PackageName name, in Version vers)
  404. {
  405. // Most of the code is copied from the base method
  406. assert(!name.sub.length, "Cannot store a subpackage, use main package instead");
  407. NativePath dstpath = this.getPackagePath(dest, name, vers.toString());
  408. this.fs.mkdir(dstpath.parentPath());
  409.  
  410. if (this.fs.existsFile(dstpath))
  411. return this.getPackage(name, vers, dest);
  412. return this.store_(data, dstpath, name, vers);
  413. }
  414. }
  415.  
  416. /**
  417. * Implements a `PackageSupplier` that doesn't do any IO
  418. *
  419. * This `PackageSupplier` needs to be pre-loaded with `Package` it can
  420. * find during the setup phase of the unittest.
  421. */
  422. public class MockPackageSupplier : PackageSupplier
  423. {
  424. /// Internal duplication to avoid having to deserialize the zip content
  425. private struct PkgData {
  426. ///
  427. PackageRecipe recipe;
  428. ///
  429. ubyte[] data;
  430. }
  431.  
  432. /// Mapping of package name to package zip data, ordered by `Version`
  433. protected PkgData[Version][PackageName] pkgs;
  434.  
  435. /// URL this was instantiated with
  436. protected string url;
  437.  
  438. ///
  439. public this(string url)
  440. {
  441. this.url = url;
  442. }
  443.  
  444. /**
  445. * Adds a package to this `PackageSupplier`
  446. *
  447. * The registry API bakes in Zip files / binary data.
  448. * When adding a package here, just provide an `Filesystem`
  449. * representing the package directory, which will be converted
  450. * to ZipFile / `ubyte[]` and returned by `fetchPackage`.
  451. *
  452. * This use a delegate approach similar to `TestDub` constructor:
  453. * a delegate must be provided to initialize the package content.
  454. * The delegate will be called once and is expected to contain,
  455. * at its root, the package.
  456. *
  457. * The name of the package will be defined from the recipe file.
  458. * It's version, however, must be provided as parameter.
  459. *
  460. * Params:
  461. * vers = The `Version` of this package.
  462. * dg = A delegate that will populate its parameter with the
  463. * content of the package.
  464. */
  465. public void add (in Version vers, scope void delegate(scope Filesystem root) dg)
  466. {
  467. scope pkgRoot = new MockFS();
  468. dg(pkgRoot);
  469.  
  470. string recipe = pkgRoot.existsFile(NativePath("dub.json")) ? "dub.json" : null;
  471. if (recipe is null)
  472. recipe = pkgRoot.existsFile(NativePath("dub.sdl")) ? "dub.sdl" : null;
  473. if (recipe is null)
  474. recipe = pkgRoot.existsFile(NativePath("package.json")) ? "package.json" : null;
  475. // Note: If you want to provide an invalid package, override
  476. // [Mock]PackageSupplier. Most tests will expect a well-behaving
  477. // registry so this assert is here to help with writing tests.
  478. assert(recipe !is null,
  479. "No package recipe found: Expected dub.json or dub.sdl");
  480. auto pkgRecipe = parsePackageRecipe(
  481. pkgRoot.readText(NativePath(recipe)), recipe);
  482. pkgRecipe.version_ = vers.toString();
  483. const name = PackageName(pkgRecipe.name);
  484. this.pkgs[name][vers] = PkgData(
  485. pkgRecipe, pkgRoot.serializeToZip("%s-%s/".format(name, vers)));
  486. }
  487.  
  488. ///
  489. public override @property string description()
  490. {
  491. return "unittest PackageSupplier for: " ~ this.url;
  492. }
  493.  
  494. ///
  495. public override Version[] getVersions(in PackageName name)
  496. {
  497. if (auto ppkgs = name.main in this.pkgs)
  498. return (*ppkgs).keys;
  499. return null;
  500. }
  501.  
  502. ///
  503. public override ubyte[] fetchPackage(in PackageName name,
  504. in VersionRange dep, bool pre_release)
  505. {
  506. return this.getBestMatch(name, dep, pre_release).data;
  507. }
  508.  
  509. ///
  510. public override Json fetchPackageRecipe(in PackageName name,
  511. in VersionRange dep, bool pre_release)
  512. {
  513. import dub.recipe.json;
  514.  
  515. auto match = this.getBestMatch(name, dep, pre_release);
  516. if (!match.data.length)
  517. return Json.init;
  518. auto res = toJson(match.recipe);
  519. return res;
  520. }
  521.  
  522. ///
  523. protected PkgData getBestMatch (
  524. in PackageName name, in VersionRange dep, bool pre_release)
  525. {
  526. auto ppkgs = name.main in this.pkgs;
  527. if (ppkgs is null)
  528. return typeof(return).init;
  529.  
  530. PkgData match;
  531. foreach (vers, pr; *ppkgs)
  532. if ((!vers.isPreRelease || pre_release) &&
  533. dep.matches(vers) &&
  534. (!match.data.length || Version(match.recipe.version_) < vers)) {
  535. match.recipe = pr.recipe;
  536. match.data = pr.data;
  537. }
  538. return match;
  539. }
  540.  
  541. ///
  542. public override SearchResult[] searchPackages(string query)
  543. {
  544. assert(0, this.url ~ " - searchPackages not implemented for: " ~ query);
  545. }
  546. }
  547.  
  548. /**
  549. * Convenience function to write a package file
  550. *
  551. * Allows to write a package file (and only a package file) for a certain
  552. * package name and version.
  553. *
  554. * Params:
  555. * root = The root Filesystem
  556. * name = The package name (typed as string for convenience)
  557. * vers = The package version
  558. * recipe = The text of the package recipe
  559. * fmt = The format used for `recipe` (default to JSON)
  560. * location = Where to place the package (default to user location)
  561. */
  562. public void writePackageFile (Filesystem root, in string name, in string vers,
  563. in string recipe, in PackageFormat fmt = PackageFormat.json,
  564. in PlacementLocation location = PlacementLocation.user)
  565. {
  566. const path = getPackagePath(name, vers, location);
  567. root.mkdir(path);
  568. root.writeFile(
  569. path ~ (fmt == PackageFormat.json ? "dub.json" : "dub.sdl"),
  570. recipe);
  571. }
  572.  
  573. /// Returns: The final destination a specific package needs to be stored in
  574. public static NativePath getPackagePath(in string name_, string vers,
  575. PlacementLocation location = PlacementLocation.user)
  576. {
  577. PackageName name = PackageName(name_);
  578. // Keep in sync with `dub.packagemanager: PackageManager.getPackagePath`
  579. // and `Location.getPackagePath`
  580. NativePath result (in NativePath base)
  581. {
  582. NativePath res = base ~ name.main.toString() ~ vers ~
  583. name.main.toString();
  584. res.endsWithSlash = true;
  585. return res;
  586. }
  587.  
  588. final switch (location) {
  589. case PlacementLocation.user:
  590. return result(TestDub.Paths.userSettings ~ "packages/");
  591. case PlacementLocation.system:
  592. return result(TestDub.Paths.systemSettings ~ "packages/");
  593. case PlacementLocation.local:
  594. return result(TestDub.ProjectPath ~ "/.dub/packages/");
  595. }
  596. }