Browse Source

Merge pull request #199 from vendure-ecommerce/testing-tools

This merge adds a new package, `@vendure/testing` which provides tools to ease e2e testing of custom plugins. Closes #198
Michael Bromley 6 years ago
parent
commit
610eb1ffab
90 changed files with 2092 additions and 1628 deletions
  1. 5 3
      .github/workflows/build_and_test.yml
  2. 2 2
      .lintstagedrc.json
  3. 6 0
      docs/assets/styles/_markdown.scss
  4. 101 0
      docs/content/docs/developer-guide/testing.md
  5. 7 14
      package.json
  6. 1 0
      packages/admin-ui-plugin/package.json
  7. 2 1
      packages/admin-ui/package.json
  8. 2 1
      packages/asset-server-plugin/package.json
  9. 3 1
      packages/common/package.json
  10. 20 1
      packages/common/src/shared-utils.spec.ts
  11. 12 0
      packages/common/src/shared-utils.ts
  12. 72 0
      packages/common/src/simple-deep-clone.spec.ts
  13. 6 1
      packages/common/src/simple-deep-clone.ts
  14. 21 14
      packages/core/e2e/administrator.e2e-spec.ts
  15. 30 29
      packages/core/e2e/auth.e2e-spec.ts
  16. 106 98
      packages/core/e2e/collection.e2e-spec.ts
  17. 6 51
      packages/core/e2e/config/test-config.ts
  18. 36 23
      packages/core/e2e/country.e2e-spec.ts
  19. 95 94
      packages/core/e2e/custom-fields.e2e-spec.ts
  20. 37 37
      packages/core/e2e/customer.e2e-spec.ts
  21. 16 21
      packages/core/e2e/default-search-plugin.e2e-spec.ts
  22. 7 8
      packages/core/e2e/entity-id-strategy.e2e-spec.ts
  23. 15 16
      packages/core/e2e/entity-uuid-strategy.e2e-spec.ts
  24. 50 39
      packages/core/e2e/facet.e2e-spec.ts
  25. 126 0
      packages/core/e2e/fixtures/test-payment-methods.ts
  26. 31 20
      packages/core/e2e/import.e2e-spec.ts
  27. 27 24
      packages/core/e2e/localization.e2e-spec.ts
  28. 24 30
      packages/core/e2e/order-promotion.e2e-spec.ts
  29. 30 104
      packages/core/e2e/order.e2e-spec.ts
  30. 27 30
      packages/core/e2e/plugin.e2e-spec.ts
  31. 17 13
      packages/core/e2e/product-option.e2e-spec.ts
  32. 8 9
      packages/core/e2e/product.e2e-spec.ts
  33. 40 40
      packages/core/e2e/promotion.e2e-spec.ts
  34. 37 28
      packages/core/e2e/role.e2e-spec.ts
  35. 41 43
      packages/core/e2e/shipping-method.e2e-spec.ts
  36. 95 103
      packages/core/e2e/shop-auth.e2e-spec.ts
  37. 9 11
      packages/core/e2e/shop-catalog.e2e-spec.ts
  38. 8 9
      packages/core/e2e/shop-customer.e2e-spec.ts
  39. 30 63
      packages/core/e2e/shop-order.e2e-spec.ts
  40. 20 41
      packages/core/e2e/stock-control.e2e-spec.ts
  41. 0 34
      packages/core/e2e/test-client.ts
  42. 3 2
      packages/core/e2e/utils/await-running-jobs.ts
  43. 0 9
      packages/core/e2e/utils/test-environment.ts
  44. 6 23
      packages/core/e2e/utils/test-order-utils.ts
  45. 32 25
      packages/core/e2e/zone.e2e-spec.ts
  46. 0 14
      packages/core/mock-data/populate-customers.ts
  47. 5 2
      packages/core/package.json
  48. 0 5
      packages/core/src/common/types/common-types.ts
  49. 2 4
      packages/core/src/config/config-helpers.ts
  50. 0 1
      packages/core/src/config/default-config.ts
  51. 2 0
      packages/core/src/config/index.ts
  52. 21 7
      packages/core/src/config/merge-config.spec.ts
  53. 25 20
      packages/core/src/config/merge-config.ts
  54. 14 2
      packages/core/src/config/vendure-config.ts
  55. 0 50
      packages/core/src/data-import/import-cli.ts
  56. 0 4
      packages/core/typings.d.ts
  57. 3 1
      packages/create/package.json
  58. 1 0
      packages/dev-server/.gitignore
  59. 0 1
      packages/dev-server/load-testing/graphql/shop/deep-query.graphql
  60. 14 5
      packages/dev-server/load-testing/init-load-test.ts
  61. 29 8
      packages/dev-server/load-testing/load-test-config.ts
  62. 0 0
      packages/dev-server/load-testing/static/.gitkeep
  63. 1 0
      packages/dev-server/package.json
  64. 21 19
      packages/dev-server/populate-dev-server.ts
  65. 3 1
      packages/elasticsearch-plugin/package.json
  66. 3 1
      packages/email-plugin/package.json
  67. 1 0
      packages/testing/.gitignore
  68. 5 0
      packages/testing/README.md
  69. 47 0
      packages/testing/package.json
  70. 63 0
      packages/testing/src/config/test-config.ts
  71. 1 1
      packages/testing/src/config/testing-asset-preview-strategy.ts
  72. 1 2
      packages/testing/src/config/testing-asset-storage-strategy.ts
  73. 1 1
      packages/testing/src/config/testing-entity-id-strategy.ts
  74. 75 0
      packages/testing/src/create-test-environment.ts
  75. 4 8
      packages/testing/src/data-population/clear-all-tables.ts
  76. 3 5
      packages/testing/src/data-population/mock-data.service.ts
  77. 23 0
      packages/testing/src/data-population/populate-customers.ts
  78. 12 22
      packages/testing/src/data-population/populate-for-testing.ts
  79. 6 0
      packages/testing/src/index.ts
  80. 37 26
      packages/testing/src/simple-graphql-client.ts
  81. 53 38
      packages/testing/src/test-server.ts
  82. 45 0
      packages/testing/src/types.ts
  83. 6 4
      packages/testing/src/utils/create-upload-post-data.spec.ts
  84. 0 0
      packages/testing/src/utils/create-upload-post-data.ts
  85. 1 2
      packages/testing/src/utils/get-default-channel-token.ts
  86. 12 0
      packages/testing/tsconfig.build.json
  87. 10 0
      packages/testing/tsconfig.json
  88. 1 0
      scripts/docs/generate-typescript-docs.ts
  89. 1 0
      scripts/publish-to-verdaccio.sh
  90. 271 259
      yarn.lock

+ 5 - 3
.github/workflows/build_and_test.yml

@@ -27,8 +27,10 @@ jobs:
       run: |
         yarn install
         yarn bootstrap
-        yarn lerna run build
+        yarn build
       env:
         CI: true
-    - name: Test
-      run: yarn test:all
+    - name: Unit tests
+      run: yarn test
+    - name: e2e tests
+      run: yarn e2e

+ 2 - 2
.lintstagedrc.json

@@ -1,6 +1,6 @@
 {
   "admin-ui/**/*.ts": [
-    "yarn lint:admin-ui",
+    "yarn lint",
     "yarn format",
     "git add"
   ],
@@ -9,7 +9,7 @@
     "git add"
   ],
   "packages/**/*.ts": [
-    "yarn lint:packages",
+    "yarn lint",
     "yarn format",
     "git add"
   ]

+ 6 - 0
docs/assets/styles/_markdown.scss

@@ -96,6 +96,12 @@ $block-border-radius: 4px;
         color: $color-code-text;
         border-radius: $block-border-radius;
         border: 1px solid $color-code-border;
+
+
+    }
+
+    a > code:not([data-lang]) {
+        color: $color-link;
     }
 
     pre:not(.chroma) {

+ 101 - 0
docs/content/docs/developer-guide/testing.md

@@ -0,0 +1,101 @@
+---
+title: "Testing"
+showtoc: true
+---
+
+# Testing
+
+Vendure plugins allow you to extend all aspects of the standard Vendure server. When a plugin gets somewhat complex (defining new entities, extending the GraphQL schema, implementing custom resolvers), you may wish to create automated tests to ensure your plugin is correct.
+
+The `@vendure/testing` package gives you some simple but powerful tooling for creating end-to-end tests for your custom Vendure code.
+
+## Usage
+
+### Install dependencies
+
+* [`@vendure/testing`](https://www.npmjs.com/package/@vendure/testing)
+* [`jest`](https://www.npmjs.com/package/jest) You'll need to install a testing framework. In this example we will use [Jest](https://jestjs.io/), but any other framework such as Jasmine should work too.
+* [`graphql-tag`](https://www.npmjs.com/package/graphql-tag) This is not strictly required but makes it much easier to create the DocumentNodes needed to query your server.
+
+Please see the [Jest documentation](https://jestjs.io/docs/en/getting-started) on how to get set up. The remainder of this article will assume a working Jest setup configured to work with TypeScript.
+
+### Create a test environment
+
+The `@vendure/testing` package exports a [`createTestEnvironment` function]({{< relref "create-test-environment" >}}) which is used to set up a Vendure server and GraphQL clients to interact with both the Shop and Admin APIs:
+
+```TypeScript
+import { createTestEnvironment, testConfig } from '@vendure/testing';
+import { MyPlugin } from '../my-plugin.ts';
+
+describe('my plugin', () => {
+
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        plugins: [MyPlugin],
+    });
+
+});
+```
+
+Notice that we pass a [`VendureConfig`]({{< relref "vendure-config" >}}) object into the `createTestEnvironment` function. The testing package provides a special [`testConfig`]({{< relref "test-config" >}}) which is pre-configured for e2e tests, but any aspect can be overridden for your tests. Here we are configuring the server to load the plugin under test, `MyPlugin`. 
+
+{{% alert "warning" %}}
+**Note**: If you need to deeply merge in some custom configuration, use the [`mergeConfig` function]({{< relref "merge-config" >}}) which is provided by `@vendure/core`.
+{{% /alert %}}
+
+### Initialize the server
+
+The [`TestServer`]({{< relref "test-server" >}}) needs to be initialized before it can be used. The `TestServer.init()` method takes an options object which defines how to populate the server:
+
+```TypeScript
+import { myInitialData } from './fixtures/my-initial-data.ts';
+
+// ...
+
+beforeAll(async () => {
+    await server.init({
+        productsCsvPath: path.join(__dirname, 'fixtures/e2e-products.csv'),
+        initialData: myInitialData,
+        dataDir: path.join(__dirname, '__data__'),
+        customerCount: 2,
+    });
+    await adminClient.asSuperAdmin();
+}, 60000);
+
+afterAll(async () => {
+    await server.destroy();
+});
+```
+
+An explanation of the options:
+
+* `productsCsvPath` This is a path to a CSV file containing product data. The format is as-yet undocumented and may be subject to change, but you can see [an example used in the Vendure e2e tests](https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/e2e/fixtures/e2e-products-full.csv) to get an idea of how it works. To start with you can just copy this file directly and use it as-is.
+* `initialData` This is an object which defines how other non-product data (Collections, ShippingMethods, Countries etc.) is populated. Again, the best idea is to [copy this example from the Vendure e2e tests](https://github.com/vendure-ecommerce/vendure/blob/master/packages/core/e2e/fixtures/e2e-initial-data.ts)
+* `dataDir` The first time this test suite is run, the data populated by the options above will be saved into an SQLite file, stored in the directory specified by this options. On subsequent runs of the test suite, the data-population step will be skipped and the data directly loaded from the SQLite file. This method of caching significantly speeds up the e2e test runs. All the .sqlite files created in the `dataDir` can safely be deleted at any time.
+* `customerCount` Specifies the number of fake Customers to create. Defaults to 10 if not specified.
+
+### Write your tests
+
+Now we are all set up to create a test. Let's test one of the GraphQL queries used by our fictional plugin:
+
+```TypeScript
+import gql from 'graphql-tag';
+
+it('myNewQuery returns the expected result', async () => {
+    adminClient.asSuperAdmin(); // log in as the SuperAdmin user
+
+    const query = gql`
+        query MyNewQuery($id: ID!) {
+            myNewQuery(id: $id) {
+                field1
+                field2
+            }
+        }
+    `;
+    const result = await adminClient.query(query, { id: 123 });
+
+    expect(result.myNewQuery).toEqual({ /* ... */ })
+});
+```
+
+Running the test will then assert that your new query works as expected.

+ 7 - 14
package.json

@@ -6,7 +6,9 @@
     "node": ">= 10.12.0 < 11 || >= 12.00"
   },
   "scripts": {
-    "core:watch": "concurrently -n tsc,gulp,common \"cd packages/core && yarn tsc:watch\" \"cd packages/core && yarn gulp:watch\" \"cd packages/common && yarn watch\"",
+    "watch": "lerna run watch --parallel",
+    "lint": "yarn tslint --fix",
+    "format": "prettier --write --html-whitespace-sensitivity ignore",
     "bootstrap": "lerna bootstrap",
     "docs:watch": "concurrently --restart-tries 5 -n docgen,hugo,webpack -c green,blue,cyan \"yarn generate-graphql-docs && yarn generate-typescript-docs -w\" \"cd docs && hugo server\" \"cd docs && yarn webpack -w\"",
     "docs:build": "yarn generate-graphql-docs && yarn generate-typescript-docs && cd docs && yarn webpack --prod && node build.js && hugo",
@@ -14,19 +16,10 @@
     "codegen": "ts-node scripts/codegen/generate-graphql-types.ts",
     "generate-typescript-docs": "ts-node scripts/docs/generate-typescript-docs.ts",
     "generate-graphql-docs": "ts-node scripts/docs/generate-graphql-docs.ts --api=shop && ts-node scripts/docs/generate-graphql-docs.ts --api=admin",
-    "format": "prettier --write --html-whitespace-sensitivity ignore",
-    "lint:packages": "yarn tslint --fix",
-    "lint:admin-ui": "cd packages/admin-ui && yarn lint --fix",
     "version": "yarn check-imports && yarn build && yarn generate-changelog && git add CHANGELOG.md",
     "dev-server:start": "cd packages/dev-server && yarn start",
-    "dev-server:populate": "cd packages/dev-server && yarn populate",
-    "test:all": "yarn test:admin-ui && yarn test:common && yarn test:core && yarn test:email-plugin && yarn test:elasticsearch-plugin && yarn test:e2e",
-    "test:common": "jest --config packages/common/jest.config.js",
-    "test:core": "jest --config packages/core/jest.config.js",
-    "test:email-plugin": "jest --config packages/email-plugin/jest.config.js --maxWorkers=1",
-    "test:elasticsearch-plugin": "jest --config packages/elasticsearch-plugin/jest.config.js",
-    "test:e2e": "jest --config packages/core/e2e/config/jest-e2e.json --runInBand",
-    "test:admin-ui": "cd packages/admin-ui && yarn test --watch=false --browsers=ChromeHeadlessCI --progress=false",
+    "test": "lerna run test --stream --no-bail",
+    "e2e": "lerna run e2e --stream --no-bail",
     "build": "lerna run build",
     "check-imports": "ts-node scripts/check-imports.ts",
     "generate-changelog": "ts-node scripts/changelogs/generate-changelog.ts",
@@ -53,7 +46,7 @@
     "husky": "^3.0.0",
     "jest": "^24.5.0",
     "klaw-sync": "^6.0.0",
-    "lerna": "^3.14.1",
+    "lerna": "^3.16.4",
     "lint-staged": "^9.2.0",
     "prettier": "^1.15.2",
     "ts-jest": "^24.1.0",
@@ -89,7 +82,7 @@
       "commit-msg": "commitlint -e $HUSKY_GIT_PARAMS",
       "post-commit": "git update-index --again",
       "pre-commit": "lint-staged",
-      "pre-push": "yarn test:all && cd packages/admin-ui && yarn build"
+      "pre-push": "yarn build && yarn test && yarn e2e"
     }
   }
 }

+ 1 - 0
packages/admin-ui-plugin/package.json

@@ -10,6 +10,7 @@
   "scripts": {
     "build": "rimraf lib && node -r ts-node/register build.ts && yarn compile",
     "watch": "tsc -p ./tsconfig.build.json --watch",
+    "lint": "tslint --fix --project ./",
     "compile": "tsc -p ./tsconfig.build.json"
   },
   "publishConfig": {

+ 2 - 1
packages/admin-ui/package.json

@@ -5,8 +5,9 @@
     "ng": "ng",
     "start": "ng serve",
     "build": "yarn reset-extensions && ng build --prod && yarn build:devkit",
+    "watch": "ng build --watch=true",
     "build:devkit": "tsc -p tsconfig.devkit.json",
-    "test": "ng test",
+    "test": "ng test --watch=false --browsers=ChromeHeadlessCI --progress=false",
     "lint": "tslint --fix",
     "reset-extensions": "rimraf ./src/app/extensions/modules && rimraf ./src/app/extensions/*.generated && rimraf ./src/app/extensions/*.temp",
     "extract-translations": "ngx-translate-extract --input ./src --output ./src/i18n-messages/en.json --clean --sort --format namespaced-json --format-indentation \"  \" -m _"

+ 2 - 1
packages/asset-server-plugin/package.json

@@ -9,7 +9,8 @@
   "license": "MIT",
   "scripts": {
     "watch": "tsc -p ./tsconfig.build.json --watch",
-    "build": "rimraf lib && tsc -p ./tsconfig.build.json"
+    "build": "rimraf lib && tsc -p ./tsconfig.build.json",
+    "lint": "tslint --fix --project ./"
   },
   "publishConfig": {
     "access": "public"

+ 3 - 1
packages/common/package.json

@@ -5,7 +5,9 @@
   "license": "MIT",
   "scripts": {
     "watch": "tsc -p ./tsconfig.build.json -w",
-    "build": "rimraf dist && tsc -p ./tsconfig.build.json"
+    "build": "rimraf dist && tsc -p ./tsconfig.build.json",
+    "lint": "tslint --fix --project ./",
+    "test": "jest --config ./jest.config.js"
   },
   "publishConfig": {
     "access": "public"

+ 20 - 1
packages/common/src/shared-utils.spec.ts

@@ -1,4 +1,4 @@
-import { generateAllCombinations } from './shared-utils';
+import { generateAllCombinations, isClassInstance } from './shared-utils';
 
 describe('generateAllCombinations()', () => {
     it('works with an empty input array', () => {
@@ -28,3 +28,22 @@ describe('generateAllCombinations()', () => {
         expect(result).toEqual([['red'], ['green'], ['blue']]);
     });
 });
+
+describe('isClassInstance()', () => {
+    it('returns true for class instances', () => {
+        expect(isClassInstance(new Date())).toBe(true);
+        expect(isClassInstance(new Foo())).toBe(true);
+        // tslint:disable-next-line:no-construct
+        expect(isClassInstance(new Number(1))).toBe(true);
+    });
+
+    it('returns false for not class instances', () => {
+        expect(isClassInstance(Date)).toBe(false);
+        expect(isClassInstance(1)).toBe(false);
+        expect(isClassInstance(Number)).toBe(false);
+        expect(isClassInstance({ a: 1 })).toBe(false);
+        expect(isClassInstance([1, 2, 3])).toBe(false);
+    });
+
+    class Foo {}
+});

+ 12 - 0
packages/common/src/shared-utils.ts

@@ -13,6 +13,18 @@ export function assertNever(value: never): never {
     throw new Error(`Expected never, got ${typeof value}`);
 }
 
+/**
+ * Simple object check.
+ * From https://stackoverflow.com/a/34749873/772859
+ */
+export function isObject(item: any): item is object {
+    return item && typeof item === 'object' && !Array.isArray(item);
+}
+
+export function isClassInstance(item: any): boolean {
+    return isObject(item) && item.constructor.name !== 'Object';
+}
+
 /**
  * Given an array of option arrays `[['red, 'blue'], ['small', 'large']]`, this method returns a new array
  * containing all the combinations of those options:

+ 72 - 0
packages/common/src/simple-deep-clone.spec.ts

@@ -0,0 +1,72 @@
+import { simpleDeepClone } from './simple-deep-clone';
+
+describe('simpleDeepClone()', () => {
+    it('clones a simple flat object', () => {
+        const target = { a: 1, b: 2 };
+        const result = simpleDeepClone(target);
+
+        expect(result).toEqual(target);
+        expect(result).not.toBe(target);
+    });
+
+    it('clones a simple deep object', () => {
+        const target = { a: 1, b: { c: 3, d: [1, 2, 3] } };
+        const result = simpleDeepClone(target);
+
+        expect(result).toEqual(target);
+        expect(result).not.toBe(target);
+    });
+
+    it('clones a simple flat array', () => {
+        const target = [1, 2, 3];
+        const result = simpleDeepClone(target);
+
+        expect(result).toEqual(target);
+        expect(result).not.toBe(target);
+    });
+
+    it('clones a simple deep array', () => {
+        const target = [1, [2, 3], [4, [5, [6]]]];
+        const result = simpleDeepClone(target);
+
+        expect(result).toEqual(target);
+        expect(result).not.toBe(target);
+    });
+
+    it('passes through primitive types', () => {
+        expect(simpleDeepClone(1)).toBe(1);
+        expect(simpleDeepClone('a')).toBe('a');
+        expect(simpleDeepClone(true as any)).toBe(true);
+        expect(simpleDeepClone(null as any)).toBe(null);
+        expect(simpleDeepClone(undefined as any)).toBe(undefined);
+    });
+
+    it('does not clone class instance', () => {
+        const target = new Foo();
+        const result = simpleDeepClone(target);
+
+        expect(result).toBe(target);
+    });
+
+    it('does not clone class instance in array', () => {
+        const foo = new Foo();
+        const target = [foo];
+        const result = simpleDeepClone(target);
+
+        expect(result).toEqual(target);
+        expect(result).not.toBe(target);
+        expect(result[0]).toBe(target[0]);
+    });
+
+    it('does not clone class instance in object', () => {
+        const foo = new Foo();
+        const target = { a: foo };
+        const result = simpleDeepClone(target);
+
+        expect(result).toEqual(target);
+        expect(result).not.toBe(target);
+        expect(result.a).toBe(target.a);
+    });
+});
+
+class Foo {}

+ 6 - 1
packages/common/src/simple-deep-clone.ts

@@ -1,3 +1,5 @@
+import { isClassInstance } from './shared-utils';
+
 /**
  * An extremely fast function for deep-cloning an object which only contains simple
  * values, i.e. primitives, arrays and nested simple objects.
@@ -18,10 +20,13 @@ export function simpleDeepClone<T extends string | number | any[] | object>(inpu
         }
         return output;
     }
+    if (isClassInstance(input)) {
+        return input;
+    }
     // handle case: object
     output = {};
     for (i in input) {
-        if ((input).hasOwnProperty(i)) {
+        if (input.hasOwnProperty(i)) {
             output[i] = simpleDeepClone((input as any)[i]);
         }
     }

+ 21 - 14
packages/core/e2e/administrator.e2e-spec.ts

@@ -1,25 +1,32 @@
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { ADMINISTRATOR_FRAGMENT } from './graphql/fragments';
-import { Administrator, CreateAdministrator, GetAdministrator, GetAdministrators, UpdateAdministrator } from './graphql/generated-e2e-admin-types';
+import {
+    Administrator,
+    CreateAdministrator,
+    GetAdministrator,
+    GetAdministrators,
+    UpdateAdministrator,
+} from './graphql/generated-e2e-admin-types';
 import { CREATE_ADMINISTRATOR } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('Administrator resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
     let createdAdmin: Administrator.Fragment;
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
             customerCount: 1,
         });
-        await client.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -27,7 +34,7 @@ describe('Administrator resolver', () => {
     });
 
     it('administrators', async () => {
-        const result = await client.query<GetAdministrators.Query, GetAdministrators.Variables>(
+        const result = await adminClient.query<GetAdministrators.Query, GetAdministrators.Variables>(
             GET_ADMINISTRATORS,
         );
         expect(result.administrators.items.length).toBe(1);
@@ -35,7 +42,7 @@ describe('Administrator resolver', () => {
     });
 
     it('createAdministrator', async () => {
-        const result = await client.query<CreateAdministrator.Mutation, CreateAdministrator.Variables>(
+        const result = await adminClient.query<CreateAdministrator.Mutation, CreateAdministrator.Variables>(
             CREATE_ADMINISTRATOR,
             {
                 input: {
@@ -53,7 +60,7 @@ describe('Administrator resolver', () => {
     });
 
     it('administrator', async () => {
-        const result = await client.query<GetAdministrator.Query, GetAdministrator.Variables>(
+        const result = await adminClient.query<GetAdministrator.Query, GetAdministrator.Variables>(
             GET_ADMINISTRATOR,
             {
                 id: createdAdmin.id,
@@ -63,7 +70,7 @@ describe('Administrator resolver', () => {
     });
 
     it('updateAdministrator', async () => {
-        const result = await client.query<UpdateAdministrator.Mutation, UpdateAdministrator.Variables>(
+        const result = await adminClient.query<UpdateAdministrator.Mutation, UpdateAdministrator.Variables>(
             UPDATE_ADMINISTRATOR,
             {
                 input: {
@@ -80,7 +87,7 @@ describe('Administrator resolver', () => {
     });
 
     it('updateAdministrator works with partial input', async () => {
-        const result = await client.query<UpdateAdministrator.Mutation, UpdateAdministrator.Variables>(
+        const result = await adminClient.query<UpdateAdministrator.Mutation, UpdateAdministrator.Variables>(
             UPDATE_ADMINISTRATOR,
             {
                 input: {
@@ -98,7 +105,7 @@ describe('Administrator resolver', () => {
         'updateAdministrator throws with invalid roleId',
         assertThrowsWithMessage(
             () =>
-                client.query<UpdateAdministrator.Mutation, UpdateAdministrator.Variables>(
+                adminClient.query<UpdateAdministrator.Mutation, UpdateAdministrator.Variables>(
                     UPDATE_ADMINISTRATOR,
                     {
                         input: {

+ 30 - 29
packages/core/e2e/auth.e2e-spec.ts

@@ -1,10 +1,12 @@
 /* tslint:disable:no-non-null-assertion */
 import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '@vendure/common/lib/shared-constants';
+import { createTestEnvironment } from '@vendure/testing';
 import { DocumentNode } from 'graphql';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { CURRENT_USER_FRAGMENT } from './graphql/fragments';
 import {
     CreateAdministrator,
@@ -23,20 +25,19 @@ import {
     GET_PRODUCT_LIST,
     UPDATE_PRODUCT,
 } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('Authorization & permissions', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
             customerCount: 1,
         });
-        await client.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -46,13 +47,13 @@ describe('Authorization & permissions', () => {
     describe('admin permissions', () => {
         describe('Anonymous user', () => {
             beforeAll(async () => {
-                await client.asAnonymousUser();
+                await adminClient.asAnonymousUser();
             });
 
             it(
                 'me is not permitted',
                 assertThrowsWithMessage(async () => {
-                    await client.query<Me.Query>(ME);
+                    await adminClient.query<Me.Query>(ME);
                 }, 'You are not currently authorized to perform this action'),
             );
 
@@ -67,15 +68,15 @@ describe('Authorization & permissions', () => {
 
         describe('ReadCatalog permission', () => {
             beforeAll(async () => {
-                await client.asSuperAdmin();
+                await adminClient.asSuperAdmin();
                 const { identifier, password } = await createAdministratorWithPermissions('ReadCatalog', [
                     Permission.ReadCatalog,
                 ]);
-                await client.asUserWithCredentials(identifier, password);
+                await adminClient.asUserWithCredentials(identifier, password);
             });
 
             it('me returns correct permissions', async () => {
-                const { me } = await client.query<Me.Query>(ME);
+                const { me } = await adminClient.query<Me.Query>(ME);
 
                 expect(me!.channels[0].permissions).toEqual([
                     Permission.Authenticated,
@@ -107,18 +108,18 @@ describe('Authorization & permissions', () => {
 
         describe('CRUD on Customers permissions', () => {
             beforeAll(async () => {
-                await client.asSuperAdmin();
+                await adminClient.asSuperAdmin();
                 const { identifier, password } = await createAdministratorWithPermissions('CRUDCustomer', [
                     Permission.CreateCustomer,
                     Permission.ReadCustomer,
                     Permission.UpdateCustomer,
                     Permission.DeleteCustomer,
                 ]);
-                await client.asUserWithCredentials(identifier, password);
+                await adminClient.asUserWithCredentials(identifier, password);
             });
 
             it('me returns correct permissions', async () => {
-                const { me } = await client.query<Me.Query>(ME);
+                const { me } = await adminClient.query<Me.Query>(ME);
 
                 expect(me!.channels[0].permissions).toEqual([
                     Permission.Authenticated,
@@ -156,7 +157,7 @@ describe('Authorization & permissions', () => {
 
     async function assertRequestAllowed<V>(operation: DocumentNode, variables?: V) {
         try {
-            const status = await client.queryStatus(operation, variables);
+            const status = await adminClient.queryStatus(operation, variables);
             expect(status).toBe(200);
         } catch (e) {
             const errorCode = getErrorCode(e);
@@ -170,7 +171,7 @@ describe('Authorization & permissions', () => {
 
     async function assertRequestForbidden<V>(operation: DocumentNode, variables: V) {
         try {
-            const status = await client.query(operation, variables);
+            const status = await adminClient.query(operation, variables);
             fail(`Should have thrown`);
         } catch (e) {
             expect(getErrorCode(e)).toBe('FORBIDDEN');
@@ -185,7 +186,7 @@ describe('Authorization & permissions', () => {
         code: string,
         permissions: Permission[],
     ): Promise<{ identifier: string; password: string }> {
-        const roleResult = await client.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
+        const roleResult = await adminClient.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
             input: {
                 code,
                 description: '',
@@ -200,18 +201,18 @@ describe('Authorization & permissions', () => {
             .substr(2, 8)}`;
         const password = `test`;
 
-        const adminResult = await client.query<CreateAdministrator.Mutation, CreateAdministrator.Variables>(
-            CREATE_ADMINISTRATOR,
-            {
-                input: {
-                    emailAddress: identifier,
-                    firstName: code,
-                    lastName: 'Admin',
-                    password,
-                    roleIds: [role.id],
-                },
+        const adminResult = await adminClient.query<
+            CreateAdministrator.Mutation,
+            CreateAdministrator.Variables
+        >(CREATE_ADMINISTRATOR, {
+            input: {
+                emailAddress: identifier,
+                firstName: code,
+                lastName: 'Admin',
+                password,
+                roleIds: [role.id],
             },
-        );
+        });
         const admin = adminResult.createAdministrator;
 
         return {

+ 106 - 98
packages/core/e2e/collection.e2e-spec.ts

@@ -1,15 +1,17 @@
 /* tslint:disable:no-non-null-assertion */
 import { ROOT_COLLECTION_NAME } from '@vendure/common/lib/shared-constants';
+import {
+    facetValueCollectionFilter,
+    variantNameCollectionFilter,
+} from '@vendure/core/dist/config/collection/default-collection-filters';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
 import { pick } from '../../common/lib/pick';
-import {
-    facetValueCollectionFilter,
-    variantNameCollectionFilter,
-} from '../src/config/collection/default-collection-filters';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { COLLECTION_FRAGMENT, FACET_VALUE_FRAGMENT } from './graphql/fragments';
 import {
     Collection,
@@ -45,14 +47,12 @@ import {
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 import { awaitRunningJobs } from './utils/await-running-jobs';
 
 describe('Collection resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
+
     let assets: GetAssetList.Items[];
     let facetValues: FacetValueFragment[];
     let electronicsCollection: Collection.Fragment;
@@ -60,20 +60,25 @@ describe('Collection resolver', () => {
     let pearCollection: Collection.Fragment;
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-collections.csv'),
             customerCount: 1,
         });
-        await client.init();
-        const assetsResult = await client.query<GetAssetList.Query, GetAssetList.Variables>(GET_ASSET_LIST, {
-            options: {
-                sort: {
-                    name: SortOrder.ASC,
+        await adminClient.asSuperAdmin();
+        const assetsResult = await adminClient.query<GetAssetList.Query, GetAssetList.Variables>(
+            GET_ASSET_LIST,
+            {
+                options: {
+                    sort: {
+                        name: SortOrder.ASC,
+                    },
                 },
             },
-        });
+        );
         assets = assetsResult.assets.items;
-        const facetValuesResult = await client.query<GetFacetValues.Query>(GET_FACET_VALUES);
+        const facetValuesResult = await adminClient.query<GetFacetValues.Query>(GET_FACET_VALUES);
         facetValues = facetValuesResult.facets.items.reduce(
             (values, facet) => [...values, ...facet.values],
             [] as FacetValueFragment[],
@@ -88,7 +93,7 @@ describe('Collection resolver', () => {
      * Test case for https://github.com/vendure-ecommerce/vendure/issues/97
      */
     it('collection breadcrumbs works after bootstrap', async () => {
-        const result = await client.query<GetCollectionBreadcrumbs.Query>(GET_COLLECTION_BREADCRUMBS, {
+        const result = await adminClient.query<GetCollectionBreadcrumbs.Query>(GET_COLLECTION_BREADCRUMBS, {
             id: 'T_1',
         });
         expect(result.collection!.breadcrumbs[0].name).toBe(ROOT_COLLECTION_NAME);
@@ -96,7 +101,7 @@ describe('Collection resolver', () => {
 
     describe('createCollection', () => {
         it('creates a root collection', async () => {
-            const result = await client.query<CreateCollection.Mutation, CreateCollection.Variables>(
+            const result = await adminClient.query<CreateCollection.Mutation, CreateCollection.Variables>(
                 CREATE_COLLECTION,
                 {
                     input: {
@@ -132,7 +137,7 @@ describe('Collection resolver', () => {
         });
 
         it('creates a nested collection', async () => {
-            const result = await client.query<CreateCollection.Mutation, CreateCollection.Variables>(
+            const result = await adminClient.query<CreateCollection.Mutation, CreateCollection.Variables>(
                 CREATE_COLLECTION,
                 {
                     input: {
@@ -163,7 +168,7 @@ describe('Collection resolver', () => {
         });
 
         it('creates a 2nd level nested collection', async () => {
-            const result = await client.query<CreateCollection.Mutation, CreateCollection.Variables>(
+            const result = await adminClient.query<CreateCollection.Mutation, CreateCollection.Variables>(
                 CREATE_COLLECTION,
                 {
                     input: {
@@ -196,7 +201,7 @@ describe('Collection resolver', () => {
 
     describe('updateCollection', () => {
         it('updates with assets', async () => {
-            const { updateCollection } = await client.query<
+            const { updateCollection } = await adminClient.query<
                 UpdateCollection.Mutation,
                 UpdateCollection.Variables
             >(UPDATE_COLLECTION, {
@@ -212,7 +217,7 @@ describe('Collection resolver', () => {
         });
 
         it('updating existing assets', async () => {
-            const { updateCollection } = await client.query<
+            const { updateCollection } = await adminClient.query<
                 UpdateCollection.Mutation,
                 UpdateCollection.Variables
             >(UPDATE_COLLECTION, {
@@ -227,7 +232,7 @@ describe('Collection resolver', () => {
         });
 
         it('removes all assets', async () => {
-            const { updateCollection } = await client.query<
+            const { updateCollection } = await adminClient.query<
                 UpdateCollection.Mutation,
                 UpdateCollection.Variables
             >(UPDATE_COLLECTION, {
@@ -243,7 +248,7 @@ describe('Collection resolver', () => {
     });
 
     it('collection query', async () => {
-        const result = await client.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
+        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
             id: computersCollection.id,
         });
         if (!result.collection) {
@@ -254,7 +259,7 @@ describe('Collection resolver', () => {
     });
 
     it('parent field', async () => {
-        const result = await client.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
+        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
             id: computersCollection.id,
         });
         if (!result.collection) {
@@ -265,7 +270,7 @@ describe('Collection resolver', () => {
     });
 
     it('children field', async () => {
-        const result = await client.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
+        const result = await adminClient.query<GetCollection.Query, GetCollection.Variables>(GET_COLLECTION, {
             id: electronicsCollection.id,
         });
         if (!result.collection) {
@@ -277,12 +282,12 @@ describe('Collection resolver', () => {
     });
 
     it('breadcrumbs', async () => {
-        const result = await client.query<GetCollectionBreadcrumbs.Query, GetCollectionBreadcrumbs.Variables>(
-            GET_COLLECTION_BREADCRUMBS,
-            {
-                id: pearCollection.id,
-            },
-        );
+        const result = await adminClient.query<
+            GetCollectionBreadcrumbs.Query,
+            GetCollectionBreadcrumbs.Variables
+        >(GET_COLLECTION_BREADCRUMBS, {
+            id: pearCollection.id,
+        });
         if (!result.collection) {
             fail(`did not return the collection`);
             return;
@@ -296,12 +301,12 @@ describe('Collection resolver', () => {
     });
 
     it('breadcrumbs for root collection', async () => {
-        const result = await client.query<GetCollectionBreadcrumbs.Query, GetCollectionBreadcrumbs.Variables>(
-            GET_COLLECTION_BREADCRUMBS,
-            {
-                id: 'T_1',
-            },
-        );
+        const result = await adminClient.query<
+            GetCollectionBreadcrumbs.Query,
+            GetCollectionBreadcrumbs.Variables
+        >(GET_COLLECTION_BREADCRUMBS, {
+            id: 'T_1',
+        });
         if (!result.collection) {
             fail(`did not return the collection`);
             return;
@@ -310,7 +315,7 @@ describe('Collection resolver', () => {
     });
 
     it('collections.assets', async () => {
-        const { collections } = await client.query<GetCollectionsWithAssets.Query>(gql`
+        const { collections } = await adminClient.query<GetCollectionsWithAssets.Query>(gql`
             query GetCollectionsWithAssets {
                 collections {
                     items {
@@ -327,7 +332,7 @@ describe('Collection resolver', () => {
 
     describe('moveCollection', () => {
         it('moves a collection to a new parent', async () => {
-            const result = await client.query<MoveCollection.Mutation, MoveCollection.Variables>(
+            const result = await adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(
                 MOVE_COLLECTION,
                 {
                     input: {
@@ -345,10 +350,10 @@ describe('Collection resolver', () => {
         });
 
         it('re-evaluates Collection contents on move', async () => {
-            const result = await client.query<GetCollectionProducts.Query, GetCollectionProducts.Variables>(
-                GET_COLLECTION_PRODUCT_VARIANTS,
-                { id: pearCollection.id },
-            );
+            const result = await adminClient.query<
+                GetCollectionProducts.Query,
+                GetCollectionProducts.Variables
+            >(GET_COLLECTION_PRODUCT_VARIANTS, { id: pearCollection.id });
             expect(result.collection!.productVariants.items.map(i => i.name)).toEqual([
                 'Laptop 13 inch 8GB',
                 'Laptop 15 inch 8GB',
@@ -359,7 +364,7 @@ describe('Collection resolver', () => {
         });
 
         it('alters the position in the current parent 1', async () => {
-            await client.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
+            await adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
                 input: {
                     collectionId: computersCollection.id,
                     parentId: electronicsCollection.id,
@@ -372,7 +377,7 @@ describe('Collection resolver', () => {
         });
 
         it('alters the position in the current parent 2', async () => {
-            await client.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
+            await adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
                 input: {
                     collectionId: pearCollection.id,
                     parentId: electronicsCollection.id,
@@ -385,7 +390,7 @@ describe('Collection resolver', () => {
         });
 
         it('corrects an out-of-bounds negative index value', async () => {
-            await client.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
+            await adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
                 input: {
                     collectionId: pearCollection.id,
                     parentId: electronicsCollection.id,
@@ -398,7 +403,7 @@ describe('Collection resolver', () => {
         });
 
         it('corrects an out-of-bounds positive index value', async () => {
-            await client.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
+            await adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
                 input: {
                     collectionId: pearCollection.id,
                     parentId: electronicsCollection.id,
@@ -414,7 +419,7 @@ describe('Collection resolver', () => {
             'throws if attempting to move into self',
             assertThrowsWithMessage(
                 () =>
-                    client.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
+                    adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
                         input: {
                             collectionId: pearCollection.id,
                             parentId: pearCollection.id,
@@ -429,7 +434,7 @@ describe('Collection resolver', () => {
             'throws if attempting to move into a decendant of self',
             assertThrowsWithMessage(
                 () =>
-                    client.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
+                    adminClient.query<MoveCollection.Mutation, MoveCollection.Variables>(MOVE_COLLECTION, {
                         input: {
                             collectionId: pearCollection.id,
                             parentId: pearCollection.id,
@@ -441,7 +446,7 @@ describe('Collection resolver', () => {
         );
 
         async function getChildrenOf(parentId: string): Promise<Array<{ name: string; id: string }>> {
-            const result = await client.query<GetCollections.Query>(GET_COLLECTIONS);
+            const result = await adminClient.query<GetCollections.Query>(GET_COLLECTIONS);
             return result.collections.items.filter(i => i.parent!.id === parentId);
         }
     });
@@ -452,7 +457,7 @@ describe('Collection resolver', () => {
         let laptopProductId: string;
 
         beforeAll(async () => {
-            const result1 = await client.query<CreateCollection.Mutation, CreateCollection.Variables>(
+            const result1 = await adminClient.query<CreateCollection.Mutation, CreateCollection.Variables>(
                 CREATE_COLLECTION,
                 {
                     input: {
@@ -481,7 +486,7 @@ describe('Collection resolver', () => {
             );
             collectionToDeleteParent = result1.createCollection;
 
-            const result2 = await client.query<CreateCollection.Mutation, CreateCollection.Variables>(
+            const result2 = await adminClient.query<CreateCollection.Mutation, CreateCollection.Variables>(
                 CREATE_COLLECTION,
                 {
                     input: {
@@ -499,14 +504,17 @@ describe('Collection resolver', () => {
         it(
             'throws for invalid collection id',
             assertThrowsWithMessage(async () => {
-                await client.query<DeleteCollection.Mutation, DeleteCollection.Variables>(DELETE_COLLECTION, {
-                    id: 'T_999',
-                });
+                await adminClient.query<DeleteCollection.Mutation, DeleteCollection.Variables>(
+                    DELETE_COLLECTION,
+                    {
+                        id: 'T_999',
+                    },
+                );
             }, "No Collection with the id '999' could be found"),
         );
 
         it('collection and product related prior to deletion', async () => {
-            const { collection } = await client.query<
+            const { collection } = await adminClient.query<
                 GetCollectionProducts.Query,
                 GetCollectionProducts.Variables
             >(GET_COLLECTION_PRODUCT_VARIANTS, {
@@ -521,7 +529,7 @@ describe('Collection resolver', () => {
 
             laptopProductId = collection!.productVariants.items[0].productId;
 
-            const { product } = await client.query<
+            const { product } = await adminClient.query<
                 GetProductCollections.Query,
                 GetProductCollections.Variables
             >(GET_PRODUCT_COLLECTIONS, {
@@ -538,7 +546,7 @@ describe('Collection resolver', () => {
         });
 
         it('deleteCollection works', async () => {
-            const { deleteCollection } = await client.query<
+            const { deleteCollection } = await adminClient.query<
                 DeleteCollection.Mutation,
                 DeleteCollection.Variables
             >(DELETE_COLLECTION, {
@@ -549,7 +557,7 @@ describe('Collection resolver', () => {
         });
 
         it('deleted parent collection is null', async () => {
-            const { collection } = await client.query<GetCollection.Query, GetCollection.Variables>(
+            const { collection } = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
                 GET_COLLECTION,
                 {
                     id: collectionToDeleteParent.id,
@@ -559,7 +567,7 @@ describe('Collection resolver', () => {
         });
 
         it('deleted child collection is null', async () => {
-            const { collection } = await client.query<GetCollection.Query, GetCollection.Variables>(
+            const { collection } = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
                 GET_COLLECTION,
                 {
                     id: collectionToDeleteChild.id,
@@ -569,7 +577,7 @@ describe('Collection resolver', () => {
         });
 
         it('product no longer lists collection', async () => {
-            const { product } = await client.query<
+            const { product } = await adminClient.query<
                 GetProductCollections.Query,
                 GetProductCollections.Variables
             >(GET_PRODUCT_COLLECTIONS, {
@@ -586,7 +594,7 @@ describe('Collection resolver', () => {
 
     describe('filters', () => {
         it('Collection with no filters has no productVariants', async () => {
-            const result = await client.query<
+            const result = await adminClient.query<
                 CreateCollectionSelectVariants.Mutation,
                 CreateCollectionSelectVariants.Variables
             >(CREATE_COLLECTION_SELECT_VARIANTS, {
@@ -600,7 +608,7 @@ describe('Collection resolver', () => {
 
         describe('facetValue filter', () => {
             it('electronics', async () => {
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, {
@@ -632,7 +640,7 @@ describe('Collection resolver', () => {
             });
 
             it('computers', async () => {
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, {
@@ -660,7 +668,7 @@ describe('Collection resolver', () => {
             });
 
             it('photo AND pear', async () => {
-                const result = await client.query<
+                const result = await adminClient.query<
                     CreateCollectionSelectVariants.Mutation,
                     CreateCollectionSelectVariants.Variables
                 >(CREATE_COLLECTION_SELECT_VARIANTS, {
@@ -690,8 +698,8 @@ describe('Collection resolver', () => {
                     } as CreateCollectionInput,
                 });
 
-                await awaitRunningJobs(client);
-                const { collection } = await client.query<GetCollection.Query, GetCollection.Variables>(
+                await awaitRunningJobs(adminClient);
+                const { collection } = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
                     GET_COLLECTION,
                     {
                         id: result.createCollection.id,
@@ -702,7 +710,7 @@ describe('Collection resolver', () => {
             });
 
             it('photo OR pear', async () => {
-                const result = await client.query<
+                const result = await adminClient.query<
                     CreateCollectionSelectVariants.Mutation,
                     CreateCollectionSelectVariants.Variables
                 >(CREATE_COLLECTION_SELECT_VARIANTS, {
@@ -732,8 +740,8 @@ describe('Collection resolver', () => {
                     } as CreateCollectionInput,
                 });
 
-                await awaitRunningJobs(client);
-                const { collection } = await client.query<GetCollection.Query, GetCollection.Variables>(
+                await awaitRunningJobs(adminClient);
+                const { collection } = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
                     GET_COLLECTION,
                     {
                         id: result.createCollection.id,
@@ -754,7 +762,7 @@ describe('Collection resolver', () => {
             });
 
             it('bell OR pear in computers', async () => {
-                const result = await client.query<
+                const result = await adminClient.query<
                     CreateCollectionSelectVariants.Mutation,
                     CreateCollectionSelectVariants.Variables
                 >(CREATE_COLLECTION_SELECT_VARIANTS, {
@@ -787,8 +795,8 @@ describe('Collection resolver', () => {
                     } as CreateCollectionInput,
                 });
 
-                await awaitRunningJobs(client);
-                const { collection } = await client.query<GetCollection.Query, GetCollection.Variables>(
+                await awaitRunningJobs(adminClient);
+                const { collection } = await adminClient.query<GetCollection.Query, GetCollection.Variables>(
                     GET_COLLECTION,
                     {
                         id: result.createCollection.id,
@@ -811,7 +819,7 @@ describe('Collection resolver', () => {
                 operator: string,
                 term: string,
             ): Promise<Collection.Fragment> {
-                const { createCollection } = await client.query<
+                const { createCollection } = await adminClient.query<
                     CreateCollection.Mutation,
                     CreateCollection.Variables
                 >(CREATE_COLLECTION, {
@@ -844,7 +852,7 @@ describe('Collection resolver', () => {
             it('contains operator', async () => {
                 const collection = await createVariantNameFilteredCollection('contains', 'camera');
 
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, {
@@ -860,7 +868,7 @@ describe('Collection resolver', () => {
             it('startsWith operator', async () => {
                 const collection = await createVariantNameFilteredCollection('startsWith', 'camera');
 
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, {
@@ -872,7 +880,7 @@ describe('Collection resolver', () => {
             it('endsWith operator', async () => {
                 const collection = await createVariantNameFilteredCollection('endsWith', 'camera');
 
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, {
@@ -887,7 +895,7 @@ describe('Collection resolver', () => {
             it('doesNotContain operator', async () => {
                 const collection = await createVariantNameFilteredCollection('doesNotContain', 'camera');
 
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, {
@@ -921,7 +929,7 @@ describe('Collection resolver', () => {
             let products: GetProductsWithVariantIds.Items[];
 
             beforeAll(async () => {
-                const result = await client.query<GetProductsWithVariantIds.Query>(gql`
+                const result = await adminClient.query<GetProductsWithVariantIds.Query>(gql`
                     query GetProductsWithVariantIds {
                         products {
                             items {
@@ -938,7 +946,7 @@ describe('Collection resolver', () => {
             });
 
             it('updates contents when Product is updated', async () => {
-                await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+                await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
                     input: {
                         id: products[1].id,
                         facetValueIds: [
@@ -949,9 +957,9 @@ describe('Collection resolver', () => {
                     },
                 });
 
-                await awaitRunningJobs(client);
+                await awaitRunningJobs(adminClient);
 
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, { id: pearCollection.id });
@@ -968,7 +976,7 @@ describe('Collection resolver', () => {
 
             it('updates contents when ProductVariant is updated', async () => {
                 const gamingPcFirstVariant = products.find(p => p.name === 'Gaming PC')!.variants[0];
-                await client.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
                     UPDATE_PRODUCT_VARIANTS,
                     {
                         input: [
@@ -980,9 +988,9 @@ describe('Collection resolver', () => {
                     },
                 );
 
-                await awaitRunningJobs(client);
+                await awaitRunningJobs(adminClient);
 
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, { id: pearCollection.id });
@@ -1000,7 +1008,7 @@ describe('Collection resolver', () => {
 
             it('correctly filters when ProductVariant and Product both have matching FacetValue', async () => {
                 const gamingPcFirstVariant = products.find(p => p.name === 'Gaming PC')!.variants[0];
-                await client.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+                await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
                     UPDATE_PRODUCT_VARIANTS,
                     {
                         input: [
@@ -1011,7 +1019,7 @@ describe('Collection resolver', () => {
                         ],
                     },
                 );
-                const result = await client.query<
+                const result = await adminClient.query<
                     GetCollectionProducts.Query,
                     GetCollectionProducts.Variables
                 >(GET_COLLECTION_PRODUCT_VARIANTS, { id: pearCollection.id });
@@ -1029,7 +1037,7 @@ describe('Collection resolver', () => {
         });
 
         it('filter inheritance of nested collections (issue #158)', async () => {
-            const { createCollection: pearElectronics } = await client.query<
+            const { createCollection: pearElectronics } = await adminClient.query<
                 CreateCollectionSelectVariants.Mutation,
                 CreateCollectionSelectVariants.Variables
             >(CREATE_COLLECTION_SELECT_VARIANTS, {
@@ -1058,12 +1066,12 @@ describe('Collection resolver', () => {
                 } as CreateCollectionInput,
             });
 
-            await awaitRunningJobs(client);
+            await awaitRunningJobs(adminClient);
 
-            const result = await client.query<GetCollectionProducts.Query, GetCollectionProducts.Variables>(
-                GET_COLLECTION_PRODUCT_VARIANTS,
-                { id: pearElectronics.id },
-            );
+            const result = await adminClient.query<
+                GetCollectionProducts.Query,
+                GetCollectionProducts.Variables
+            >(GET_COLLECTION_PRODUCT_VARIANTS, { id: pearElectronics.id });
             expect(result.collection!.productVariants.items.map(i => i.name)).toEqual([
                 'Laptop 13 inch 8GB',
                 'Laptop 15 inch 8GB',
@@ -1080,7 +1088,7 @@ describe('Collection resolver', () => {
 
     describe('Product collections property', () => {
         it('returns all collections to which the Product belongs', async () => {
-            const result = await client.query<
+            const result = await adminClient.query<
                 GetCollectionsForProducts.Query,
                 GetCollectionsForProducts.Variables
             >(GET_COLLECTIONS_FOR_PRODUCTS, { term: 'camera' });
@@ -1097,10 +1105,10 @@ describe('Collection resolver', () => {
     });
 
     it('collection does not list deleted products', async () => {
-        await client.query<DeleteProduct.Mutation, DeleteProduct.Variables>(DELETE_PRODUCT, {
+        await adminClient.query<DeleteProduct.Mutation, DeleteProduct.Variables>(DELETE_PRODUCT, {
             id: 'T_2', // curvy monitor
         });
-        const { collection } = await client.query<
+        const { collection } = await adminClient.query<
             GetCollectionProducts.Query,
             GetCollectionProducts.Variables
         >(GET_COLLECTION_PRODUCT_VARIANTS, {

+ 6 - 51
packages/core/e2e/config/test-config.ts

@@ -1,15 +1,7 @@
-import { Transport } from '@nestjs/microservices';
-import { ADMIN_API_PATH, SHOP_API_PATH } from '@vendure/common/lib/shared-constants';
+import { mergeConfig } from '@vendure/core';
+import { testConfig as defaultTestConfig } from '@vendure/testing';
 import path from 'path';
 
-import { DefaultAssetNamingStrategy } from '../../src/config/asset-naming-strategy/default-asset-naming-strategy';
-import { NoopLogger } from '../../src/config/logger/noop-logger';
-import { VendureConfig } from '../../src/config/vendure-config';
-
-import { TestingAssetPreviewStrategy } from './testing-asset-preview-strategy';
-import { TestingAssetStorageStrategy } from './testing-asset-storage-strategy';
-import { TestingEntityIdStrategy } from './testing-entity-id-strategy';
-
 /**
  * We use a relatively long timeout on the initial beforeAll() function of the
  * e2e tests because on the first run (and always in CI) the sqlite databases
@@ -27,47 +19,10 @@ if (process.env.E2E_DEBUG) {
     jest.setTimeout(1800 * 1000);
 }
 
-/**
- * Config settings used for e2e tests
- */
-export const testConfig: VendureConfig = {
-    port: 3050,
-    adminApiPath: ADMIN_API_PATH,
-    shopApiPath: SHOP_API_PATH,
-    cors: true,
-    defaultChannelToken: 'e2e-default-channel',
-    authOptions: {
-        sessionSecret: 'some-secret',
-        tokenMethod: 'bearer',
-        requireVerification: true,
-    },
-    dbConnectionOptions: {
-        type: 'sqljs',
-        database: new Uint8Array([]),
-        location: '',
-        autoSave: false,
-        logging: false,
-    },
-    promotionOptions: {},
-    customFields: {},
-    entityIdStrategy: new TestingEntityIdStrategy(),
-    paymentOptions: {
-        paymentMethodHandlers: [],
-    },
-    logger: new NoopLogger(),
+export const dataDir = path.join(__dirname, '../__data__');
+
+export const testConfig = mergeConfig(defaultTestConfig, {
     importExportOptions: {
         importAssetsDir: path.join(__dirname, '..', 'fixtures/assets'),
     },
-    assetOptions: {
-        assetNamingStrategy: new DefaultAssetNamingStrategy(),
-        assetStorageStrategy: new TestingAssetStorageStrategy(),
-        assetPreviewStrategy: new TestingAssetPreviewStrategy(),
-    },
-    workerOptions: {
-        runInMainProcess: true,
-        transport: Transport.TCP,
-        options: {
-            port: 3051,
-        },
-    },
-};
+});

+ 36 - 23
packages/core/e2e/country.e2e-spec.ts

@@ -1,7 +1,9 @@
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { COUNTRY_FRAGMENT } from './graphql/fragments';
 import {
     CreateCountry,
@@ -13,24 +15,23 @@ import {
     UpdateCountry,
 } from './graphql/generated-e2e-admin-types';
 import { GET_COUNTRY_LIST, UPDATE_COUNTRY } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Facet resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
     let countries: GetCountryList.Items[];
     let GB: GetCountryList.Items;
     let AT: GetCountryList.Items;
 
     beforeAll(async () => {
         await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
             customerCount: 1,
         });
-        await client.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -38,7 +39,7 @@ describe('Facet resolver', () => {
     });
 
     it('countries', async () => {
-        const result = await client.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+        const result = await adminClient.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
 
         expect(result.countries.totalItems).toBe(7);
         countries = result.countries.items;
@@ -47,7 +48,7 @@ describe('Facet resolver', () => {
     });
 
     it('country', async () => {
-        const result = await client.query<GetCountry.Query, GetCountry.Variables>(GET_COUNTRY, {
+        const result = await adminClient.query<GetCountry.Query, GetCountry.Variables>(GET_COUNTRY, {
             id: GB.id,
         });
 
@@ -55,50 +56,62 @@ describe('Facet resolver', () => {
     });
 
     it('updateCountry', async () => {
-        const result = await client.query<UpdateCountry.Mutation, UpdateCountry.Variables>(UPDATE_COUNTRY, {
-            input: {
-                id: AT.id,
-                enabled: false,
+        const result = await adminClient.query<UpdateCountry.Mutation, UpdateCountry.Variables>(
+            UPDATE_COUNTRY,
+            {
+                input: {
+                    id: AT.id,
+                    enabled: false,
+                },
             },
-        });
+        );
 
         expect(result.updateCountry.enabled).toBe(false);
     });
 
     it('createCountry', async () => {
-        const result = await client.query<CreateCountry.Mutation, CreateCountry.Variables>(CREATE_COUNTRY, {
-            input: {
-                code: 'GL',
-                enabled: true,
-                translations: [{ languageCode: LanguageCode.en, name: 'Gondwanaland' }],
+        const result = await adminClient.query<CreateCountry.Mutation, CreateCountry.Variables>(
+            CREATE_COUNTRY,
+            {
+                input: {
+                    code: 'GL',
+                    enabled: true,
+                    translations: [{ languageCode: LanguageCode.en, name: 'Gondwanaland' }],
+                },
             },
-        });
+        );
 
         expect(result.createCountry.name).toBe('Gondwanaland');
     });
 
     describe('deletion', () => {
         it('deletes Country not used in any address', async () => {
-            const result1 = await client.query<DeleteCountry.Mutation, DeleteCountry.Variables>(DELETE_COUNTRY, { id: AT.id });
+            const result1 = await adminClient.query<DeleteCountry.Mutation, DeleteCountry.Variables>(
+                DELETE_COUNTRY,
+                { id: AT.id },
+            );
 
             expect(result1.deleteCountry).toEqual({
                 result: DeletionResult.DELETED,
                 message: '',
             });
 
-            const result2 = await client.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+            const result2 = await adminClient.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
             expect(result2.countries.items.find(c => c.id === AT.id)).toBeUndefined();
         });
 
         it('does not delete Country that is used in one or more addresses', async () => {
-            const result1 = await client.query<DeleteCountry.Mutation, DeleteCountry.Variables>(DELETE_COUNTRY, { id: GB.id });
+            const result1 = await adminClient.query<DeleteCountry.Mutation, DeleteCountry.Variables>(
+                DELETE_COUNTRY,
+                { id: GB.id },
+            );
 
             expect(result1.deleteCountry).toEqual({
                 result: DeletionResult.NOT_DELETED,
                 message: 'The selected Country cannot be deleted as it is used in 1 Address',
             });
 
-            const result2 = await client.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+            const result2 = await adminClient.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
             expect(result2.countries.items.find(c => c.id === GB.id)).not.toBeUndefined();
         });
     });

+ 95 - 94
packages/core/e2e/custom-fields.e2e-spec.ts

@@ -1,110 +1,111 @@
 // Force the timezone to avoid tests failing in other locales
 process.env.TZ = 'UTC';
 
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { CustomFields } from '@vendure/core/dist/config/custom-field/custom-field-types';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { LanguageCode } from '../../common/lib/generated-types';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 // tslint:disable:no-non-null-assertion
 
+const customConfig = {
+    ...testConfig,
+    ...{
+        customFields: {
+            Product: [
+                { name: 'nullable', type: 'string' },
+                { name: 'notNullable', type: 'string', nullable: false, defaultValue: '' },
+                { name: 'stringWithDefault', type: 'string', defaultValue: 'hello' },
+                { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
+                { name: 'intWithDefault', type: 'int', defaultValue: 5 },
+                { name: 'floatWithDefault', type: 'float', defaultValue: 5.5 },
+                { name: 'booleanWithDefault', type: 'boolean', defaultValue: true },
+                {
+                    name: 'dateTimeWithDefault',
+                    type: 'datetime',
+                    defaultValue: new Date('2019-04-30T12:59:16.4158386Z'),
+                },
+                { name: 'validateString', type: 'string', pattern: '^[0-9][a-z]+$' },
+                { name: 'validateLocaleString', type: 'localeString', pattern: '^[0-9][a-z]+$' },
+                { name: 'validateInt', type: 'int', min: 0, max: 10 },
+                { name: 'validateFloat', type: 'float', min: 0.5, max: 10.5 },
+                {
+                    name: 'validateDateTime',
+                    type: 'datetime',
+                    min: '2019-01-01T08:30',
+                    max: '2019-06-01T08:30',
+                },
+                {
+                    name: 'validateFn1',
+                    type: 'string',
+                    validate: value => {
+                        if (value !== 'valid') {
+                            return `The value ['${value}'] is not valid`;
+                        }
+                    },
+                },
+                {
+                    name: 'validateFn2',
+                    type: 'string',
+                    validate: value => {
+                        if (value !== 'valid') {
+                            return [
+                                {
+                                    languageCode: LanguageCode.en,
+                                    value: `The value ['${value}'] is not valid`,
+                                },
+                            ];
+                        }
+                    },
+                },
+                {
+                    name: 'stringWithOptions',
+                    type: 'string',
+                    options: [{ value: 'small' }, { value: 'medium' }, { value: 'large' }],
+                },
+                {
+                    name: 'nonPublic',
+                    type: 'string',
+                    defaultValue: 'hi!',
+                    public: false,
+                },
+                {
+                    name: 'public',
+                    type: 'string',
+                    defaultValue: 'ho!',
+                    public: true,
+                },
+                {
+                    name: 'longString',
+                    type: 'string',
+                },
+            ],
+            Facet: [
+                {
+                    name: 'translated',
+                    type: 'localeString',
+                },
+            ],
+        } as CustomFields,
+    },
+};
+
 describe('Custom fields', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(customConfig);
 
     beforeAll(async () => {
-        await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
-            },
-            {
-                customFields: {
-                    Product: [
-                        { name: 'nullable', type: 'string' },
-                        { name: 'notNullable', type: 'string', nullable: false, defaultValue: '' },
-                        { name: 'stringWithDefault', type: 'string', defaultValue: 'hello' },
-                        { name: 'localeStringWithDefault', type: 'localeString', defaultValue: 'hola' },
-                        { name: 'intWithDefault', type: 'int', defaultValue: 5 },
-                        { name: 'floatWithDefault', type: 'float', defaultValue: 5.5 },
-                        { name: 'booleanWithDefault', type: 'boolean', defaultValue: true },
-                        {
-                            name: 'dateTimeWithDefault',
-                            type: 'datetime',
-                            defaultValue: new Date('2019-04-30T12:59:16.4158386Z'),
-                        },
-                        { name: 'validateString', type: 'string', pattern: '^[0-9][a-z]+$' },
-                        { name: 'validateLocaleString', type: 'localeString', pattern: '^[0-9][a-z]+$' },
-                        { name: 'validateInt', type: 'int', min: 0, max: 10 },
-                        { name: 'validateFloat', type: 'float', min: 0.5, max: 10.5 },
-                        {
-                            name: 'validateDateTime',
-                            type: 'datetime',
-                            min: '2019-01-01T08:30',
-                            max: '2019-06-01T08:30',
-                        },
-                        {
-                            name: 'validateFn1',
-                            type: 'string',
-                            validate: value => {
-                                if (value !== 'valid') {
-                                    return `The value ['${value}'] is not valid`;
-                                }
-                            },
-                        },
-                        {
-                            name: 'validateFn2',
-                            type: 'string',
-                            validate: value => {
-                                if (value !== 'valid') {
-                                    return [
-                                        {
-                                            languageCode: LanguageCode.en,
-                                            value: `The value ['${value}'] is not valid`,
-                                        },
-                                    ];
-                                }
-                            },
-                        },
-                        {
-                            name: 'stringWithOptions',
-                            type: 'string',
-                            options: [{ value: 'small' }, { value: 'medium' }, { value: 'large' }],
-                        },
-                        {
-                            name: 'nonPublic',
-                            type: 'string',
-                            defaultValue: 'hi!',
-                            public: false,
-                        },
-                        {
-                            name: 'public',
-                            type: 'string',
-                            defaultValue: 'ho!',
-                            public: true,
-                        },
-                        {
-                            name: 'longString',
-                            type: 'string',
-                        },
-                    ],
-                    Facet: [
-                        {
-                            name: 'translated',
-                            type: 'localeString',
-                        },
-                    ],
-                },
-            },
-        );
-        await adminClient.init();
-        await shopClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {

+ 37 - 37
packages/core/e2e/customer.e2e-spec.ts

@@ -1,14 +1,18 @@
 import { OnModuleInit } from '@nestjs/common';
 import { omit } from '@vendure/common/lib/omit';
+import {
+    AccountRegistrationEvent,
+    EventBus,
+    EventBusModule,
+    mergeConfig,
+    VendurePlugin,
+} from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { EventBus } from '../src/event-bus/event-bus';
-import { EventBusModule } from '../src/event-bus/event-bus.module';
-import { AccountRegistrationEvent } from '../src/event-bus/events/account-registration-event';
-import { VendurePlugin } from '../src/plugin/vendure-plugin';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { CUSTOMER_FRAGMENT } from './graphql/fragments';
 import {
     CreateAddress,
@@ -25,32 +29,44 @@ import {
 import { AddItemToOrder } from './graphql/generated-e2e-shop-types';
 import { GET_CUSTOMER, GET_CUSTOMER_LIST } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER } from './graphql/shop-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 // tslint:disable:no-non-null-assertion
 let sendEmailFn: jest.Mock;
 
+/**
+ * This mock plugin simulates an EmailPlugin which would send emails
+ * on the registration & password reset events.
+ */
+@VendurePlugin({
+    imports: [EventBusModule],
+})
+class TestEmailPlugin implements OnModuleInit {
+    constructor(private eventBus: EventBus) {}
+    onModuleInit() {
+        this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
+            sendEmailFn(event);
+        });
+    }
+}
+
 describe('Customer resolver', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, { plugins: [TestEmailPlugin] }),
+    );
+
     let firstCustomer: GetCustomerList.Items;
     let secondCustomer: GetCustomerList.Items;
     let thirdCustomer: GetCustomerList.Items;
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 5,
-            },
-            {
-                plugins: [TestEmailPlugin],
-            },
-        );
-        await adminClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 5,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -486,19 +502,3 @@ const DELETE_CUSTOMER = gql`
         }
     }
 `;
-
-/**
- * This mock plugin simulates an EmailPlugin which would send emails
- * on the registration & password reset events.
- */
-@VendurePlugin({
-    imports: [EventBusModule],
-})
-class TestEmailPlugin implements OnModuleInit {
-    constructor(private eventBus: EventBus) {}
-    onModuleInit() {
-        this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
-            sendEmailFn(event);
-        });
-    }
-}

+ 16 - 21
packages/core/e2e/default-search-plugin.e2e-spec.ts

@@ -1,12 +1,13 @@
 import { pick } from '@vendure/common/lib/pick';
+import { mergeConfig } from '@vendure/core';
+import { DefaultSearchPlugin } from '@vendure/core';
+import { facetValueCollectionFilter } from '@vendure/core/dist/config/collection/default-collection-filters';
+import { createTestEnvironment, SimpleGraphQLClient } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
-import { facetValueCollectionFilter } from '../src/config/collection/default-collection-filters';
-import { DefaultSearchPlugin } from '../src/plugin/default-search-plugin/default-search-plugin';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     CreateCollection,
     CreateFacet,
@@ -29,27 +30,21 @@ import {
     UPDATE_TAX_RATE,
 } from './graphql/shared-definitions';
 import { SEARCH_PRODUCTS_SHOP } from './graphql/shop-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { awaitRunningJobs } from './utils/await-running-jobs';
 
 describe('Default search plugin', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, { plugins: [DefaultSearchPlugin] }),
+    );
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-                customerCount: 1,
-            },
-            {
-                plugins: [DefaultSearchPlugin],
-            },
-        );
-        await adminClient.init();
-        await shopClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {

+ 7 - 8
packages/core/e2e/entity-id-strategy.e2e-spec.ts

@@ -1,8 +1,10 @@
 /* tslint:disable:no-non-null-assertion */
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     IdTest1,
     IdTest2,
@@ -14,21 +16,18 @@ import {
     IdTest8,
     LanguageCode,
 } from './graphql/generated-e2e-admin-types';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 
 describe('EntityIdStrategy', () => {
-    const shopClient = new TestShopClient();
-    const adminClient = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
 
     beforeAll(async () => {
         await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
             customerCount: 1,
         });
-        await shopClient.init();
-        await adminClient.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {

+ 15 - 16
packages/core/e2e/entity-uuid-strategy.e2e-spec.ts

@@ -1,7 +1,8 @@
 /* tslint:disable:no-non-null-assertion */
+import { UuidIdStrategy } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
 import path from 'path';
 
-import { UuidIdStrategy } from '../src/config/entity-id-strategy/uuid-id-strategy';
 // This import is here to simulate the behaviour of
 // the package end-user importing symbols from the
 // @vendure/core barrel file. Doing so will then cause the
@@ -10,27 +11,25 @@ import { UuidIdStrategy } from '../src/config/entity-id-strategy/uuid-id-strateg
 // order of file evaluation.
 import '../src/index';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { GetProductList } from './graphql/generated-e2e-admin-types';
 import { GET_PRODUCT_LIST } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 
 describe('UuidIdStrategy', () => {
-    const adminClient = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment({
+        ...testConfig,
+        entityIdStrategy: new UuidIdStrategy(),
+    });
 
     beforeAll(async () => {
-        await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-                customerCount: 1,
-            },
-            {
-                entityIdStrategy: new UuidIdStrategy(),
-            },
-        );
-        await adminClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {

+ 50 - 39
packages/core/e2e/facet.e2e-spec.ts

@@ -1,7 +1,9 @@
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { FACET_VALUE_FRAGMENT, FACET_WITH_VALUES_FRAGMENT } from './graphql/fragments';
 import {
     CreateFacet,
@@ -28,23 +30,23 @@ import {
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Facet resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
+
     let brandFacet: FacetWithValues.Fragment;
     let speakerTypeFacet: FacetWithValues.Fragment;
 
     beforeAll(async () => {
         await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
             customerCount: 1,
         });
-        await client.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -52,7 +54,7 @@ describe('Facet resolver', () => {
     });
 
     it('createFacet', async () => {
-        const result = await client.query<CreateFacet.Mutation, CreateFacet.Variables>(CREATE_FACET, {
+        const result = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(CREATE_FACET, {
             input: {
                 isPrivate: false,
                 code: 'speaker-type',
@@ -71,7 +73,7 @@ describe('Facet resolver', () => {
     });
 
     it('updateFacet', async () => {
-        const result = await client.query<UpdateFacet.Mutation, UpdateFacet.Variables>(UPDATE_FACET, {
+        const result = await adminClient.query<UpdateFacet.Mutation, UpdateFacet.Variables>(UPDATE_FACET, {
             input: {
                 id: speakerTypeFacet.id,
                 translations: [{ languageCode: LanguageCode.en, name: 'Speaker Category' }],
@@ -82,7 +84,7 @@ describe('Facet resolver', () => {
     });
 
     it('createFacetValues', async () => {
-        const result = await client.query<CreateFacetValues.Mutation, CreateFacetValues.Variables>(
+        const result = await adminClient.query<CreateFacetValues.Mutation, CreateFacetValues.Variables>(
             CREATE_FACET_VALUES,
             {
                 input: [
@@ -104,7 +106,7 @@ describe('Facet resolver', () => {
     });
 
     it('updateFacetValues', async () => {
-        const result = await client.query<UpdateFacetValues.Mutation, UpdateFacetValues.Variables>(
+        const result = await adminClient.query<UpdateFacetValues.Mutation, UpdateFacetValues.Variables>(
             UPDATE_FACET_VALUES,
             {
                 input: [
@@ -120,7 +122,7 @@ describe('Facet resolver', () => {
     });
 
     it('facets', async () => {
-        const result = await client.query<GetFacetList.Query>(GET_FACET_LIST);
+        const result = await adminClient.query<GetFacetList.Query>(GET_FACET_LIST);
 
         const { items } = result.facets;
         expect(items.length).toBe(2);
@@ -132,7 +134,7 @@ describe('Facet resolver', () => {
     });
 
     it('facet', async () => {
-        const result = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+        const result = await adminClient.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
             GET_FACET_WITH_VALUES,
             {
                 id: speakerTypeFacet.id,
@@ -147,19 +149,19 @@ describe('Facet resolver', () => {
 
         beforeAll(async () => {
             // add the FacetValues to products and variants
-            const result1 = await client.query<GetProductListWithVariants.Query>(
+            const result1 = await adminClient.query<GetProductListWithVariants.Query>(
                 GET_PRODUCTS_LIST_WITH_VARIANTS,
             );
             products = result1.products.items;
 
-            await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+            await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
                 input: {
                     id: products[0].id,
                     facetValueIds: [speakerTypeFacet.values[0].id],
                 },
             });
 
-            await client.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
+            await adminClient.query<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
                 UPDATE_PRODUCT_VARIANTS,
                 {
                     input: [
@@ -171,7 +173,7 @@ describe('Facet resolver', () => {
                 },
             );
 
-            await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
+            await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(UPDATE_PRODUCT, {
                 input: {
                     id: products[1].id,
                     facetValueIds: [speakerTypeFacet.values[1].id],
@@ -181,14 +183,14 @@ describe('Facet resolver', () => {
 
         it('deleteFacetValues deletes unused facetValue', async () => {
             const facetValueToDelete = speakerTypeFacet.values[2];
-            const result1 = await client.query<DeleteFacetValues.Mutation, DeleteFacetValues.Variables>(
+            const result1 = await adminClient.query<DeleteFacetValues.Mutation, DeleteFacetValues.Variables>(
                 DELETE_FACET_VALUES,
                 {
                     ids: [facetValueToDelete.id],
                     force: false,
                 },
             );
-            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+            const result2 = await adminClient.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
                 GET_FACET_WITH_VALUES,
                 {
                     id: speakerTypeFacet.id,
@@ -207,14 +209,14 @@ describe('Facet resolver', () => {
 
         it('deleteFacetValues for FacetValue in use returns NOT_DELETED', async () => {
             const facetValueToDelete = speakerTypeFacet.values[0];
-            const result1 = await client.query<DeleteFacetValues.Mutation, DeleteFacetValues.Variables>(
+            const result1 = await adminClient.query<DeleteFacetValues.Mutation, DeleteFacetValues.Variables>(
                 DELETE_FACET_VALUES,
                 {
                     ids: [facetValueToDelete.id],
                     force: false,
                 },
             );
-            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+            const result2 = await adminClient.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
                 GET_FACET_WITH_VALUES,
                 {
                     id: speakerTypeFacet.id,
@@ -233,7 +235,7 @@ describe('Facet resolver', () => {
 
         it('deleteFacetValues for FacetValue in use can be force deleted', async () => {
             const facetValueToDelete = speakerTypeFacet.values[0];
-            const result1 = await client.query<DeleteFacetValues.Mutation, DeleteFacetValues.Variables>(
+            const result1 = await adminClient.query<DeleteFacetValues.Mutation, DeleteFacetValues.Variables>(
                 DELETE_FACET_VALUES,
                 {
                     ids: [facetValueToDelete.id],
@@ -249,7 +251,7 @@ describe('Facet resolver', () => {
             ]);
 
             // FacetValue no longer in the Facet.values array
-            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+            const result2 = await adminClient.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
                 GET_FACET_WITH_VALUES,
                 {
                     id: speakerTypeFacet.id,
@@ -258,7 +260,7 @@ describe('Facet resolver', () => {
             expect(result2.facet!.values[0]).not.toEqual(facetValueToDelete);
 
             // FacetValue no longer in the Product.facetValues array
-            const result3 = await client.query<
+            const result3 = await adminClient.query<
                 GetProductWithVariants.Query,
                 GetProductWithVariants.Variables
             >(GET_PRODUCT_WITH_VARIANTS, {
@@ -268,11 +270,14 @@ describe('Facet resolver', () => {
         });
 
         it('deleteFacet that is in use returns NOT_DELETED', async () => {
-            const result1 = await client.query<DeleteFacet.Mutation, DeleteFacet.Variables>(DELETE_FACET, {
-                id: speakerTypeFacet.id,
-                force: false,
-            });
-            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+            const result1 = await adminClient.query<DeleteFacet.Mutation, DeleteFacet.Variables>(
+                DELETE_FACET,
+                {
+                    id: speakerTypeFacet.id,
+                    force: false,
+                },
+            );
+            const result2 = await adminClient.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
                 GET_FACET_WITH_VALUES,
                 {
                     id: speakerTypeFacet.id,
@@ -288,10 +293,13 @@ describe('Facet resolver', () => {
         });
 
         it('deleteFacet that is in use can be force deleted', async () => {
-            const result1 = await client.query<DeleteFacet.Mutation, DeleteFacet.Variables>(DELETE_FACET, {
-                id: speakerTypeFacet.id,
-                force: true,
-            });
+            const result1 = await adminClient.query<DeleteFacet.Mutation, DeleteFacet.Variables>(
+                DELETE_FACET,
+                {
+                    id: speakerTypeFacet.id,
+                    force: true,
+                },
+            );
 
             expect(result1.deleteFacet).toEqual({
                 result: DeletionResult.DELETED,
@@ -299,7 +307,7 @@ describe('Facet resolver', () => {
             });
 
             // FacetValue no longer in the Facet.values array
-            const result2 = await client.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
+            const result2 = await adminClient.query<GetFacetWithValues.Query, GetFacetWithValues.Variables>(
                 GET_FACET_WITH_VALUES,
                 {
                     id: speakerTypeFacet.id,
@@ -308,7 +316,7 @@ describe('Facet resolver', () => {
             expect(result2.facet).toBe(null);
 
             // FacetValue no longer in the Product.facetValues array
-            const result3 = await client.query<
+            const result3 = await adminClient.query<
                 GetProductWithVariants.Query,
                 GetProductWithVariants.Variables
             >(GET_PRODUCT_WITH_VARIANTS, {
@@ -318,7 +326,7 @@ describe('Facet resolver', () => {
         });
 
         it('deleteFacet with no FacetValues works', async () => {
-            const { createFacet } = await client.query<CreateFacet.Mutation, CreateFacet.Variables>(
+            const { createFacet } = await adminClient.query<CreateFacet.Mutation, CreateFacet.Variables>(
                 CREATE_FACET,
                 {
                     input: {
@@ -328,10 +336,13 @@ describe('Facet resolver', () => {
                     },
                 },
             );
-            const result = await client.query<DeleteFacet.Mutation, DeleteFacet.Variables>(DELETE_FACET, {
-                id: createFacet.id,
-                force: false,
-            });
+            const result = await adminClient.query<DeleteFacet.Mutation, DeleteFacet.Variables>(
+                DELETE_FACET,
+                {
+                    id: createFacet.id,
+                    force: false,
+                },
+            );
             expect(result.deleteFacet.result).toBe(DeletionResult.DELETED);
         });
     });

+ 126 - 0
packages/core/e2e/fixtures/test-payment-methods.ts

@@ -0,0 +1,126 @@
+import { PaymentMethodHandler } from '@vendure/core';
+
+import { LanguageCode } from '../graphql/generated-e2e-admin-types';
+
+export const testSuccessfulPaymentMethod = new PaymentMethodHandler({
+    code: 'test-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Settled',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+    settlePayment: order => ({
+        success: true,
+    }),
+});
+
+/**
+ * A two-stage (authorize, capture) payment method, with no createRefund method.
+ */
+export const twoStagePaymentMethod = new PaymentMethodHandler({
+    code: 'authorize-only-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Authorized',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+    settlePayment: () => {
+        return {
+            success: true,
+            metadata: {
+                moreData: 42,
+            },
+        };
+    },
+});
+
+/**
+ * A payment method which includes a createRefund method.
+ */
+export const singleStageRefundablePaymentMethod = new PaymentMethodHandler({
+    code: 'single-stage-refundable-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Settled',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+    settlePayment: () => {
+        return { success: true };
+    },
+    createRefund: (input, total, order, payment, args) => {
+        return {
+            amount: total,
+            state: 'Settled',
+            transactionId: 'abc123',
+        };
+    },
+});
+
+/**
+ * A payment method where calling `settlePayment` always fails.
+ */
+export const failsToSettlePaymentMethod = new PaymentMethodHandler({
+    code: 'fails-to-settle-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Authorized',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+    settlePayment: () => {
+        return {
+            success: false,
+            errorMessage: 'Something went horribly wrong',
+        };
+    },
+});
+export const testFailingPaymentMethod = new PaymentMethodHandler({
+    code: 'test-failing-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Failing Payment Method' }],
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Declined',
+            metadata,
+        };
+    },
+    settlePayment: order => ({
+        success: true,
+    }),
+});
+export const testErrorPaymentMethod = new PaymentMethodHandler({
+    code: 'test-error-payment-method',
+    description: [{ languageCode: LanguageCode.en, value: 'Test Error Payment Method' }],
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Error',
+            errorMessage: 'Something went horribly wrong',
+            metadata,
+        };
+    },
+    settlePayment: order => ({
+        success: true,
+    }),
+});

+ 31 - 20
packages/core/e2e/import.e2e-spec.ts

@@ -1,28 +1,27 @@
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 
 describe('Import resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment({
+        ...testConfig,
+        customFields: {
+            Product: [{ type: 'string', name: 'pageType' }],
+            ProductVariant: [{ type: 'int', name: 'weight' }],
+        },
+    });
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-empty.csv'),
-                customerCount: 0,
-            },
-            {
-                customFields: {
-                    Product: [{ type: 'string', name: 'pageType' }],
-                    ProductVariant: [{ type: 'int', name: 'weight' }],
-                },
-            },
-        );
-        await client.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-empty.csv'),
+            customerCount: 0,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -44,7 +43,19 @@ describe('Import resolver', () => {
         });
 
         const csvFile = path.join(__dirname, 'fixtures', 'product-import.csv');
-        const result = await client.importProducts(csvFile);
+        const result = await adminClient.fileUploadMutation({
+            mutation: gql`
+                mutation ImportProducts($csvFile: Upload!) {
+                    importProducts(csvFile: $csvFile) {
+                        imported
+                        processed
+                        errors
+                    }
+                }
+            `,
+            filePaths: [csvFile],
+            mapVariables: () => ({ csvFile: null }),
+        });
 
         expect(result.importProducts.errors).toEqual([
             'Invalid Record Length: header length is 16, got 1 on line 8',
@@ -52,7 +63,7 @@ describe('Import resolver', () => {
         expect(result.importProducts.imported).toBe(4);
         expect(result.importProducts.processed).toBe(4);
 
-        const productResult = await client.query(
+        const productResult = await adminClient.query(
             gql`
                 query GetProducts($options: ProductListOptions) {
                     products(options: $options) {

+ 27 - 24
packages/core/e2e/localization.e2e-spec.ts

@@ -1,9 +1,10 @@
+import { pick } from '@vendure/common/lib/pick';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { pick } from '../../common/lib/pick';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     GetProductWithVariants,
     LanguageCode,
@@ -11,22 +12,21 @@ import {
     UpdateProduct,
 } from './graphql/generated-e2e-admin-types';
 import { GET_PRODUCT_WITH_VARIANTS, UPDATE_PRODUCT } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 
 /* tslint:disable:no-non-null-assertion */
 describe('Role resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
             customerCount: 1,
         });
-        await client.init();
+        await adminClient.asSuperAdmin();
 
-        const { updateProduct } = await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
+        const { updateProduct } = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
             UPDATE_PRODUCT,
             {
                 input: {
@@ -55,16 +55,19 @@ describe('Role resolver', () => {
             },
         );
 
-        await client.query<UpdateOptionGroup.Mutation, UpdateOptionGroup.Variables>(UPDATE_OPTION_GROUP, {
-            input: {
-                id: 'T_1',
-                translations: [
-                    { languageCode: LanguageCode.en, name: 'en name' },
-                    { languageCode: LanguageCode.de, name: 'de name' },
-                    { languageCode: LanguageCode.zh, name: 'zh name' },
-                ],
+        await adminClient.query<UpdateOptionGroup.Mutation, UpdateOptionGroup.Variables>(
+            UPDATE_OPTION_GROUP,
+            {
+                input: {
+                    id: 'T_1',
+                    translations: [
+                        { languageCode: LanguageCode.en, name: 'en name' },
+                        { languageCode: LanguageCode.de, name: 'de name' },
+                        { languageCode: LanguageCode.zh, name: 'zh name' },
+                    ],
+                },
             },
-        });
+        );
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -72,7 +75,7 @@ describe('Role resolver', () => {
     });
 
     it('returns default language when none specified', async () => {
-        const { product } = await client.query<
+        const { product } = await adminClient.query<
             GetProductWithVariants.Query,
             GetProductWithVariants.Variables
         >(GET_PRODUCT_WITH_VARIANTS, {
@@ -86,7 +89,7 @@ describe('Role resolver', () => {
     });
 
     it('returns specified language', async () => {
-        const { product } = await client.query<
+        const { product } = await adminClient.query<
             GetProductWithVariants.Query,
             GetProductWithVariants.Variables
         >(
@@ -104,7 +107,7 @@ describe('Role resolver', () => {
     });
 
     it('falls back to default language code', async () => {
-        const { product } = await client.query<
+        const { product } = await adminClient.query<
             GetProductWithVariants.Query,
             GetProductWithVariants.Variables
         >(
@@ -122,7 +125,7 @@ describe('Role resolver', () => {
     });
 
     it('nested entites are translated', async () => {
-        const { product } = await client.query<
+        const { product } = await adminClient.query<
             GetProductWithVariants.Query,
             GetProductWithVariants.Variables
         >(
@@ -138,7 +141,7 @@ describe('Role resolver', () => {
     });
 
     it('translates results of mutation', async () => {
-        const { updateProduct } = await client.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
+        const { updateProduct } = await adminClient.query<UpdateProduct.Mutation, UpdateProduct.Variables>(
             UPDATE_PRODUCT,
             {
                 input: {

+ 24 - 30
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -1,15 +1,18 @@
 /* tslint:disable:no-non-null-assertion */
-import gql from 'graphql-tag';
-import path from 'path';
-
-import { pick } from '../../common/src/pick';
+import { pick } from '@vendure/common/lib/pick';
 import {
+    atLeastNWithFacets,
     discountOnItemWithFacets,
+    minimumOrderAmount,
     orderPercentageDiscount,
-} from '../src/config/promotion/default-promotion-actions';
-import { atLeastNWithFacets, minimumOrderAmount } from '../src/config/promotion/default-promotion-conditions';
+} from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import {
     CreatePromotion,
     CreatePromotionInput,
@@ -36,19 +39,16 @@ import {
     REMOVE_COUPON_CODE,
     SET_CUSTOMER,
 } from './graphql/shop-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
-import {
-    addPaymentToOrder,
-    proceedToArrangingPayment,
-    testSuccessfulPaymentMethod,
-} from './utils/test-order-utils';
+import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
 
 describe('Promotions applied to Orders', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        paymentOptions: {
+            paymentMethodHandlers: [testSuccessfulPaymentMethod],
+        },
+    });
 
     const freeOrderAction = {
         code: orderPercentageDiscount.code,
@@ -65,19 +65,13 @@ describe('Promotions applied to Orders', () => {
     let products: GetPromoProducts.Items[];
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-promotions.csv'),
-                customerCount: 2,
-            },
-            {
-                paymentOptions: {
-                    paymentMethodHandlers: [testSuccessfulPaymentMethod],
-                },
-            },
-        );
-        await shopClient.init();
-        await adminClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-promotions.csv'),
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
 
         await getProducts();
         await createGlobalPromotions();

+ 30 - 104
packages/core/e2e/order.e2e-spec.ts

@@ -1,13 +1,16 @@
 /* tslint:disable:no-non-null-assertion */
-import { LanguageCode } from '@vendure/common/lib/generated-shop-types';
+import { pick } from '@vendure/common/lib/pick';
+import { createTestEnvironment, SimpleGraphQLClient } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { HistoryEntryType, StockMovementType } from '../../common/lib/generated-types';
-import { pick } from '../../common/lib/pick';
-import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
+import {
+    failsToSettlePaymentMethod,
+    singleStageRefundablePaymentMethod,
+    twoStagePaymentMethod,
+} from './fixtures/test-payment-methods';
 import { ORDER_FRAGMENT, ORDER_WITH_LINES_FRAGMENT } from './graphql/fragments';
 import {
     AddNoteToOrder,
@@ -22,10 +25,12 @@ import {
     GetOrderListFulfillments,
     GetProductWithVariants,
     GetStockMovement,
+    HistoryEntryType,
     OrderItemFragment,
     RefundOrder,
     SettlePayment,
     SettleRefund,
+    StockMovementType,
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
 import { AddItemToOrder, GetActiveOrder } from './graphql/generated-e2e-shop-types';
@@ -36,35 +41,31 @@ import {
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
 import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
 
 describe('Orders resolver', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        paymentOptions: {
+            paymentMethodHandlers: [
+                twoStagePaymentMethod,
+                failsToSettlePaymentMethod,
+                singleStageRefundablePaymentMethod,
+            ],
+        },
+    });
     let customers: GetCustomerList.Items[];
     const password = 'test';
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-                customerCount: 3,
-            },
-            {
-                paymentOptions: {
-                    paymentMethodHandlers: [
-                        twoStagePaymentMethod,
-                        failsToSettlePaymentMethod,
-                        singleStageRefundablePaymentMethod,
-                    ],
-                },
-            },
-        );
-        await adminClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 3,
+        });
+        await adminClient.asSuperAdmin();
 
         // Create a couple of orders to be queried
         const result = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(
@@ -1191,84 +1192,9 @@ describe('Orders resolver', () => {
     });
 });
 
-/**
- * A two-stage (authorize, capture) payment method, with no createRefund method.
- */
-const twoStagePaymentMethod = new PaymentMethodHandler({
-    code: 'authorize-only-payment-method',
-    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Authorized',
-            transactionId: '12345',
-            metadata,
-        };
-    },
-    settlePayment: () => {
-        return {
-            success: true,
-            metadata: {
-                moreData: 42,
-            },
-        };
-    },
-});
-
-/**
- * A payment method which includes a createRefund method.
- */
-const singleStageRefundablePaymentMethod = new PaymentMethodHandler({
-    code: 'single-stage-refundable-payment-method',
-    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Settled',
-            transactionId: '12345',
-            metadata,
-        };
-    },
-    settlePayment: () => {
-        return { success: true };
-    },
-    createRefund: (input, total, order, payment, args) => {
-        return {
-            amount: total,
-            state: 'Settled',
-            transactionId: 'abc123',
-        };
-    },
-});
-
-/**
- * A payment method where calling `settlePayment` always fails.
- */
-const failsToSettlePaymentMethod = new PaymentMethodHandler({
-    code: 'fails-to-settle-payment-method',
-    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Authorized',
-            transactionId: '12345',
-            metadata,
-        };
-    },
-    settlePayment: () => {
-        return {
-            success: false,
-            errorMessage: 'Something went horribly wrong',
-        };
-    },
-});
-
 async function createTestOrder(
-    adminClient: TestAdminClient,
-    shopClient: TestShopClient,
+    adminClient: SimpleGraphQLClient,
+    shopClient: SimpleGraphQLClient,
     emailAddress: string,
     password: string,
 ): Promise<{

+ 27 - 30
packages/core/e2e/plugin.e2e-spec.ts

@@ -1,10 +1,11 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { ConfigService } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { ConfigService } from '../src/config/config.service';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     TestAPIExtensionPlugin,
     TestLazyExtensionPlugin,
@@ -12,42 +13,38 @@ import {
     TestPluginWithConfigAndBootstrap,
     TestPluginWithProvider,
 } from './fixtures/test-plugins';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 
 describe('Plugins', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
     const bootstrapMockFn = jest.fn();
     const onBootstrapFn = jest.fn();
     const onWorkerBootstrapFn = jest.fn();
     const onCloseFn = jest.fn();
     const onWorkerCloseFn = jest.fn();
 
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        plugins: [
+            TestPluginWithAllLifecycleHooks.init(
+                onBootstrapFn,
+                onWorkerBootstrapFn,
+                onCloseFn,
+                onWorkerCloseFn,
+            ),
+            TestPluginWithConfigAndBootstrap.setup(bootstrapMockFn),
+            TestAPIExtensionPlugin,
+            TestPluginWithProvider,
+            TestLazyExtensionPlugin,
+        ],
+    });
+
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-                customerCount: 1,
-            },
-            {
-                plugins: [
-                    TestPluginWithAllLifecycleHooks.init(
-                        onBootstrapFn,
-                        onWorkerBootstrapFn,
-                        onCloseFn,
-                        onWorkerCloseFn,
-                    ),
-                    TestPluginWithConfigAndBootstrap.setup(bootstrapMockFn),
-                    TestAPIExtensionPlugin,
-                    TestPluginWithProvider,
-                    TestLazyExtensionPlugin,
-                ],
-            },
-        );
-        await adminClient.init();
-        await shopClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {

+ 17 - 13
packages/core/e2e/product-option.e2e-spec.ts

@@ -1,9 +1,11 @@
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
 import { omit } from '../../common/lib/omit';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     CreateProductOption,
     CreateProductOptionGroup,
@@ -12,24 +14,23 @@ import {
     UpdateProductOption,
     UpdateProductOptionGroup,
 } from './graphql/generated-e2e-admin-types';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 // tslint:disable:no-non-null-assertion
 
 describe('ProductOption resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
     let sizeGroup: ProductOptionGroupFragment;
     let mediumOption: CreateProductOption.CreateProductOption;
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             customerCount: 1,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
         });
-        await client.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -37,7 +38,7 @@ describe('ProductOption resolver', () => {
     });
 
     it('createProductOptionGroup', async () => {
-        const { createProductOptionGroup } = await client.query<
+        const { createProductOptionGroup } = await adminClient.query<
             CreateProductOptionGroup.Mutation,
             CreateProductOptionGroup.Variables
         >(CREATE_PRODUCT_OPTION_GROUP, {
@@ -75,7 +76,7 @@ describe('ProductOption resolver', () => {
     });
 
     it('updateProductOptionGroup', async () => {
-        const { updateProductOptionGroup } = await client.query<
+        const { updateProductOptionGroup } = await adminClient.query<
             UpdateProductOptionGroup.Mutation,
             UpdateProductOptionGroup.Variables
         >(UPDATE_PRODUCT_OPTION_GROUP, {
@@ -93,7 +94,7 @@ describe('ProductOption resolver', () => {
     it(
         'createProductOption throws with invalid productOptionGroupId',
         assertThrowsWithMessage(async () => {
-            const { createProductOption } = await client.query<
+            const { createProductOption } = await adminClient.query<
                 CreateProductOption.Mutation,
                 CreateProductOption.Variables
             >(CREATE_PRODUCT_OPTION, {
@@ -106,11 +107,11 @@ describe('ProductOption resolver', () => {
                     ],
                 },
             });
-        }, 'No ProductOptionGroup with the id \'999\' could be found'),
+        }, "No ProductOptionGroup with the id '999' could be found"),
     );
 
     it('createProductOption', async () => {
-        const { createProductOption } = await client.query<
+        const { createProductOption } = await adminClient.query<
             CreateProductOption.Mutation,
             CreateProductOption.Variables
         >(CREATE_PRODUCT_OPTION, {
@@ -134,7 +135,10 @@ describe('ProductOption resolver', () => {
     });
 
     it('updateProductOption', async () => {
-        const { updateProductOption } = await client.query<UpdateProductOption.Mutation, UpdateProductOption.Variables>(UPDATE_PRODUCT_OPTION, {
+        const { updateProductOption } = await adminClient.query<
+            UpdateProductOption.Mutation,
+            UpdateProductOption.Variables
+        >(UPDATE_PRODUCT_OPTION, {
             input: {
                 id: 'T_7',
                 translations: [

+ 8 - 9
packages/core/e2e/product.e2e-spec.ts

@@ -1,10 +1,12 @@
 import { omit } from '@vendure/common/lib/omit';
 import { pick } from '@vendure/common/lib/pick';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     AddOptionGroupToProduct,
     CreateProduct,
@@ -35,24 +37,21 @@ import {
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Product resolver', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             customerCount: 1,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
         });
-        await adminClient.init();
-        await shopClient.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {

+ 40 - 40
packages/core/e2e/promotion.e2e-spec.ts

@@ -1,12 +1,11 @@
 import { pick } from '@vendure/common/lib/pick';
+import { PromotionAction, PromotionCondition, PromotionOrderAction } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { LanguageCode } from '../../common/lib/generated-types';
-import { PromotionAction, PromotionOrderAction } from '../src/config/promotion/promotion-action';
-import { PromotionCondition } from '../src/config/promotion/promotion-condition';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { PROMOTION_FRAGMENT } from './graphql/fragments';
 import {
     CreatePromotion,
@@ -15,25 +14,28 @@ import {
     GetAdjustmentOperations,
     GetPromotion,
     GetPromotionList,
+    LanguageCode,
     Promotion,
     UpdatePromotion,
 } from './graphql/generated-e2e-admin-types';
 import { CREATE_PROMOTION } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Promotion resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
-
     const promoCondition = generateTestCondition('promo_condition');
     const promoCondition2 = generateTestCondition('promo_condition2');
-
     const promoAction = generateTestAction('promo_action');
 
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        promotionOptions: {
+            promotionConditions: [promoCondition, promoCondition2],
+            promotionActions: [promoAction],
+        },
+    });
+
     const snapshotProps: Array<keyof Promotion.Fragment> = [
         'name',
         'actions',
@@ -46,19 +48,13 @@ describe('Promotion resolver', () => {
     let promotion: Promotion.Fragment;
 
     beforeAll(async () => {
-        await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
-            },
-            {
-                promotionOptions: {
-                    promotionConditions: [promoCondition, promoCondition2],
-                    promotionActions: [promoAction],
-                },
-            },
-        );
-        await client.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -66,7 +62,7 @@ describe('Promotion resolver', () => {
     });
 
     it('createPromotion', async () => {
-        const result = await client.query<CreatePromotion.Mutation, CreatePromotion.Variables>(
+        const result = await adminClient.query<CreatePromotion.Mutation, CreatePromotion.Variables>(
             CREATE_PROMOTION,
             {
                 input: {
@@ -103,7 +99,7 @@ describe('Promotion resolver', () => {
     it(
         'createPromotion throws with empty conditions and no couponCode',
         assertThrowsWithMessage(async () => {
-            await client.query<CreatePromotion.Mutation, CreatePromotion.Variables>(CREATE_PROMOTION, {
+            await adminClient.query<CreatePromotion.Mutation, CreatePromotion.Variables>(CREATE_PROMOTION, {
                 input: {
                     name: 'bad promotion',
                     enabled: true,
@@ -126,7 +122,7 @@ describe('Promotion resolver', () => {
     );
 
     it('updatePromotion', async () => {
-        const result = await client.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(
+        const result = await adminClient.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(
             UPDATE_PROMOTION,
             {
                 input: {
@@ -153,7 +149,7 @@ describe('Promotion resolver', () => {
     it(
         'updatePromotion throws with empty conditions and no couponCode',
         assertThrowsWithMessage(async () => {
-            await client.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(UPDATE_PROMOTION, {
+            await adminClient.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(UPDATE_PROMOTION, {
                 input: {
                     id: promotion.id,
                     couponCode: '',
@@ -164,7 +160,7 @@ describe('Promotion resolver', () => {
     );
 
     it('promotion', async () => {
-        const result = await client.query<GetPromotion.Query, GetPromotion.Variables>(GET_PROMOTION, {
+        const result = await adminClient.query<GetPromotion.Query, GetPromotion.Variables>(GET_PROMOTION, {
             id: promotion.id,
         });
 
@@ -172,7 +168,7 @@ describe('Promotion resolver', () => {
     });
 
     it('promotions', async () => {
-        const result = await client.query<GetPromotionList.Query, GetPromotionList.Variables>(
+        const result = await adminClient.query<GetPromotionList.Query, GetPromotionList.Variables>(
             GET_PROMOTION_LIST,
             {},
         );
@@ -182,9 +178,10 @@ describe('Promotion resolver', () => {
     });
 
     it('adjustmentOperations', async () => {
-        const result = await client.query<GetAdjustmentOperations.Query, GetAdjustmentOperations.Variables>(
-            GET_ADJUSTMENT_OPERATIONS,
-        );
+        const result = await adminClient.query<
+            GetAdjustmentOperations.Query,
+            GetAdjustmentOperations.Variables
+        >(GET_ADJUSTMENT_OPERATIONS);
 
         expect(result.promotionActions).toMatchSnapshot();
         expect(result.promotionConditions).toMatchSnapshot();
@@ -195,13 +192,13 @@ describe('Promotion resolver', () => {
         let promotionToDelete: GetPromotionList.Items;
 
         beforeAll(async () => {
-            const result = await client.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
+            const result = await adminClient.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
             allPromotions = result.promotions.items;
         });
 
         it('deletes a promotion', async () => {
             promotionToDelete = allPromotions[0];
-            const result = await client.query<DeletePromotion.Mutation, DeletePromotion.Variables>(
+            const result = await adminClient.query<DeletePromotion.Mutation, DeletePromotion.Variables>(
                 DELETE_PROMOTION,
                 { id: promotionToDelete.id },
             );
@@ -210,15 +207,18 @@ describe('Promotion resolver', () => {
         });
 
         it('cannot get a deleted promotion', async () => {
-            const result = await client.query<GetPromotion.Query, GetPromotion.Variables>(GET_PROMOTION, {
-                id: promotionToDelete.id,
-            });
+            const result = await adminClient.query<GetPromotion.Query, GetPromotion.Variables>(
+                GET_PROMOTION,
+                {
+                    id: promotionToDelete.id,
+                },
+            );
 
             expect(result.promotion).toBe(null);
         });
 
         it('deleted promotion omitted from list', async () => {
-            const result = await client.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
+            const result = await adminClient.query<GetPromotionList.Query>(GET_PROMOTION_LIST);
 
             expect(result.promotions.items.length).toBe(allPromotions.length - 1);
             expect(result.promotions.items.map(c => c.id).includes(promotionToDelete.id)).toBe(false);
@@ -228,7 +228,7 @@ describe('Promotion resolver', () => {
             'updatePromotion throws for deleted promotion',
             assertThrowsWithMessage(
                 () =>
-                    client.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(UPDATE_PROMOTION, {
+                    adminClient.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(UPDATE_PROMOTION, {
                         input: {
                             id: promotionToDelete.id,
                             enabled: false,

+ 37 - 28
packages/core/e2e/role.e2e-spec.ts

@@ -1,9 +1,11 @@
 import { omit } from '@vendure/common/lib/omit';
 import { CUSTOMER_ROLE_CODE, SUPER_ADMIN_ROLE_CODE } from '@vendure/common/lib/shared-constants';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { ROLE_FRAGMENT } from './graphql/fragments';
 import {
     CreateRole,
@@ -14,22 +16,21 @@ import {
     UpdateRole,
 } from './graphql/generated-e2e-admin-types';
 import { CREATE_ROLE } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('Role resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
     let createdRole: Role.Fragment;
     let defaultRoles: Role.Fragment[];
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
             customerCount: 1,
         });
-        await client.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -37,7 +38,7 @@ describe('Role resolver', () => {
     });
 
     it('roles', async () => {
-        const result = await client.query<GetRoles.Query, GetRoles.Variables>(GET_ROLES);
+        const result = await adminClient.query<GetRoles.Query, GetRoles.Variables>(GET_ROLES);
 
         defaultRoles = result.roles.items;
         expect(result.roles.items.length).toBe(2);
@@ -47,7 +48,7 @@ describe('Role resolver', () => {
     it(
         'createRole with invalid permission',
         assertThrowsWithMessage(async () => {
-            await client.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
+            await adminClient.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
                 input: {
                     code: 'test',
                     description: 'test role',
@@ -58,13 +59,16 @@ describe('Role resolver', () => {
     );
 
     it('createRole with no permissions includes Authenticated', async () => {
-        const { createRole } = await client.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
-            input: {
-                code: 'test',
-                description: 'test role',
-                permissions: [],
+        const { createRole } = await adminClient.query<CreateRole.Mutation, CreateRole.Variables>(
+            CREATE_ROLE,
+            {
+                input: {
+                    code: 'test',
+                    description: 'test role',
+                    permissions: [],
+                },
             },
-        });
+        );
 
         expect(omit(createRole, ['channels'])).toEqual({
             code: 'test',
@@ -75,13 +79,16 @@ describe('Role resolver', () => {
     });
 
     it('createRole deduplicates permissions', async () => {
-        const { createRole } = await client.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
-            input: {
-                code: 'test2',
-                description: 'test role2',
-                permissions: [Permission.ReadSettings, Permission.ReadSettings],
+        const { createRole } = await adminClient.query<CreateRole.Mutation, CreateRole.Variables>(
+            CREATE_ROLE,
+            {
+                input: {
+                    code: 'test2',
+                    description: 'test role2',
+                    permissions: [Permission.ReadSettings, Permission.ReadSettings],
+                },
             },
-        });
+        );
 
         expect(omit(createRole, ['channels'])).toEqual({
             code: 'test2',
@@ -92,7 +99,7 @@ describe('Role resolver', () => {
     });
 
     it('createRole with permissions', async () => {
-        const result = await client.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
+        const result = await adminClient.query<CreateRole.Mutation, CreateRole.Variables>(CREATE_ROLE, {
             input: {
                 code: 'test',
                 description: 'test role',
@@ -110,12 +117,14 @@ describe('Role resolver', () => {
     });
 
     it('role', async () => {
-        const result = await client.query<GetRole.Query, GetRole.Variables>(GET_ROLE, { id: createdRole.id });
+        const result = await adminClient.query<GetRole.Query, GetRole.Variables>(GET_ROLE, {
+            id: createdRole.id,
+        });
         expect(result.role).toEqual(createdRole);
     });
 
     it('updateRole', async () => {
-        const result = await client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
+        const result = await adminClient.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
             input: {
                 id: createdRole.id,
                 code: 'test-modified',
@@ -138,7 +147,7 @@ describe('Role resolver', () => {
     });
 
     it('updateRole works with partial input', async () => {
-        const result = await client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
+        const result = await adminClient.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
             input: {
                 id: createdRole.id,
                 code: 'test-modified-again',
@@ -156,7 +165,7 @@ describe('Role resolver', () => {
     });
 
     it('updateRole deduplicates permissions', async () => {
-        const result = await client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
+        const result = await adminClient.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
             input: {
                 id: createdRole.id,
                 permissions: [
@@ -179,7 +188,7 @@ describe('Role resolver', () => {
                 fail(`Could not find SuperAdmin role`);
                 return;
             }
-            return client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
+            return adminClient.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
                 input: {
                     id: superAdminRole.id,
                     code: 'superadmin-modified',
@@ -198,7 +207,7 @@ describe('Role resolver', () => {
                 fail(`Could not find Customer role`);
                 return;
             }
-            return client.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
+            return adminClient.query<UpdateRole.Mutation, UpdateRole.Variables>(UPDATE_ROLE, {
                 input: {
                     id: customerRole.id,
                     code: 'customer-modified',

+ 41 - 43
packages/core/e2e/shipping-method.e2e-spec.ts

@@ -1,13 +1,15 @@
 /* tslint:disable:no-non-null-assertion */
+import {
+    defaultShippingCalculator,
+    defaultShippingEligibilityChecker,
+    ShippingCalculator,
+} from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { LanguageCode } from '../../common/lib/generated-types';
-import { defaultShippingCalculator } from '../src/config/shipping-method/default-shipping-calculator';
-import { defaultShippingEligibilityChecker } from '../src/config/shipping-method/default-shipping-eligibility-checker';
-import { ShippingCalculator } from '../src/config/shipping-method/shipping-calculator';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     CreateShippingMethod,
     DeleteShippingMethod,
@@ -16,33 +18,47 @@ import {
     GetEligibilityCheckers,
     GetShippingMethod,
     GetShippingMethodList,
+    LanguageCode,
     TestEligibleMethods,
     TestShippingMethod,
     UpdateShippingMethod,
 } from './graphql/generated-e2e-admin-types';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
+
+const TEST_METADATA = {
+    foo: 'bar',
+    baz: [1, 2, 3],
+};
+
+const calculatorWithMetadata = new ShippingCalculator({
+    code: 'calculator-with-metadata',
+    description: [{ languageCode: LanguageCode.en, value: 'Has metadata' }],
+    args: {},
+    calculate: order => {
+        return {
+            price: 100,
+            priceWithTax: 100,
+            metadata: TEST_METADATA,
+        };
+    },
+});
 
 describe('ShippingMethod resolver', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        shippingOptions: {
+            shippingEligibilityCheckers: [defaultShippingEligibilityChecker],
+            shippingCalculators: [defaultShippingCalculator, calculatorWithMetadata],
+        },
+    });
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-                customerCount: 1,
-            },
-            {
-                shippingOptions: {
-                    shippingEligibilityCheckers: [defaultShippingEligibilityChecker],
-                    shippingCalculators: [defaultShippingCalculator, calculatorWithMetadata],
-                },
-            },
-        );
-        await adminClient.init();
-        await shopClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -279,24 +295,6 @@ describe('ShippingMethod resolver', () => {
     });
 });
 
-const TEST_METADATA = {
-    foo: 'bar',
-    baz: [1, 2, 3],
-};
-
-const calculatorWithMetadata = new ShippingCalculator({
-    code: 'calculator-with-metadata',
-    description: [{ languageCode: LanguageCode.en, value: 'Has metadata' }],
-    args: {},
-    calculate: order => {
-        return {
-            price: 100,
-            priceWithTax: 100,
-            metadata: TEST_METADATA,
-        };
-    },
-});
-
 const SHIPPING_METHOD_FRAGMENT = gql`
     fragment ShippingMethod on ShippingMethod {
         id

+ 95 - 103
packages/core/e2e/shop-auth.e2e-spec.ts

@@ -2,19 +2,23 @@
 import { OnModuleInit } from '@nestjs/common';
 import { RegisterCustomerInput } from '@vendure/common/lib/generated-shop-types';
 import { pick } from '@vendure/common/lib/pick';
+import {
+    AccountRegistrationEvent,
+    EventBus,
+    EventBusModule,
+    IdentifierChangeEvent,
+    IdentifierChangeRequestEvent,
+    mergeConfig,
+    PasswordResetEvent,
+    VendurePlugin,
+} from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
 import { DocumentNode } from 'graphql';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { EventBus } from '../src/event-bus/event-bus';
-import { EventBusModule } from '../src/event-bus/event-bus.module';
-import { AccountRegistrationEvent } from '../src/event-bus/events/account-registration-event';
-import { IdentifierChangeEvent } from '../src/event-bus/events/identifier-change-event';
-import { IdentifierChangeRequestEvent } from '../src/event-bus/events/identifier-change-request-event';
-import { PasswordResetEvent } from '../src/event-bus/events/password-reset-event';
-import { VendurePlugin } from '../src/plugin/vendure-plugin';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     CreateAdministrator,
     CreateRole,
@@ -42,29 +46,50 @@ import {
     UPDATE_EMAIL_ADDRESS,
     VERIFY_EMAIL,
 } from './graphql/shop-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 let sendEmailFn: jest.Mock;
 
+/**
+ * This mock plugin simulates an EmailPlugin which would send emails
+ * on the registration & password reset events.
+ */
+@VendurePlugin({
+    imports: [EventBusModule],
+})
+class TestEmailPlugin implements OnModuleInit {
+    constructor(private eventBus: EventBus) {}
+    onModuleInit() {
+        this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
+            sendEmailFn(event);
+        });
+        this.eventBus.ofType(PasswordResetEvent).subscribe(event => {
+            sendEmailFn(event);
+        });
+        this.eventBus.ofType(IdentifierChangeRequestEvent).subscribe(event => {
+            sendEmailFn(event);
+        });
+        this.eventBus.ofType(IdentifierChangeEvent).subscribe(event => {
+            sendEmailFn(event);
+        });
+    }
+}
+
 describe('Shop auth & accounts', () => {
-    const shopClient = new TestShopClient();
-    const adminClient = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            plugins: [TestEmailPlugin as any],
+        }),
+    );
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 2,
-            },
-            {
-                plugins: [TestEmailPlugin],
-            },
-        );
-        await shopClient.init();
-        await adminClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -508,25 +533,23 @@ describe('Shop auth & accounts', () => {
 });
 
 describe('Expiring tokens', () => {
-    const shopClient = new TestShopClient();
-    const adminClient = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            plugins: [TestEmailPlugin as any],
+            authOptions: {
+                verificationTokenDuration: '1ms',
+            },
+        }),
+    );
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
-            },
-            {
-                plugins: [TestEmailPlugin],
-                authOptions: {
-                    verificationTokenDuration: '1ms',
-                },
-            },
-        );
-        await shopClient.init();
-        await adminClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     beforeEach(() => {
@@ -598,24 +621,23 @@ describe('Expiring tokens', () => {
 });
 
 describe('Registration without email verification', () => {
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            plugins: [TestEmailPlugin as any],
+            authOptions: {
+                requireVerification: false,
+            },
+        }),
+    );
     const userEmailAddress = 'glen.beardsley@test.com';
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
-            },
-            {
-                plugins: [TestEmailPlugin],
-                authOptions: {
-                    requireVerification: false,
-                },
-            },
-        );
-        await shopClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
     }, TEST_SETUP_TIMEOUT_MS);
 
     beforeEach(() => {
@@ -672,35 +694,30 @@ describe('Registration without email verification', () => {
 });
 
 describe('Updating email address without email verification', () => {
-    const shopClient = new TestShopClient();
-    const adminClient = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            plugins: [TestEmailPlugin as any],
+            authOptions: {
+                requireVerification: false,
+            },
+        }),
+    );
     let customer: GetCustomer.Customer;
     const NEW_EMAIL_ADDRESS = 'new@address.com';
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
-                customerCount: 1,
-            },
-            {
-                plugins: [TestEmailPlugin],
-                authOptions: {
-                    requireVerification: false,
-                },
-            },
-        );
-        await shopClient.init();
-        await adminClient.init();
-    }, TEST_SETUP_TIMEOUT_MS);
-
-    beforeAll(async () => {
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
         const result = await adminClient.query<GetCustomer.Query, GetCustomer.Variables>(GET_CUSTOMER, {
             id: 'T_1',
         });
         customer = result.customer!;
-    });
+    }, TEST_SETUP_TIMEOUT_MS);
 
     beforeEach(() => {
         sendEmailFn = jest.fn();
@@ -729,31 +746,6 @@ describe('Updating email address without email verification', () => {
     });
 });
 
-/**
- * This mock plugin simulates an EmailPlugin which would send emails
- * on the registration & password reset events.
- */
-@VendurePlugin({
-    imports: [EventBusModule],
-})
-class TestEmailPlugin implements OnModuleInit {
-    constructor(private eventBus: EventBus) {}
-    onModuleInit() {
-        this.eventBus.ofType(AccountRegistrationEvent).subscribe(event => {
-            sendEmailFn(event);
-        });
-        this.eventBus.ofType(PasswordResetEvent).subscribe(event => {
-            sendEmailFn(event);
-        });
-        this.eventBus.ofType(IdentifierChangeRequestEvent).subscribe(event => {
-            sendEmailFn(event);
-        });
-        this.eventBus.ofType(IdentifierChangeEvent).subscribe(event => {
-            sendEmailFn(event);
-        });
-    }
-}
-
 function getVerificationTokenPromise(): Promise<string> {
     return new Promise<any>(resolve => {
         sendEmailFn.mockImplementation((event: AccountRegistrationEvent) => {

+ 9 - 11
packages/core/e2e/shop-catalog.e2e-spec.ts

@@ -1,10 +1,11 @@
 /* tslint:disable:no-non-null-assertion */
+import { facetValueCollectionFilter } from '@vendure/core/dist/config/collection/default-collection-filters';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { facetValueCollectionFilter } from '../src/config/collection/default-collection-filters';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import {
     CreateCollection,
     CreateFacet,
@@ -36,22 +37,19 @@ import {
     UPDATE_PRODUCT,
     UPDATE_PRODUCT_VARIANTS,
 } from './graphql/shared-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('Shop catalog', () => {
-    const shopClient = new TestShopClient();
-    const adminClient = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
             customerCount: 1,
         });
-        await shopClient.init();
-        await adminClient.init();
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {

+ 8 - 9
packages/core/e2e/shop-customer.e2e-spec.ts

@@ -1,8 +1,10 @@
 /* tslint:disable:no-non-null-assertion */
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { AttemptLogin, GetCustomer, GetCustomerIds } from './graphql/generated-e2e-admin-types';
 import {
     CreateAddressInput,
@@ -22,23 +24,20 @@ import {
     UPDATE_CUSTOMER,
     UPDATE_PASSWORD,
 } from './graphql/shop-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('Shop customers', () => {
-    const shopClient = new TestShopClient();
-    const adminClient = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
     let customer: GetCustomer.Customer;
 
     beforeAll(async () => {
-        const token = await server.init({
+        await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
             customerCount: 2,
         });
-        await shopClient.init();
-        await adminClient.init();
+        await adminClient.asSuperAdmin();
 
         // Fetch the first Customer and store it as the `customer` variable.
         const { customers } = await adminClient.query<GetCustomerIds.Query>(gql`

+ 30 - 63
packages/core/e2e/shop-order.e2e-spec.ts

@@ -1,15 +1,20 @@
 /* tslint:disable:no-non-null-assertion */
+import { mergeConfig } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
 import path from 'path';
 
-import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
+import {
+    testErrorPaymentMethod,
+    testFailingPaymentMethod,
+    testSuccessfulPaymentMethod,
+} from './fixtures/test-payment-methods';
 import {
     CreateAddressInput,
     GetCountryList,
     GetCustomer,
     GetCustomerList,
-    LanguageCode,
     UpdateCountry,
 } from './graphql/generated-e2e-admin-types';
 import {
@@ -52,37 +57,32 @@ import {
     SET_SHIPPING_METHOD,
     TRANSITION_TO_STATE,
 } from './graphql/shop-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
-import { testSuccessfulPaymentMethod } from './utils/test-order-utils';
 
 describe('Shop orders', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
-
-    beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
-                customerCount: 2,
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            paymentOptions: {
+                paymentMethodHandlers: [
+                    testSuccessfulPaymentMethod,
+                    testFailingPaymentMethod,
+                    testErrorPaymentMethod,
+                ],
             },
-            {
-                paymentOptions: {
-                    paymentMethodHandlers: [
-                        testSuccessfulPaymentMethod,
-                        testFailingPaymentMethod,
-                        testErrorPaymentMethod,
-                    ],
-                },
-                orderOptions: {
-                    orderItemsLimit: 99,
-                },
+            orderOptions: {
+                orderItemsLimit: 99,
             },
-        );
-        await shopClient.init();
-        await adminClient.init();
+        }),
+    );
+
+    beforeAll(async () => {
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -867,36 +867,3 @@ describe('Shop orders', () => {
         });
     });
 });
-
-const testFailingPaymentMethod = new PaymentMethodHandler({
-    code: 'test-failing-payment-method',
-    description: [{ languageCode: LanguageCode.en, value: 'Test Failing Payment Method' }],
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Declined',
-            metadata,
-        };
-    },
-    settlePayment: order => ({
-        success: true,
-    }),
-});
-
-const testErrorPaymentMethod = new PaymentMethodHandler({
-    code: 'test-error-payment-method',
-    description: [{ languageCode: LanguageCode.en, value: 'Test Error Payment Method' }],
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Error',
-            errorMessage: 'Something went horribly wrong',
-            metadata,
-        };
-    },
-    settlePayment: order => ({
-        success: true,
-    }),
-});

+ 20 - 41
packages/core/e2e/stock-control.e2e-spec.ts

@@ -1,16 +1,16 @@
 /* tslint:disable:no-non-null-assertion */
+import { mergeConfig, OrderState } from '@vendure/core';
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
-import { OrderState } from '../src/service/helpers/order-state-machine/order-state';
-
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
 import { VARIANT_WITH_STOCK_FRAGMENT } from './graphql/fragments';
 import {
     CreateAddressInput,
     GetStockMovement,
-    LanguageCode,
     StockMovementType,
     UpdateProductVariantInput,
     UpdateStock,
@@ -30,29 +30,25 @@ import {
     SET_SHIPPING_ADDRESS,
     TRANSITION_TO_STATE,
 } from './graphql/shop-definitions';
-import { TestAdminClient, TestShopClient } from './test-client';
-import { TestServer } from './test-server';
 import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 
 describe('Stock control', () => {
-    const adminClient = new TestAdminClient();
-    const shopClient = new TestShopClient();
-    const server = new TestServer();
+    const { server, adminClient, shopClient } = createTestEnvironment(
+        mergeConfig(testConfig, {
+            paymentOptions: {
+                paymentMethodHandlers: [testSuccessfulPaymentMethod],
+            },
+        }),
+    );
 
     beforeAll(async () => {
-        const token = await server.init(
-            {
-                productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control.csv'),
-                customerCount: 2,
-            },
-            {
-                paymentOptions: {
-                    paymentMethodHandlers: [testPaymentMethod],
-                },
-            },
-        );
-        await shopClient.init();
-        await adminClient.init();
+        await server.init({
+            dataDir,
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-stock-control.csv'),
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
     }, TEST_SETUP_TIMEOUT_MS);
 
     afterAll(async () => {
@@ -202,7 +198,7 @@ describe('Stock control', () => {
                 AddPaymentToOrder.Variables
             >(ADD_PAYMENT, {
                 input: {
-                    method: testPaymentMethod.code,
+                    method: testSuccessfulPaymentMethod.code,
                     metadata: {},
                 } as PaymentInput,
             });
@@ -236,23 +232,6 @@ describe('Stock control', () => {
     });
 });
 
-const testPaymentMethod = new PaymentMethodHandler({
-    code: 'test-payment-method',
-    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Settled',
-            transactionId: '12345',
-            metadata,
-        };
-    },
-    settlePayment: order => ({
-        success: true,
-    }),
-});
-
 const UPDATE_STOCK_ON_HAND = gql`
     mutation UpdateStock($input: [UpdateProductVariantInput!]!) {
         updateProductVariants(input: $input) {

+ 0 - 34
packages/core/e2e/test-client.ts

@@ -1,34 +0,0 @@
-import { getDefaultChannelToken } from '../mock-data/get-default-channel-token';
-import { SimpleGraphQLClient } from '../mock-data/simple-graphql-client';
-
-import { testConfig } from './config/test-config';
-
-// tslint:disable:no-console
-/**
- * A GraphQL client for use in e2e tests configured to use the test admin server endpoint.
- */
-export class TestAdminClient extends SimpleGraphQLClient {
-    constructor() {
-        super(`http://localhost:${testConfig.port}/${testConfig.adminApiPath}`);
-    }
-
-    async init() {
-        const token = await getDefaultChannelToken(false);
-        this.setChannelToken(token);
-        await this.asSuperAdmin();
-    }
-}
-
-/**
- * A GraphQL client for use in e2e tests configured to use the test shop server endpoint.
- */
-export class TestShopClient extends SimpleGraphQLClient {
-    constructor() {
-        super(`http://localhost:${testConfig.port}/${testConfig.shopApiPath}`);
-    }
-
-    async init() {
-        const token = await getDefaultChannelToken(false);
-        this.setChannelToken(token);
-    }
-}

+ 3 - 2
packages/core/e2e/utils/await-running-jobs.ts

@@ -1,12 +1,13 @@
+import { SimpleGraphQLClient } from '@vendure/testing';
+
 import { GetRunningJobs, JobState } from '../graphql/generated-e2e-admin-types';
 import { GET_RUNNING_JOBS } from '../graphql/shared-definitions';
-import { TestAdminClient } from '../test-client';
 
 /**
  * For mutation which trigger background jobs, this can be used to "pause" the execution of
  * the test until those jobs have completed;
  */
-export async function awaitRunningJobs(adminClient: TestAdminClient, timeout: number = 5000) {
+export async function awaitRunningJobs(adminClient: SimpleGraphQLClient, timeout: number = 5000) {
     let runningJobs = 0;
     const startTime = +new Date();
     let timedOut = false;

+ 0 - 9
packages/core/e2e/utils/test-environment.ts

@@ -1,9 +0,0 @@
-export const E2E_TESTING_ENV_VARIABLE = 'isE2ETest';
-
-export function setTestEnvironment() {
-    process.env[E2E_TESTING_ENV_VARIABLE] = '1';
-}
-
-export function isTestEnvironment() {
-    return !!process.env[E2E_TESTING_ENV_VARIABLE];
-}

+ 6 - 23
packages/core/e2e/utils/test-order-utils.ts

@@ -1,7 +1,8 @@
 /* tslint:disable:no-non-null-assertion */
-import { ID } from '../../../common/lib/shared-types';
-import { PaymentMethodHandler } from '../../src/config/payment-method/payment-method-handler';
-import { LanguageCode } from '../graphql/generated-e2e-admin-types';
+import { ID } from '@vendure/common/lib/shared-types';
+import { PaymentMethodHandler } from '@vendure/core';
+import { SimpleGraphQLClient } from '@vendure/testing';
+
 import {
     AddPaymentToOrder,
     GetShippingMethods,
@@ -16,9 +17,8 @@ import {
     SET_SHIPPING_METHOD,
     TRANSITION_TO_STATE,
 } from '../graphql/shop-definitions';
-import { TestShopClient } from '../test-client';
 
-export async function proceedToArrangingPayment(shopClient: TestShopClient): Promise<ID> {
+export async function proceedToArrangingPayment(shopClient: SimpleGraphQLClient): Promise<ID> {
     await shopClient.query<SetShippingAddress.Mutation, SetShippingAddress.Variables>(SET_SHIPPING_ADDRESS, {
         input: {
             fullName: 'name',
@@ -46,7 +46,7 @@ export async function proceedToArrangingPayment(shopClient: TestShopClient): Pro
 }
 
 export async function addPaymentToOrder(
-    shopClient: TestShopClient,
+    shopClient: SimpleGraphQLClient,
     handler: PaymentMethodHandler,
 ): Promise<NonNullable<AddPaymentToOrder.Mutation['addPaymentToOrder']>> {
     const result = await shopClient.query<AddPaymentToOrder.Mutation, AddPaymentToOrder.Variables>(
@@ -63,20 +63,3 @@ export async function addPaymentToOrder(
     const order = result.addPaymentToOrder!;
     return order as any;
 }
-
-export const testSuccessfulPaymentMethod = new PaymentMethodHandler({
-    code: 'test-payment-method',
-    description: [{ languageCode: LanguageCode.en, value: 'Test Payment Method' }],
-    args: {},
-    createPayment: (order, args, metadata) => {
-        return {
-            amount: order.total,
-            state: 'Settled',
-            transactionId: '12345',
-            metadata,
-        };
-    },
-    settlePayment: order => ({
-        success: true,
-    }),
-});

+ 32 - 25
packages/core/e2e/zone.e2e-spec.ts

@@ -1,26 +1,27 @@
+import { createTestEnvironment } from '@vendure/testing';
 import gql from 'graphql-tag';
 import path from 'path';
 
-import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
+import { dataDir, TEST_SETUP_TIMEOUT_MS, testConfig } from './config/test-config';
+import { initialData } from './fixtures/e2e-initial-data';
 import { ZONE_FRAGMENT } from './graphql/fragments';
 import {
     AddMembersToZone,
-    CreateZone, DeleteZone,
+    CreateZone,
+    DeleteZone,
     DeletionResult,
     GetCountryList,
-    GetZone, GetZones,
+    GetZone,
+    GetZones,
     RemoveMembersFromZone,
     UpdateZone,
 } from './graphql/generated-e2e-admin-types';
 import { GET_COUNTRY_LIST } from './graphql/shared-definitions';
-import { TestAdminClient } from './test-client';
-import { TestServer } from './test-server';
 
 // tslint:disable:no-non-null-assertion
 
 describe('Facet resolver', () => {
-    const client = new TestAdminClient();
-    const server = new TestServer();
+    const { server, adminClient } = createTestEnvironment(testConfig);
     let countries: GetCountryList.Items[];
     let zones: Array<{ id: string; name: string }>;
     let oceania: { id: string; name: string };
@@ -28,12 +29,14 @@ describe('Facet resolver', () => {
 
     beforeAll(async () => {
         await server.init({
+            dataDir,
+            initialData,
             productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
             customerCount: 1,
         });
-        await client.init();
+        await adminClient.asSuperAdmin();
 
-        const result = await client.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
+        const result = await adminClient.query<GetCountryList.Query>(GET_COUNTRY_LIST, {});
         countries = result.countries.items;
     }, TEST_SETUP_TIMEOUT_MS);
 
@@ -42,14 +45,14 @@ describe('Facet resolver', () => {
     });
 
     it('zones', async () => {
-        const result = await client.query<GetZones.Query>(GET_ZONE_LIST);
+        const result = await adminClient.query<GetZones.Query>(GET_ZONE_LIST);
         expect(result.zones.length).toBe(5);
         zones = result.zones;
         oceania = zones[0];
     });
 
     it('zone', async () => {
-        const result = await client.query<GetZone.Query, GetZone.Variables>(GET_ZONE, {
+        const result = await adminClient.query<GetZone.Query, GetZone.Variables>(GET_ZONE, {
             id: oceania.id,
         });
 
@@ -57,7 +60,7 @@ describe('Facet resolver', () => {
     });
 
     it('updateZone', async () => {
-        const result = await client.query<UpdateZone.Mutation, UpdateZone.Variables>(UPDATE_ZONE, {
+        const result = await adminClient.query<UpdateZone.Mutation, UpdateZone.Variables>(UPDATE_ZONE, {
             input: {
                 id: oceania.id,
                 name: 'oceania2',
@@ -68,7 +71,7 @@ describe('Facet resolver', () => {
     });
 
     it('createZone', async () => {
-        const result = await client.query<CreateZone.Mutation, CreateZone.Variables>(CREATE_ZONE, {
+        const result = await adminClient.query<CreateZone.Mutation, CreateZone.Variables>(CREATE_ZONE, {
             input: {
                 name: 'Pangaea',
                 memberIds: [countries[0].id, countries[1].id],
@@ -81,7 +84,7 @@ describe('Facet resolver', () => {
     });
 
     it('addMembersToZone', async () => {
-        const result = await client.query<AddMembersToZone.Mutation, AddMembersToZone.Variables>(
+        const result = await adminClient.query<AddMembersToZone.Mutation, AddMembersToZone.Variables>(
             ADD_MEMBERS_TO_ZONE,
             {
                 zoneId: oceania.id,
@@ -94,13 +97,13 @@ describe('Facet resolver', () => {
     });
 
     it('removeMembersFromZone', async () => {
-        const result = await client.query<RemoveMembersFromZone.Mutation, RemoveMembersFromZone.Variables>(
-            REMOVE_MEMBERS_FROM_ZONE,
-            {
-                zoneId: oceania.id,
-                memberIds: [countries[0].id, countries[2].id],
-            },
-        );
+        const result = await adminClient.query<
+            RemoveMembersFromZone.Mutation,
+            RemoveMembersFromZone.Variables
+        >(REMOVE_MEMBERS_FROM_ZONE, {
+            zoneId: oceania.id,
+            memberIds: [countries[0].id, countries[2].id],
+        });
 
         expect(!!result.removeMembersFromZone.members.find(m => m.name === countries[0].name)).toBe(false);
         expect(!!result.removeMembersFromZone.members.find(m => m.name === countries[2].name)).toBe(false);
@@ -109,19 +112,23 @@ describe('Facet resolver', () => {
 
     describe('deletion', () => {
         it('deletes Zone not used in any TaxRate', async () => {
-            const result1 = await client.query<DeleteZone.Mutation, DeleteZone.Variables>(DELETE_ZONE, { id: pangaea.id });
+            const result1 = await adminClient.query<DeleteZone.Mutation, DeleteZone.Variables>(DELETE_ZONE, {
+                id: pangaea.id,
+            });
 
             expect(result1.deleteZone).toEqual({
                 result: DeletionResult.DELETED,
                 message: '',
             });
 
-            const result2 = await client.query<GetZones.Query>(GET_ZONE_LIST);
+            const result2 = await adminClient.query<GetZones.Query>(GET_ZONE_LIST);
             expect(result2.zones.find(c => c.id === pangaea.id)).toBeUndefined();
         });
 
         it('does not delete Zone that is used in one or more TaxRates', async () => {
-            const result1 = await client.query<DeleteZone.Mutation, DeleteZone.Variables>(DELETE_ZONE, { id: oceania.id });
+            const result1 = await adminClient.query<DeleteZone.Mutation, DeleteZone.Variables>(DELETE_ZONE, {
+                id: oceania.id,
+            });
 
             expect(result1.deleteZone).toEqual({
                 result: DeletionResult.NOT_DELETED,
@@ -130,7 +137,7 @@ describe('Facet resolver', () => {
                     'TaxRates: Standard Tax Oceania, Reduced Tax Oceania, Zero Tax Oceania',
             });
 
-            const result2 = await client.query<GetZones.Query>(GET_ZONE_LIST);
+            const result2 = await adminClient.query<GetZones.Query>(GET_ZONE_LIST);
             expect(result2.zones.find(c => c.id === oceania.id)).not.toBeUndefined();
         });
     });

+ 0 - 14
packages/core/mock-data/populate-customers.ts

@@ -1,14 +0,0 @@
-import { VendureConfig } from '../src/config/vendure-config';
-
-import { MockDataService } from './mock-data.service';
-import { SimpleGraphQLClient } from './simple-graphql-client';
-
-/**
- * Creates customers with addresses by making API calls to the Admin API.
- */
-export async function populateCustomers(count: number, config: VendureConfig, logging: boolean = false) {
-    const client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.adminApiPath}`);
-    await client.asSuperAdmin();
-    const mockDataService = new MockDataService(client, logging);
-    await mockDataService.populateCustomers(count);
-}

+ 5 - 2
packages/core/package.json

@@ -18,7 +18,11 @@
   "scripts": {
     "tsc:watch": "tsc -p ./build/tsconfig.build.json --watch",
     "gulp:watch": "gulp -f ./build/gulpfile.ts watch",
-    "build": "rimraf dist && tsc -p ./build/tsconfig.build.json && tsc -p ./build/tsconfig.cli.json && gulp -f ./build/gulpfile.ts build"
+    "build": "rimraf dist && tsc -p ./build/tsconfig.build.json && tsc -p ./build/tsconfig.cli.json && gulp -f ./build/gulpfile.ts build",
+    "watch": "concurrently yarn:tsc:watch yarn:gulp:watch",
+    "lint": "tslint --fix --project ./",
+    "test": "jest --config ./jest.config.js",
+    "e2e": "jest --config ./e2e/config/jest-e2e.json --runInBand"
   },
   "publishConfig": {
     "access": "public"
@@ -87,7 +91,6 @@
     "@types/node-fetch": "^2.5.2",
     "@types/progress": "^2.0.3",
     "@types/prompts": "^2.0.2",
-    "faker": "^4.1.0",
     "gulp": "^4.0.0",
     "mysql": "^2.16.0",
     "node-fetch": "^2.6.0",

+ 0 - 5
packages/core/src/common/types/common-types.ts

@@ -30,11 +30,6 @@ export interface Orderable {
  */
 export type ReadOnlyRequired<T> = { +readonly [K in keyof T]-?: T[K] };
 
-/**
- * Creates a mutable version of a type with readonly properties.
- */
-export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
-
 /**
  * Given an array type e.g. Array<string>, return the inner type e.g. string.
  */

+ 2 - 4
packages/core/src/config/config-helpers.ts

@@ -1,8 +1,6 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
-
 import { defaultConfig } from './default-config';
 import { mergeConfig } from './merge-config';
-import { RuntimeVendureConfig, VendureConfig } from './vendure-config';
+import { PartialVendureConfig, RuntimeVendureConfig } from './vendure-config';
 
 let activeConfig = defaultConfig;
 
@@ -10,7 +8,7 @@ let activeConfig = defaultConfig;
  * Override the default config by merging in the supplied values. Should only be used prior to
  * bootstrapping the app.
  */
-export function setConfig(userConfig: DeepPartial<VendureConfig>): void {
+export function setConfig(userConfig: PartialVendureConfig): void {
     activeConfig = mergeConfig(activeConfig, userConfig);
 }
 

+ 0 - 1
packages/core/src/config/default-config.ts

@@ -23,7 +23,6 @@ import { RuntimeVendureConfig } from './vendure-config';
  * The default configuration settings which are used if not explicitly overridden in the bootstrap() call.
  *
  * @docsCategory configuration
- * @docsPage Configuration
  */
 export const defaultConfig: RuntimeVendureConfig = {
     channelTokenKey: 'vendure-token',

+ 2 - 0
packages/core/src/config/index.ts

@@ -10,6 +10,7 @@ export * from './entity-id-strategy/uuid-id-strategy';
 export * from './order-merge-strategy/order-merge-strategy';
 export * from './logger/vendure-logger';
 export * from './logger/default-logger';
+export * from './logger/noop-logger';
 export * from './payment-method/example-payment-method-config';
 export * from './payment-method/payment-method-handler';
 export * from './promotion/default-promotion-actions';
@@ -21,3 +22,4 @@ export * from './shipping-method/default-shipping-eligibility-checker';
 export * from './shipping-method/shipping-calculator';
 export * from './shipping-method/shipping-eligibility-checker';
 export * from './vendure-config';
+export * from './merge-config';

+ 21 - 7
packages/core/src/config/merge-config.spec.ts

@@ -1,14 +1,28 @@
 import { mergeConfig } from './merge-config';
 
 describe('mergeConfig()', () => {
+    it('creates a new object reference', () => {
+        const nestedObject = { b: 2 };
+        const input: any = {
+            a: nestedObject,
+        };
+
+        const result = mergeConfig(input, { c: 5 } as any);
+        expect(result).toEqual({
+            a: nestedObject,
+            c: 5,
+        });
+        expect(input).not.toBe(result);
+        expect(input.a).not.toBe(result.a);
+    });
     it('merges top-level properties', () => {
         const input: any = {
             a: 1,
             b: 2,
         };
 
-        mergeConfig(input, { b: 3, c: 5 } as any);
-        expect(input).toEqual({
+        const result = mergeConfig(input, { b: 3, c: 5 } as any);
+        expect(result).toEqual({
             a: 1,
             b: 3,
             c: 5,
@@ -21,8 +35,8 @@ describe('mergeConfig()', () => {
             b: { c: 2 },
         };
 
-        mergeConfig(input, { b: { c: 5 } } as any);
-        expect(input).toEqual({
+        const result = mergeConfig(input, { b: { c: 5 } } as any);
+        expect(result).toEqual({
             a: 1,
             b: { c: 5 },
         });
@@ -40,9 +54,9 @@ describe('mergeConfig()', () => {
             class: new Foo(),
         };
 
-        mergeConfig(input, { class: new Bar() } as any);
+        const result = mergeConfig(input, { class: new Bar() } as any);
 
-        expect(input.class instanceof Bar).toBe(true);
-        expect(input.class.name).toBe('bar');
+        expect(result.class instanceof Bar).toBe(true);
+        expect(result.class.name).toBe('bar');
     });
 });

+ 25 - 20
packages/core/src/config/merge-config.ts

@@ -1,37 +1,42 @@
-import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { isClassInstance, isObject } from '@vendure/common/lib/shared-utils';
+import { simpleDeepClone } from '@vendure/common/lib/simple-deep-clone';
 
-import { VendureConfig } from './vendure-config';
+import { PartialVendureConfig, VendureConfig } from './vendure-config';
 
 /**
- * Simple object check.
- * From https://stackoverflow.com/a/34749873/772859
+ * @description
+ * Performs a deep merge of two VendureConfig objects. Unlike `Object.assign()` the `target` object is
+ * not mutated, instead the function returns a new object which is the result of deeply merging the
+ * values of `source` into `target`.
+ *
+ * @example
+ * ```TypeScript
+ * const result = mergeConfig(defaultConfig, {
+ *   assetOptions: {
+ *     uploadMaxFileSize: 5000,
+ *   },
+ * };
+ * ```
+ *
+ * @docsCategory configuration
  */
-function isObject(item: any): item is object {
-    return item && typeof item === 'object' && !Array.isArray(item);
-}
-
-function isClassInstance(item: any): boolean {
-    return isObject(item) && item.constructor.name !== 'Object';
-}
-
-/**
- * Deep merge config objects. Based on the solution from https://stackoverflow.com/a/34749873/772859
- * but modified so that it does not overwrite fields of class instances, rather it overwrites
- * the entire instance.
- */
-export function mergeConfig<T extends VendureConfig>(target: T, source: DeepPartial<VendureConfig>): T {
+export function mergeConfig<T extends VendureConfig>(target: T, source: PartialVendureConfig, depth = 0): T {
     if (!source) {
         return target;
     }
 
+    if (depth === 0) {
+        target = simpleDeepClone(target);
+    }
+
     if (isObject(target) && isObject(source)) {
         for (const key in source) {
             if (isObject((source as any)[key])) {
                 if (!(target as any)[key]) {
-                    Object.assign((target as any), { [key]: {} });
+                    Object.assign(target as any, { [key]: {} });
                 }
                 if (!isClassInstance((source as any)[key])) {
-                    mergeConfig((target as any)[key], (source as any)[key]);
+                    mergeConfig((target as any)[key], (source as any)[key], depth + 1);
                 } else {
                     (target as any)[key] = (source as any)[key];
                 }

+ 14 - 2
packages/core/src/config/vendure-config.ts

@@ -373,7 +373,6 @@ export interface WorkerOptions {
  * [`VendureConfig`](https://github.com/vendure-ecommerce/vendure/blob/master/server/src/config/vendure-config.ts) interface.
  *
  * @docsCategory configuration
- * @docsPage Configuration
  * */
 export interface VendureConfig {
     /**
@@ -531,7 +530,6 @@ export interface VendureConfig {
  * config values have been merged with the {@link defaultConfig} values.
  *
  * @docsCategory configuration
- * @docsPage Configuration
  */
 export interface RuntimeVendureConfig extends Required<VendureConfig> {
     assetOptions: Required<AssetOptions>;
@@ -541,3 +539,17 @@ export interface RuntimeVendureConfig extends Required<VendureConfig> {
     orderOptions: Required<OrderOptions>;
     workerOptions: Required<WorkerOptions>;
 }
+
+type DeepPartialSimple<T> = {
+    [P in keyof T]?:
+        | null
+        | (T[P] extends Array<infer U>
+              ? Array<DeepPartialSimple<U>>
+              : T[P] extends ReadonlyArray<infer X>
+              ? ReadonlyArray<DeepPartialSimple<X>>
+              : T[P] extends Type<any>
+              ? T[P]
+              : DeepPartialSimple<T[P]>);
+};
+
+export type PartialVendureConfig = DeepPartialSimple<VendureConfig>;

+ 0 - 50
packages/core/src/data-import/import-cli.ts

@@ -1,50 +0,0 @@
-import path from 'path';
-
-import { devConfig } from '../../../dev-server/dev-config';
-import { SimpleGraphQLClient } from '../../mock-data/simple-graphql-client';
-import { bootstrap } from '../bootstrap';
-import { setConfig } from '../config/config-helpers';
-import { VendureConfig } from '../config/vendure-config';
-
-// tslint:disable:no-console
-/**
- * A CLI script which imports products from a CSV file.
- */
-if (require.main === module) {
-    // Running from command line
-    const csvFile = process.argv[2];
-    const csvPath = path.join(__dirname, csvFile);
-    getClient()
-        .then(client => importProducts(client, csvPath))
-        .then(
-            () => process.exit(0),
-            err => {
-                console.log(err);
-                process.exit(1);
-            },
-        );
-}
-
-async function getClient() {
-    const config: VendureConfig = {
-        ...devConfig as any,
-        port: 3020,
-        authOptions: {
-            tokenMethod: 'bearer',
-        },
-        plugins: [],
-    };
-    (config.dbConnectionOptions as any).logging = false;
-    setConfig(config);
-    const app = await bootstrap(config);
-    const client = new SimpleGraphQLClient(`http://localhost:${config.port}/${config.adminApiPath}`);
-    client.setChannelToken(devConfig.defaultChannelToken || 'no-default-channel-token');
-    await client.asSuperAdmin();
-    return client;
-}
-
-async function importProducts(client: SimpleGraphQLClient, csvFile: string) {
-    console.log(`loading data from "${csvFile}"`);
-    const result = await client.importProducts(csvFile);
-    console.log(result);
-}

+ 0 - 4
packages/core/typings.d.ts

@@ -1,9 +1,5 @@
 // This file is for any 3rd party JS libs which don't have a corresponding @types/ package.
 
-declare module 'node-libcurl' {
-    export const Curl: any;
-}
-
 declare module 'opn' {
     declare const opn: (path: string) => Promise<any>;
     export default opn;

+ 3 - 1
packages/create/package.json

@@ -12,7 +12,9 @@
   ],
   "scripts": {
     "copy-assets": "rimraf assets && ts-node ./build.ts",
-    "build": "yarn copy-assets && rimraf lib && tsc -p ./tsconfig.build.json"
+    "build": "yarn copy-assets && rimraf lib && tsc -p ./tsconfig.build.json",
+    "watch": "yarn copy-assets && rimraf lib && tsc -p ./tsconfig.build.json -w",
+    "lint": "tslint --fix --project ./"
   },
   "publishConfig": {
     "access": "public"

+ 1 - 0
packages/dev-server/.gitignore

@@ -6,4 +6,5 @@ vendure-import-error.log
 load-testing/data-sources/products*.csv
 load-testing/results/*.json
 load-testing/results/*.csv
+load-testing/static/assets
 dev-config-override.ts

+ 0 - 1
packages/dev-server/load-testing/graphql/shop/deep-query.graphql

@@ -44,7 +44,6 @@
                     value
                 }
                 code
-                description
             }
             featuredAsset {
                 id

+ 14 - 5
packages/dev-server/load-testing/init-load-test.ts

@@ -3,15 +3,19 @@
 import { bootstrap } from '@vendure/core';
 import { populate } from '@vendure/core/cli/populate';
 import { BaseProductRecord } from '@vendure/core/dist/data-import/providers/import-parser/import-parser';
+import { clearAllTables, populateCustomers } from '@vendure/testing';
 import stringify from 'csv-stringify';
 import fs from 'fs';
 import path from 'path';
 
-import { clearAllTables } from '../../core/mock-data/clear-all-tables';
 import { initialData } from '../../core/mock-data/data-sources/initial-data';
-import { populateCustomers } from '../../core/mock-data/populate-customers';
 
-import { getLoadTestConfig, getMysqlConnectionOptions, getProductCount, getProductCsvFilePath } from './load-test-config';
+import {
+    getLoadTestConfig,
+    getMysqlConnectionOptions,
+    getProductCount,
+    getProductCsvFilePath,
+} from './load-test-config';
 
 // tslint:disable:no-console
 
@@ -41,7 +45,7 @@ if (require.main === module) {
                     )
                     .then(async app => {
                         console.log('populating customers...');
-                        await populateCustomers(10, config as any, true);
+                        await populateCustomers(10, config, true);
                         return app.close();
                     });
             } else {
@@ -170,7 +174,12 @@ function generateMockData(productCount: number, writeFn: (row: string[]) => void
 }
 
 function getCategoryNames() {
-    const allNames = initialData.collections.reduce((all, c) => [...all, ...c.facetNames], [] as string[]);
+    const allNames = new Set<string>();
+    for (const collection of initialData.collections) {
+        for (const filter of collection.filters || []) {
+            filter.args.facetValueNames.forEach(name => allNames.add(name));
+        }
+    }
     return Array.from(new Set(allNames));
 }
 

+ 29 - 8
packages/dev-server/load-testing/load-test-config.ts

@@ -1,12 +1,19 @@
 /* tslint:disable:no-console */
-import { VendureConfig } from '@vendure/core';
+import { AssetServerPlugin } from '@vendure/asset-server-plugin';
+import {
+    DefaultLogger,
+    DefaultSearchPlugin,
+    examplePaymentHandler,
+    LogLevel,
+    mergeConfig,
+    VendureConfig,
+} from '@vendure/core';
+import { defaultConfig } from '@vendure/core/dist/config/default-config';
 import path from 'path';
 
-import { devConfig } from '../dev-config';
-
 export function getMysqlConnectionOptions(count: number) {
     return {
-        type: 'mysql',
+        type: 'mysql' as const,
         host: '192.168.99.100',
         port: 3306,
         username: 'root',
@@ -15,10 +22,13 @@ export function getMysqlConnectionOptions(count: number) {
     };
 }
 
-export function getLoadTestConfig(tokenMethod: 'cookie' | 'bearer'): VendureConfig {
+export function getLoadTestConfig(tokenMethod: 'cookie' | 'bearer'): Required<VendureConfig> {
     const count = getProductCount();
-    return {
-        ...devConfig as any,
+    return mergeConfig(defaultConfig, {
+        paymentOptions: {
+            paymentMethodHandlers: [examplePaymentHandler],
+        },
+        logger: new DefaultLogger({ level: LogLevel.Info }),
         dbConnectionOptions: getMysqlConnectionOptions(count),
         authOptions: {
             tokenMethod,
@@ -27,8 +37,19 @@ export function getLoadTestConfig(tokenMethod: 'cookie' | 'bearer'): VendureConf
         importExportOptions: {
             importAssetsDir: path.join(__dirname, './data-sources'),
         },
+        workerOptions: {
+            runInMainProcess: true,
+        },
         customFields: {},
-    };
+        plugins: [
+            AssetServerPlugin.init({
+                assetUploadDir: path.join(__dirname, 'static/assets'),
+                route: 'assets',
+                port: 5002,
+            }),
+            DefaultSearchPlugin,
+        ],
+    });
 }
 
 export function getProductCsvFilePath() {

+ 0 - 0
packages/dev-server/load-testing/static/.gitkeep


+ 1 - 0
packages/dev-server/package.json

@@ -24,6 +24,7 @@
   },
   "devDependencies": {
     "@types/csv-stringify": "^3.1.0",
+    "@vendure/testing": "0.5.0",
     "concurrently": "^5.0.0",
     "csv-stringify": "^5.3.3"
   }

+ 21 - 19
packages/dev-server/populate-dev-server.ts

@@ -1,12 +1,12 @@
 // tslint:disable-next-line:no-reference
 /// <reference path="../core/typings.d.ts" />
-import { bootstrap, VendureConfig } from '@vendure/core';
+import { bootstrap, mergeConfig, VendureConfig } from '@vendure/core';
 import { populate } from '@vendure/core/cli/populate';
+import { defaultConfig } from '@vendure/core/dist/config/default-config';
+import { clearAllTables, populateCustomers } from '@vendure/testing';
 import path from 'path';
 
-import { clearAllTables } from '../core/mock-data/clear-all-tables';
 import { initialData } from '../core/mock-data/data-sources/initial-data';
-import { populateCustomers } from '../core/mock-data/populate-customers';
 
 import { devConfig } from './dev-config';
 
@@ -17,21 +17,23 @@ import { devConfig } from './dev-config';
  */
 if (require.main === module) {
     // Running from command line
-    const populateConfig: VendureConfig = {
-        ...(devConfig as any),
-        authOptions: {
-            tokenMethod: 'bearer',
-            requireVerification: false,
-        },
-        importExportOptions: {
-            importAssetsDir: path.join(__dirname, '../core/mock-data/assets'),
-        },
-        workerOptions: {
-            runInMainProcess: true,
-        },
-        customFields: {},
-    };
-    clearAllTables(populateConfig as any, true)
+    const populateConfig = mergeConfig(
+        defaultConfig,
+        mergeConfig(devConfig, {
+            authOptions: {
+                tokenMethod: 'bearer',
+                requireVerification: false,
+            },
+            importExportOptions: {
+                importAssetsDir: path.join(__dirname, '../core/mock-data/assets'),
+            },
+            workerOptions: {
+                runInMainProcess: true,
+            },
+            customFields: {},
+        }),
+    );
+    clearAllTables(populateConfig, true)
         .then(() =>
             populate(
                 () => bootstrap(populateConfig),
@@ -41,7 +43,7 @@ if (require.main === module) {
         )
         .then(async app => {
             console.log('populating customers...');
-            await populateCustomers(10, populateConfig as any, true);
+            await populateCustomers(10, populateConfig, true);
             return app.close();
         })
         .then(

+ 3 - 1
packages/elasticsearch-plugin/package.json

@@ -9,7 +9,9 @@
   ],
   "scripts": {
     "watch": "tsc -p ./tsconfig.build.json --watch",
-    "build": "rimraf lib && tsc -p ./tsconfig.build.json"
+    "build": "rimraf lib && tsc -p ./tsconfig.build.json",
+    "lint": "tslint --fix --project ./",
+    "test": "jest --config ./jest.config.js"
   },
   "publishConfig": {
     "access": "public"

+ 3 - 1
packages/email-plugin/package.json

@@ -11,7 +11,9 @@
   ],
   "scripts": {
     "watch": "tsc -p ./tsconfig.build.json --watch",
-    "build": "rimraf lib && tsc -p ./tsconfig.build.json"
+    "build": "rimraf lib && tsc -p ./tsconfig.build.json",
+    "lint": "tslint --fix --project ./",
+    "test": "jest --config ./jest.config.js"
   },
   "publishConfig": {
     "access": "public"

+ 1 - 0
packages/testing/.gitignore

@@ -0,0 +1 @@
+lib

+ 5 - 0
packages/testing/README.md

@@ -0,0 +1,5 @@
+# @vendure/testing
+
+This package contains utilities for writing end-to-end tests for Vendure.
+
+

+ 47 - 0
packages/testing/package.json

@@ -0,0 +1,47 @@
+{
+  "name": "@vendure/testing",
+  "version": "0.5.0",
+  "description": "End-to-end testing tools for Vendure projects",
+  "keywords": [
+    "vendure",
+    "testing",
+    "e2e"
+  ],
+  "author": "Michael Bromley <michael@michaelbromley.co.uk>",
+  "homepage": "https://github.com/vendure-ecommerce/vendure#readme",
+  "license": "MIT",
+  "directories": {
+    "lib": "lib"
+  },
+  "files": [
+    "lib"
+  ],
+  "main": "lib/index.js",
+  "types": "lib/index.d.ts",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vendure-ecommerce/vendure.git"
+  },
+  "scripts": {
+    "build": "tsc -p ./tsconfig.build.json",
+    "watch": "tsc -p ./tsconfig.build.json -w",
+    "lint": "tslint --fix --project ./"
+  },
+  "bugs": {
+    "url": "https://github.com/vendure-ecommerce/vendure/issues"
+  },
+  "dependencies": {
+    "@vendure/common": "^0.5.0",
+    "faker": "^4.1.0",
+    "graphql": "^14.5.8",
+    "graphql-tag": "^2.10.1",
+    "node-fetch": "^2.6.0",
+    "node-libcurl": "^2.0.2",
+    "sql.js": "^1.0.0"
+  },
+  "devDependencies": {
+    "@vendure/core": "^0.5.0",
+    "rimraf": "^3.0.0",
+    "typescript": "^3.6.4"
+  }
+}

+ 63 - 0
packages/testing/src/config/test-config.ts

@@ -0,0 +1,63 @@
+import { Transport } from '@nestjs/microservices';
+import { ADMIN_API_PATH, SHOP_API_PATH } from '@vendure/common/lib/shared-constants';
+import { DefaultAssetNamingStrategy, NoopLogger, VendureConfig } from '@vendure/core';
+import { defaultConfig } from '@vendure/core/dist/config/default-config';
+import { mergeConfig } from '@vendure/core/dist/config/merge-config';
+import path from 'path';
+
+import { TestingAssetPreviewStrategy } from './testing-asset-preview-strategy';
+import { TestingAssetStorageStrategy } from './testing-asset-storage-strategy';
+import { TestingEntityIdStrategy } from './testing-entity-id-strategy';
+
+/**
+ * @description
+ * A {@link VendureConfig} object used for e2e tests. This configuration uses sqljs as the database
+ * and configures some special settings which are optimized for e2e tests:
+ *
+ * * `entityIdStrategy: new TestingEntityIdStrategy()` This ID strategy uses auto-increment IDs but encodes all IDs
+ * to be prepended with the string `'T_'`, so ID `1` becomes `'T_1'`.
+ * * `logger: new NoopLogger()` Do no output logs by default
+ * * `assetStorageStrategy: new TestingAssetStorageStrategy()` This strategy does not actually persist any binary data to disk.
+ * * `assetPreviewStrategy: new TestingAssetPreviewStrategy()` This strategy is a no-op.
+ *
+ * @docsCategory testing
+ */
+export const testConfig: Required<VendureConfig> = mergeConfig(defaultConfig, {
+    port: 3050,
+    adminApiPath: ADMIN_API_PATH,
+    shopApiPath: SHOP_API_PATH,
+    cors: true,
+    defaultChannelToken: 'e2e-default-channel',
+    authOptions: {
+        sessionSecret: 'some-secret',
+        tokenMethod: 'bearer',
+        requireVerification: true,
+    },
+    dbConnectionOptions: {
+        type: 'sqljs',
+        database: new Uint8Array([]),
+        location: '',
+        autoSave: false,
+        logging: false,
+    },
+    promotionOptions: {},
+    customFields: {},
+    entityIdStrategy: new TestingEntityIdStrategy(),
+    paymentOptions: {
+        paymentMethodHandlers: [],
+    },
+    logger: new NoopLogger(),
+    importExportOptions: {},
+    assetOptions: {
+        assetNamingStrategy: new DefaultAssetNamingStrategy(),
+        assetStorageStrategy: new TestingAssetStorageStrategy(),
+        assetPreviewStrategy: new TestingAssetPreviewStrategy(),
+    },
+    workerOptions: {
+        runInMainProcess: true,
+        transport: Transport.TCP,
+        options: {
+            port: 3051,
+        },
+    },
+});

+ 1 - 1
packages/core/e2e/config/testing-asset-preview-strategy.ts → packages/testing/src/config/testing-asset-preview-strategy.ts

@@ -1,4 +1,4 @@
-import { AssetPreviewStrategy } from '../../src/config/asset-preview-strategy/asset-preview-strategy';
+import { AssetPreviewStrategy } from '@vendure/core';
 
 const TEST_IMAGE_BASE_64 =
     // tslint:disable-next-line:max-line-length

+ 1 - 2
packages/core/e2e/config/testing-asset-storage-strategy.ts → packages/testing/src/config/testing-asset-storage-strategy.ts

@@ -1,8 +1,7 @@
+import { AssetStorageStrategy } from '@vendure/core';
 import { Request } from 'express';
 import { Readable, Stream, Writable } from 'stream';
 
-import { AssetStorageStrategy } from '../../src/config/asset-storage-strategy/asset-storage-strategy';
-
 import { getTestImageBuffer } from './testing-asset-preview-strategy';
 
 /**

+ 1 - 1
packages/core/e2e/config/testing-entity-id-strategy.ts → packages/testing/src/config/testing-entity-id-strategy.ts

@@ -1,4 +1,4 @@
-import { IntegerIdStrategy } from '../../src/config/entity-id-strategy/entity-id-strategy';
+import { IntegerIdStrategy } from '@vendure/core';
 
 /**
  * A testing entity id strategy which prefixes all IDs with a constant string. This is used in the

+ 75 - 0
packages/testing/src/create-test-environment.ts

@@ -0,0 +1,75 @@
+import { VendureConfig } from '@vendure/core';
+
+import { SimpleGraphQLClient } from './simple-graphql-client';
+import { TestServer } from './test-server';
+
+/**
+ * @description
+ * The return value of {@link createTestEnvironment}, containing the test server
+ * and clients for the Shop API and Admin API.
+ *
+ * @docsCategory testing
+ */
+export interface TestEnvironment {
+    /**
+     * @description
+     * A Vendure server instance against which GraphQL requests can be made.
+     */
+    server: TestServer;
+    /**
+     * @description
+     * A GraphQL client configured for the Admin API.
+     */
+    adminClient: SimpleGraphQLClient;
+    /**
+     * @description
+     * A GraphQL client configured for the Shop API.
+     */
+    shopClient: SimpleGraphQLClient;
+}
+
+/**
+ * @description
+ * Configures a {@link TestServer} and a {@link SimpleGraphQLClient} for each of the GraphQL APIs
+ * for use in end-to-end tests. Returns a {@link TestEnvironment} object.
+ *
+ * @example
+ * ```TypeScript
+ * import { createTestEnvironment, testConfig } from '\@vendure/testing';
+ *
+ * describe('some feature to test', () => {
+ *
+ *   const { server, adminClient, shopClient } = createTestEnvironment(testConfig);
+ *
+ *   beforeAll(async () => {
+ *     await server.init({
+ *         // ... server options
+ *     });
+ *     await adminClient.asSuperAdmin();
+ *   });
+ *
+ *   afterAll(async () => {
+ *       await server.destroy();
+ *   });
+ *
+ *   // ... end-to-end tests here
+ * });
+ * ```
+ * @docsCategory testing
+ */
+export function createTestEnvironment(config: Required<VendureConfig>): TestEnvironment {
+    const server = new TestServer(config);
+    const adminClient = new SimpleGraphQLClient(
+        config,
+        `http://localhost:${config.port}/${config.adminApiPath}`,
+    );
+    const shopClient = new SimpleGraphQLClient(
+        config,
+        `http://localhost:${config.port}/${config.shopApiPath}`,
+    );
+    return {
+        server,
+        adminClient,
+        shopClient,
+    };
+}

+ 4 - 8
packages/core/mock-data/clear-all-tables.ts → packages/testing/src/data-population/clear-all-tables.ts

@@ -1,10 +1,7 @@
+import { VendureConfig } from '@vendure/core';
+import { preBootstrapConfig } from '@vendure/core/dist/bootstrap';
 import { createConnection } from 'typeorm';
 
-import { isTestEnvironment } from '../e2e/utils/test-environment';
-import { preBootstrapConfig } from '../src/bootstrap';
-import { defaultConfig } from '../src/config/default-config';
-import { VendureConfig } from '../src/config/vendure-config';
-
 // tslint:disable:no-console
 // tslint:disable:no-floating-promises
 /**
@@ -12,9 +9,8 @@ import { VendureConfig } from '../src/config/vendure-config';
  */
 export async function clearAllTables(config: VendureConfig, logging = true) {
     config = await preBootstrapConfig(config);
-    const entityIdStrategy = config.entityIdStrategy || defaultConfig.entityIdStrategy;
-    const name = isTestEnvironment() ? undefined : 'clearAllTables';
-    const connection = await createConnection({ ...config.dbConnectionOptions, name });
+    const entityIdStrategy = config.entityIdStrategy;
+    const connection = await createConnection({ ...config.dbConnectionOptions });
     if (logging) {
         console.log('Clearing all tables...');
     }

+ 3 - 5
packages/core/mock-data/mock-data.service.ts → packages/testing/src/data-population/mock-data.service.ts

@@ -1,9 +1,7 @@
 import faker from 'faker/locale/en_GB';
 import gql from 'graphql-tag';
 
-import { CreateAddressInput, CreateCustomerInput } from '../e2e/graphql/generated-e2e-admin-types';
-
-import { SimpleGraphQLClient } from './simple-graphql-client';
+import { SimpleGraphQLClient } from '../simple-graphql-client';
 
 // tslint:disable:no-console
 /**
@@ -37,7 +35,7 @@ export class MockDataService {
                     lastName,
                     emailAddress: faker.internet.email(firstName, lastName),
                     phoneNumber: faker.phone.phoneNumber(),
-                } as CreateCustomerInput,
+                },
                 password: 'test',
             };
 
@@ -63,7 +61,7 @@ export class MockDataService {
                         province: faker.address.county(),
                         postalCode: faker.address.zipCode(),
                         countryCode: 'GB',
-                    } as CreateAddressInput,
+                    },
                     customerId: customer.id,
                 };
 

+ 23 - 0
packages/testing/src/data-population/populate-customers.ts

@@ -0,0 +1,23 @@
+import { VendureConfig } from '@vendure/core';
+
+import { SimpleGraphQLClient } from '../simple-graphql-client';
+
+import { MockDataService } from './mock-data.service';
+
+/**
+ * Creates customers with addresses by making API calls to the Admin API.
+ */
+export async function populateCustomers(
+    count: number,
+    config: Required<VendureConfig>,
+    logging: boolean = false,
+    simpleGraphQLClient = new SimpleGraphQLClient(
+        config,
+        `http://localhost:${config.port}/${config.adminApiPath}`,
+    ),
+) {
+    const client = simpleGraphQLClient;
+    await client.asSuperAdmin();
+    const mockDataService = new MockDataService(client, logging);
+    await mockDataService.populateCustomers(count);
+}

+ 12 - 22
packages/core/mock-data/populate-for-testing.ts → packages/testing/src/data-population/populate-for-testing.ts

@@ -1,51 +1,42 @@
 /* tslint:disable:no-console */
 import { INestApplication, INestMicroservice } from '@nestjs/common';
 import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { InitialData, VendureConfig } from '@vendure/core';
 import fs from 'fs-extra';
 
-import { setConfig } from '../src/config/config-helpers';
-import { VendureConfig } from '../src/config/vendure-config';
+import { TestServerOptions } from '../types';
 
 import { clearAllTables } from './clear-all-tables';
 import { populateCustomers } from './populate-customers';
 
-export interface PopulateOptions {
-    logging?: boolean;
-    customerCount: number;
-    productsCsvPath: string;
-    initialDataPath: string;
-}
-
 // tslint:disable:no-floating-promises
 /**
  * Clears all tables from the database and populates with (deterministic) random data.
  */
 export async function populateForTesting(
-    config: VendureConfig,
+    config: Required<VendureConfig>,
     bootstrapFn: (config: VendureConfig) => Promise<[INestApplication, INestMicroservice | undefined]>,
-    options: PopulateOptions,
+    options: TestServerOptions,
 ): Promise<[INestApplication, INestMicroservice | undefined]> {
     (config.dbConnectionOptions as any).logging = false;
     const logging = options.logging === undefined ? true : options.logging;
     const originalRequireVerification = config.authOptions.requireVerification;
     config.authOptions.requireVerification = false;
 
-    setConfig(config);
     await clearAllTables(config, logging);
     const [app, worker] = await bootstrapFn(config);
 
-    await populateInitialData(app, options.initialDataPath, logging);
+    await populateInitialData(app, options.initialData, logging);
     await populateProducts(app, options.productsCsvPath, logging);
-    await populateCollections(app, options.initialDataPath, logging);
-    await populateCustomers(options.customerCount, config, logging);
+    await populateCollections(app, options.initialData, logging);
+    await populateCustomers(options.customerCount || 10, config, logging);
 
     config.authOptions.requireVerification = originalRequireVerification;
     return [app, worker];
 }
 
-async function populateInitialData(app: INestApplication, initialDataPath: string, logging: boolean) {
-    const { Populator } = await import('../src/data-import/providers/populator/populator');
-    const { initialData } = await import(initialDataPath);
+async function populateInitialData(app: INestApplication, initialData: InitialData, logging: boolean) {
+    const { Populator } = await import('@vendure/core');
     const populator = app.get(Populator);
     try {
         await populator.populateInitialData(initialData);
@@ -57,9 +48,8 @@ async function populateInitialData(app: INestApplication, initialDataPath: strin
     }
 }
 
-async function populateCollections(app: INestApplication, initialDataPath: string, logging: boolean) {
-    const { Populator } = await import('../src/data-import/providers/populator/populator');
-    const { initialData } = await import(initialDataPath);
+async function populateCollections(app: INestApplication, initialData: InitialData, logging: boolean) {
+    const { Populator } = await import('@vendure/core');
     if (!initialData.collections.length) {
         return;
     }
@@ -75,7 +65,7 @@ async function populateCollections(app: INestApplication, initialDataPath: strin
 }
 
 async function populateProducts(app: INestApplication, productsCsvPath: string, logging: boolean) {
-    const { Importer } = await import('../src/data-import/providers/importer/importer');
+    const { Importer } = await import('@vendure/core');
     const importer = app.get(Importer);
     const productData = await fs.readFile(productsCsvPath, 'utf-8');
 

+ 6 - 0
packages/testing/src/index.ts

@@ -0,0 +1,6 @@
+export * from './simple-graphql-client';
+export * from './test-server';
+export * from './config/test-config';
+export * from './create-test-environment';
+export * from './data-population/clear-all-tables';
+export * from './data-population/populate-customers';

+ 37 - 26
packages/core/mock-data/simple-graphql-client.ts → packages/testing/src/simple-graphql-client.ts

@@ -1,5 +1,5 @@
-/// <reference types="../typings" />
 import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from '@vendure/common/lib/shared-constants';
+import { VendureConfig } from '@vendure/core';
 import { DocumentNode } from 'graphql';
 import gql from 'graphql-tag';
 import { print } from 'graphql/language/printer';
@@ -7,10 +7,7 @@ import fetch, { Response } from 'node-fetch';
 import { Curl } from 'node-libcurl';
 import { stringify } from 'querystring';
 
-import { ImportInfo } from '../e2e/graphql/generated-e2e-admin-types';
-import { getConfig } from '../src/config/config-helpers';
-
-import { createUploadPostData } from './create-upload-post-data';
+import { createUploadPostData } from './utils/create-upload-post-data';
 
 const LOGIN = gql`
     mutation($username: String!, $password: String!) {
@@ -30,29 +27,42 @@ export type QueryParams = { [key: string]: string | number };
 
 // tslint:disable:no-console
 /**
+ * @description
  * A minimalistic GraphQL client for populating and querying test data.
+ *
+ * @docsCategory testing
  */
 export class SimpleGraphQLClient {
     private authToken: string;
     private channelToken: string;
     private headers: { [key: string]: any } = {};
 
-    constructor(private apiUrl: string = '') {}
+    constructor(private vendureConfig: Required<VendureConfig>, private apiUrl: string = '') {}
 
+    /**
+     * @description
+     * Sets the authToken to be used in each GraphQL request.
+     */
     setAuthToken(token: string) {
         this.authToken = token;
         this.headers.Authorization = `Bearer ${this.authToken}`;
     }
 
+    /**
+     * @description
+     * Returns the authToken currently being used.
+     */
     getAuthToken(): string {
         return this.authToken;
     }
 
+    /** @internal */
     setChannelToken(token: string) {
-        this.headers[getConfig().channelTokenKey] = token;
+        this.headers[this.vendureConfig.channelTokenKey] = token;
     }
 
     /**
+     * @description
      * Performs both query and mutation operations.
      */
     async query<T = any, V = Record<string, any>>(
@@ -74,27 +84,19 @@ export class SimpleGraphQLClient {
         }
     }
 
+    /**
+     * @description
+     * Performs a query or mutation and returns the resulting status code.
+     */
     async queryStatus<T = any, V = Record<string, any>>(query: DocumentNode, variables?: V): Promise<number> {
         const response = await this.request(query, variables);
         return response.status;
     }
 
-    importProducts(csvFilePath: string): Promise<{ importProducts: ImportInfo }> {
-        return this.fileUploadMutation({
-            mutation: gql`
-                mutation ImportProducts($csvFile: Upload!) {
-                    importProducts(csvFile: $csvFile) {
-                        imported
-                        processed
-                        errors
-                    }
-                }
-            `,
-            filePaths: [csvFilePath],
-            mapVariables: () => ({ csvFile: null }),
-        });
-    }
-
+    /**
+     * @description
+     * Attemps to log in with the specified credentials.
+     */
     async asUserWithCredentials(username: string, password: string) {
         // first log out as the current user
         if (this.authToken) {
@@ -110,10 +112,18 @@ export class SimpleGraphQLClient {
         return result.login;
     }
 
+    /**
+     * @description
+     * Logs in as the SuperAdmin user.
+     */
     async asSuperAdmin() {
         await this.asUserWithCredentials(SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD);
     }
 
+    /**
+     * @description
+     * Logs out so that the client is then treated as an anonymous user.
+     */
     async asAnonymousUser() {
         await this.query(
             gql`
@@ -142,7 +152,7 @@ export class SimpleGraphQLClient {
             headers: { 'Content-Type': 'application/json', ...this.headers },
             body,
         });
-        const authToken = response.headers.get(getConfig().authOptions.authTokenHeaderKey || '');
+        const authToken = response.headers.get(this.vendureConfig.authOptions.authTokenHeaderKey || '');
         if (authToken != null) {
             this.setAuthToken(authToken);
         }
@@ -159,13 +169,14 @@ export class SimpleGraphQLClient {
     }
 
     /**
+     * @description
      * Uses curl to post a multipart/form-data request to the server. Due to differences between the Node and browser
      * environments, we cannot just use an existing library like apollo-upload-client.
      *
      * Upload spec: https://github.com/jaydenseric/graphql-multipart-request-spec
      * Discussion of issue: https://github.com/jaydenseric/apollo-upload-client/issues/32
      */
-    private fileUploadMutation(options: {
+    fileUploadMutation(options: {
         mutation: DocumentNode;
         filePaths: string[];
         mapVariables: (filePaths: string[]) => any;
@@ -197,7 +208,7 @@ export class SimpleGraphQLClient {
             curl.setOpt(Curl.option.HTTPPOST, processedPostData);
             curl.setOpt(Curl.option.HTTPHEADER, [
                 `Authorization: Bearer ${this.authToken}`,
-                `${getConfig().channelTokenKey}: ${this.channelToken}`,
+                `${this.vendureConfig.channelTokenKey}: ${this.channelToken}`,
             ]);
             curl.perform();
             curl.on('end', (statusCode: any, body: any) => {

+ 53 - 38
packages/core/e2e/test-server.ts → packages/testing/src/test-server.ts

@@ -1,54 +1,48 @@
 import { INestApplication, INestMicroservice } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
-import { Omit } from '@vendure/common/lib/omit';
+import { DefaultLogger, Logger, VendureConfig } from '@vendure/core';
+import { preBootstrapConfig } from '@vendure/core/dist/bootstrap';
 import fs from 'fs';
 import path from 'path';
-import { ConnectionOptions } from 'typeorm';
 import { SqljsConnectionOptions } from 'typeorm/driver/sqljs/SqljsConnectionOptions';
 
-import { populateForTesting, PopulateOptions } from '../mock-data/populate-for-testing';
-import { preBootstrapConfig } from '../src/bootstrap';
-import { Mutable } from '../src/common/types/common-types';
-import { DefaultLogger } from '../src/config/logger/default-logger';
-import { Logger } from '../src/config/logger/vendure-logger';
-import { VendureConfig } from '../src/config/vendure-config';
-
-import { testConfig } from './config/test-config';
-import { setTestEnvironment } from './utils/test-environment';
+import { populateForTesting } from './data-population/populate-for-testing';
+import { Mutable, TestServerOptions } from './types';
 
 // tslint:disable:no-console
 /**
- * A server against which the e2e tests should be run.
+ * @description
+ * A real Vendure server against which the e2e tests should be run.
+ *
+ * @docsCategory testing
  */
 export class TestServer {
-    app: INestApplication;
-    worker?: INestMicroservice;
+    private app: INestApplication;
+    private worker?: INestMicroservice;
+
+    constructor(private vendureConfig: Required<VendureConfig>) {}
 
     /**
+     * @description
      * Bootstraps an instance of Vendure server and populates the database according to the options
-     * passed in. Should be called immediately after creating the client in the `beforeAll` function.
+     * passed in. Should be called in the `beforeAll` function.
      *
      * The populated data is saved into an .sqlite file for each test file. On subsequent runs, this file
      * is loaded so that the populate step can be skipped, which speeds up the tests significantly.
      */
-    async init(
-        options: Omit<PopulateOptions, 'initialDataPath'>,
-        customConfig: Partial<VendureConfig> = {},
-    ): Promise<void> {
-        setTestEnvironment();
-        const testingConfig = { ...testConfig, ...customConfig };
-        const dbFilePath = this.getDbFilePath();
-        (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).location = dbFilePath;
+    async init(options: TestServerOptions): Promise<void> {
+        const dbFilePath = this.getDbFilePath(options.dataDir);
+        (this.vendureConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).location = dbFilePath;
         if (!fs.existsSync(dbFilePath)) {
             if (options.logging) {
                 console.log(`Test data not found. Populating database and caching...`);
             }
-            await this.populateInitialData(testingConfig, options);
+            await this.populateInitialData(this.vendureConfig, options);
         }
         if (options.logging) {
             console.log(`Loading test data from "${dbFilePath}"`);
         }
-        const [app, worker] = await this.bootstrapForTesting(testingConfig);
+        const [app, worker] = await this.bootstrapForTesting(this.vendureConfig);
         if (app) {
             this.app = app;
         } else {
@@ -61,7 +55,9 @@ export class TestServer {
     }
 
     /**
-     * Destroy the Vendure instance. Should be called in the `afterAll` function.
+     * @description
+     * Destroy the Vendure server instance and clean up all resources.
+     * Should be called after all tests have run, e.g. in an `afterAll` function.
      */
     async destroy() {
         // allow a grace period of any outstanding async tasks to complete
@@ -72,30 +68,49 @@ export class TestServer {
         }
     }
 
-    private getDbFilePath() {
-        const dbDataDir = '__data__';
+    private getDbFilePath(dataDir: string) {
         // tslint:disable-next-line:no-non-null-assertion
-        const testFilePath = module!.parent!.filename;
+        const testFilePath = this.getCallerFilename(2);
         const dbFileName = path.basename(testFilePath) + '.sqlite';
-        const dbFilePath = path.join(path.dirname(testFilePath), dbDataDir, dbFileName);
+        const dbFilePath = path.join(dataDir, dbFileName);
         return dbFilePath;
     }
 
+    private getCallerFilename(depth: number) {
+        let pst: ErrorConstructor['prepareStackTrace'];
+        let stack: any;
+        let file: any;
+        let frame: any;
+
+        pst = Error.prepareStackTrace;
+        Error.prepareStackTrace = (_, _stack) => {
+            Error.prepareStackTrace = pst;
+            return _stack;
+        };
+
+        stack = new Error().stack;
+        stack = stack.slice(depth + 1);
+
+        do {
+            frame = stack.shift();
+            file = frame && frame.getFileName();
+        } while (stack.length && file === 'module.js');
+
+        return file;
+    }
+
     /**
      * Populates an .sqlite database file based on the PopulateOptions.
      */
     private async populateInitialData(
-        testingConfig: VendureConfig,
-        options: Omit<PopulateOptions, 'initialDataPath'>,
+        testingConfig: Required<VendureConfig>,
+        options: TestServerOptions,
     ): Promise<void> {
         (testingConfig.dbConnectionOptions as Mutable<SqljsConnectionOptions>).autoSave = true;
 
         const [app, worker] = await populateForTesting(testingConfig, this.bootstrapForTesting, {
             logging: false,
-            ...{
-                ...options,
-                initialDataPath: path.join(__dirname, 'fixtures/e2e-initial-data.ts'),
-            },
+            ...options,
         });
         await app.close();
         if (worker) {
@@ -113,14 +128,14 @@ export class TestServer {
     ): Promise<[INestApplication, INestMicroservice | undefined]> {
         const config = await preBootstrapConfig(userConfig);
         Logger.useLogger(config.logger);
-        const appModule = await import('../src/app.module');
+        const appModule = await import('@vendure/core/dist/app.module');
         try {
             DefaultLogger.hideNestBoostrapLogs();
             const app = await NestFactory.create(appModule.AppModule, { cors: config.cors, logger: false });
             let worker: INestMicroservice | undefined;
             await app.listen(config.port);
             if (config.workerOptions.runInMainProcess) {
-                const workerModule = await import('../src/worker/worker.module');
+                const workerModule = await import('@vendure/core/dist/worker/worker.module');
                 worker = await NestFactory.createMicroservice(workerModule.WorkerModule, {
                     transport: config.workerOptions.transport,
                     logger: new Logger(),

+ 45 - 0
packages/testing/src/types.ts

@@ -0,0 +1,45 @@
+import { InitialData } from '@vendure/core';
+
+/**
+ * Creates a mutable version of a type with readonly properties.
+ */
+export type Mutable<T> = { -readonly [K in keyof T]: T[K] };
+
+/**
+ * @description
+ * Configuration options used to initialize an instance of the {@link TestServer}.
+ *
+ * @docsCategory testing
+ */
+export interface TestServerOptions {
+    /**
+     * @description
+     * The directory in which the populated SQLite database files will be
+     * saved. These files are a cache to speed up subsequent runs of e2e tests.
+     */
+    dataDir: string;
+    /**
+     * @description
+     * The path to a CSV file containing product data to import.
+     */
+    productsCsvPath: string;
+    /**
+     * @description
+     * An object containing non-product data which is used to populate the database.
+     */
+    initialData: InitialData;
+    /**
+     * @description
+     * The number of fake Customers to populate into the database.
+     *
+     * @default 10
+     */
+    customerCount?: number;
+    /**
+     * @description
+     * Set this to `true` to log some information about the database population process.
+     *
+     * @default false
+     */
+    logging?: boolean;
+}

+ 6 - 4
packages/core/mock-data/create-upload-post-data.spec.ts → packages/testing/src/utils/create-upload-post-data.spec.ts

@@ -4,18 +4,20 @@ import { createUploadPostData } from './create-upload-post-data';
 
 describe('createUploadPostData()', () => {
     it('creates correct output for createAssets mutation', () => {
-        const result = createUploadPostData(gql`
+        const result = createUploadPostData(
+            gql`
                 mutation CreateAssets($input: [CreateAssetInput!]!) {
                     createAssets(input: $input) {
                         id
                         name
                     }
-                }`
-            ,
+                }
+            `,
             ['a.jpg', 'b.jpg'],
             filePaths => ({
                 input: filePaths.map(() => ({ file: null })),
-            }));
+            }),
+        );
 
         expect(result.operations.operationName).toBe('CreateAssets');
         expect(result.operations.variables).toEqual({

+ 0 - 0
packages/core/mock-data/create-upload-post-data.ts → packages/testing/src/utils/create-upload-post-data.ts


+ 1 - 2
packages/core/mock-data/get-default-channel-token.ts → packages/testing/src/utils/get-default-channel-token.ts

@@ -1,8 +1,7 @@
 import { DEFAULT_CHANNEL_CODE } from '@vendure/common/lib/shared-constants';
+import { Channel } from '@vendure/core';
 import { ConnectionOptions, getConnection } from 'typeorm';
 
-import { Channel } from '../src/entity/channel/channel.entity';
-
 // tslint:disable:no-console
 // tslint:disable:no-floating-promises
 /**

+ 12 - 0
packages/testing/tsconfig.build.json

@@ -0,0 +1,12 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "outDir": "./lib"
+  },
+  "include": [
+    "src/**/*.ts"
+  ],
+  "exclude": [
+    "**/*.spec.ts"
+  ]
+}

+ 10 - 0
packages/testing/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "removeComments": true,
+    "noLib": false,
+    "skipLibCheck": true,
+    "sourceMap": true
+  }
+}

+ 1 - 0
scripts/docs/generate-typescript-docs.ts

@@ -23,6 +23,7 @@ const sections: DocsSectionConfig[] = [
             'packages/asset-server-plugin/src/',
             'packages/email-plugin/src/',
             'packages/elasticsearch-plugin/src/',
+            'packages/testing/src/',
         ],
         exclude: [
             /generated-shop-types/,

+ 1 - 0
scripts/publish-to-verdaccio.sh

@@ -18,3 +18,4 @@ cd ../core && npm publish -reg $VERDACCIO &&\
 cd ../create && npm publish -reg $VERDACCIO &&\
 cd ../elasticsearch-plugin && npm publish -reg $VERDACCIO &&\
 cd ../email-plugin && npm publish -reg $VERDACCIO
+cd ../testing && npm publish -reg $VERDACCIO

+ 271 - 259
yarn.lock

@@ -931,15 +931,15 @@
     iterall "^1.1.3"
     uuid "^3.1.0"
 
-"@lerna/add@3.16.2":
-  version "3.16.2"
-  resolved "https://registry.npmjs.org/@lerna/add/-/add-3.16.2.tgz#90ecc1be7051cfcec75496ce122f656295bd6e94"
-  integrity sha512-RAAaF8aODPogj2Ge9Wj3uxPFIBGpog9M+HwSuq03ZnkkO831AmasCTJDqV+GEpl1U2DvnhZQEwHpWmTT0uUeEw==
+"@lerna/add@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/add/-/add-3.18.0.tgz#86e38f14d7a0a7c61315dccb402377feb1c9db83"
+  integrity sha512-Z5EaQbBnJn1LEPb0zb0Q2o9T8F8zOnlCsj6JYpY6aSke17UUT7xx0QMN98iBK+ueUHKjN/vdFdYlNCYRSIdujA==
   dependencies:
     "@evocateur/pacote" "^9.6.3"
-    "@lerna/bootstrap" "3.16.2"
-    "@lerna/command" "3.16.0"
-    "@lerna/filter-options" "3.16.0"
+    "@lerna/bootstrap" "3.18.0"
+    "@lerna/command" "3.18.0"
+    "@lerna/filter-options" "3.18.0"
     "@lerna/npm-conf" "3.16.0"
     "@lerna/validation-error" "3.13.0"
     dedent "^0.7.0"
@@ -947,31 +947,22 @@
     p-map "^2.1.0"
     semver "^6.2.0"
 
-"@lerna/batch-packages@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/batch-packages/-/batch-packages-3.16.0.tgz#1c16cb697e7d718177db744cbcbdac4e30253c8c"
-  integrity sha512-7AdMkANpubY/FKFI01im01tlx6ygOBJ/0JcixMUWoWP/7Ds3SWQF22ID6fbBr38jUWptYLDs2fagtTDL7YUPuA==
+"@lerna/bootstrap@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/bootstrap/-/bootstrap-3.18.0.tgz#705d9eb51a24d549518796a09f24d24526ed975b"
+  integrity sha512-3DZKWIaKvr7sUImoKqSz6eqn84SsOVMnA5QHwgzXiQjoeZ/5cg9x2r+Xj3+3w/lvLoh0j8U2GNtrIaPNis4bKQ==
   dependencies:
-    "@lerna/package-graph" "3.16.0"
-    npmlog "^4.1.2"
-
-"@lerna/bootstrap@3.16.2":
-  version "3.16.2"
-  resolved "https://registry.npmjs.org/@lerna/bootstrap/-/bootstrap-3.16.2.tgz#be268d940221d3c3270656b9b791b492559ad9d8"
-  integrity sha512-I+gs7eh6rv9Vyd+CwqL7sftRfOOsSzCle8cv/CGlMN7/p7EAVhxEdAw8SYoHIKHzipXszuqqy1Y3opyleD0qdA==
-  dependencies:
-    "@lerna/batch-packages" "3.16.0"
-    "@lerna/command" "3.16.0"
-    "@lerna/filter-options" "3.16.0"
-    "@lerna/has-npm-version" "3.16.0"
-    "@lerna/npm-install" "3.16.0"
-    "@lerna/package-graph" "3.16.0"
+    "@lerna/command" "3.18.0"
+    "@lerna/filter-options" "3.18.0"
+    "@lerna/has-npm-version" "3.16.5"
+    "@lerna/npm-install" "3.16.5"
+    "@lerna/package-graph" "3.18.0"
     "@lerna/pulse-till-done" "3.13.0"
-    "@lerna/rimraf-dir" "3.14.2"
+    "@lerna/rimraf-dir" "3.16.5"
     "@lerna/run-lifecycle" "3.16.2"
-    "@lerna/run-parallel-batches" "3.16.0"
-    "@lerna/symlink-binary" "3.16.2"
-    "@lerna/symlink-dependencies" "3.16.2"
+    "@lerna/run-topologically" "3.18.0"
+    "@lerna/symlink-binary" "3.17.0"
+    "@lerna/symlink-dependencies" "3.17.0"
     "@lerna/validation-error" "3.13.0"
     dedent "^0.7.0"
     get-port "^4.2.0"
@@ -985,88 +976,88 @@
     read-package-tree "^5.1.6"
     semver "^6.2.0"
 
-"@lerna/changed@3.16.4":
-  version "3.16.4"
-  resolved "https://registry.npmjs.org/@lerna/changed/-/changed-3.16.4.tgz#c3e727d01453513140eee32c94b695de577dc955"
-  integrity sha512-NCD7XkK744T23iW0wqKEgF4R9MYmReUbyHCZKopFnsNpQdqumc3SOIvQUAkKCP6hQJmYvxvOieoVgy/CVDpZ5g==
+"@lerna/changed@3.18.3":
+  version "3.18.3"
+  resolved "https://registry.npmjs.org/@lerna/changed/-/changed-3.18.3.tgz#50529e8bd5d7fe2d0ace046a6e274d3de652a493"
+  integrity sha512-xZW7Rm+DlDIGc0EvKGyJZgT9f8FFa4d52mr/Y752dZuXR2qRmf9tXhVloRG39881s2A6yi3jqLtXZggKhsQW4Q==
   dependencies:
-    "@lerna/collect-updates" "3.16.0"
-    "@lerna/command" "3.16.0"
-    "@lerna/listable" "3.16.0"
+    "@lerna/collect-updates" "3.18.0"
+    "@lerna/command" "3.18.0"
+    "@lerna/listable" "3.18.0"
     "@lerna/output" "3.13.0"
-    "@lerna/version" "3.16.4"
+    "@lerna/version" "3.18.3"
 
-"@lerna/check-working-tree@3.14.2":
-  version "3.14.2"
-  resolved "https://registry.npmjs.org/@lerna/check-working-tree/-/check-working-tree-3.14.2.tgz#5ce007722180a69643a8456766ed8a91fc7e9ae1"
-  integrity sha512-7safqxM/MYoAoxZxulUDtIJIbnBIgo0PB/FHytueG+9VaX7GMnDte2Bt1EKa0dz2sAyQdmQ3Q8ZXpf/6JDjaeg==
+"@lerna/check-working-tree@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/check-working-tree/-/check-working-tree-3.16.5.tgz#b4f8ae61bb4523561dfb9f8f8d874dd46bb44baa"
+  integrity sha512-xWjVBcuhvB8+UmCSb5tKVLB5OuzSpw96WEhS2uz6hkWVa/Euh1A0/HJwn2cemyK47wUrCQXtczBUiqnq9yX5VQ==
   dependencies:
-    "@lerna/collect-uncommitted" "3.14.2"
-    "@lerna/describe-ref" "3.14.2"
+    "@lerna/collect-uncommitted" "3.16.5"
+    "@lerna/describe-ref" "3.16.5"
     "@lerna/validation-error" "3.13.0"
 
-"@lerna/child-process@3.14.2":
-  version "3.14.2"
-  resolved "https://registry.npmjs.org/@lerna/child-process/-/child-process-3.14.2.tgz#950240cba83f7dfe25247cfa6c9cebf30b7d94f6"
-  integrity sha512-xnq+W5yQb6RkwI0p16ZQnrn6HkloH/MWTw4lGE1nKsBLAUbmSU5oTE93W1nrG0X3IMF/xWc9UYvNdUGMWvZZ4w==
+"@lerna/child-process@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/child-process/-/child-process-3.16.5.tgz#38fa3c18064aa4ac0754ad80114776a7b36a69b2"
+  integrity sha512-vdcI7mzei9ERRV4oO8Y1LHBZ3A5+ampRKg1wq5nutLsUA4mEBN6H7JqjWOMY9xZemv6+kATm2ofjJ3lW5TszQg==
   dependencies:
     chalk "^2.3.1"
     execa "^1.0.0"
     strong-log-transformer "^2.0.0"
 
-"@lerna/clean@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/clean/-/clean-3.16.0.tgz#1c134334cacea1b1dbeacdc580e8b9240db8efa1"
-  integrity sha512-5P9U5Y19WmYZr7UAMGXBpY7xCRdlR7zhHy8MAPDKVx70rFIBS6nWXn5n7Kntv74g7Lm1gJ2rsiH5tj1OPcRJgg==
+"@lerna/clean@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/clean/-/clean-3.18.0.tgz#cc67d7697db969a70e989992fdf077126308fb2e"
+  integrity sha512-BiwBELZNkarRQqj+v5NPB1aIzsOX+Y5jkZ9a5UbwHzEdBUQ5lQa0qaMLSOve/fSkaiZQxe6qnTyatN75lOcDMg==
   dependencies:
-    "@lerna/command" "3.16.0"
-    "@lerna/filter-options" "3.16.0"
+    "@lerna/command" "3.18.0"
+    "@lerna/filter-options" "3.18.0"
     "@lerna/prompt" "3.13.0"
     "@lerna/pulse-till-done" "3.13.0"
-    "@lerna/rimraf-dir" "3.14.2"
+    "@lerna/rimraf-dir" "3.16.5"
     p-map "^2.1.0"
     p-map-series "^1.0.0"
     p-waterfall "^1.0.0"
 
-"@lerna/cli@3.13.0":
-  version "3.13.0"
-  resolved "https://registry.npmjs.org/@lerna/cli/-/cli-3.13.0.tgz#3d7b357fdd7818423e9681a7b7f2abd106c8a266"
-  integrity sha512-HgFGlyCZbYaYrjOr3w/EsY18PdvtsTmDfpUQe8HwDjXlPeCCUgliZjXLOVBxSjiOvPeOSwvopwIHKWQmYbwywg==
+"@lerna/cli@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/cli/-/cli-3.18.0.tgz#2b6f8605bee299c6ada65bc2e4b3ed7bf715af3a"
+  integrity sha512-AwDyfGx7fxJgeaZllEuyJ9LZ6Tdv9yqRD9RX762yCJu+PCAFvB9bp6OYuRSGli7QQgM0CuOYnSg4xVNOmuGKDA==
   dependencies:
     "@lerna/global-options" "3.13.0"
     dedent "^0.7.0"
     npmlog "^4.1.2"
-    yargs "^12.0.1"
+    yargs "^14.2.0"
 
-"@lerna/collect-uncommitted@3.14.2":
-  version "3.14.2"
-  resolved "https://registry.npmjs.org/@lerna/collect-uncommitted/-/collect-uncommitted-3.14.2.tgz#b5ed00d800bea26bb0d18404432b051eee8d030e"
-  integrity sha512-4EkQu4jIOdNL2BMzy/N0ydHB8+Z6syu6xiiKXOoFl0WoWU9H1jEJCX4TH7CmVxXL1+jcs8FIS2pfQz4oew99Eg==
+"@lerna/collect-uncommitted@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/collect-uncommitted/-/collect-uncommitted-3.16.5.tgz#a494d61aac31cdc7aec4bbe52c96550274132e63"
+  integrity sha512-ZgqnGwpDZiWyzIQVZtQaj9tRizsL4dUOhuOStWgTAw1EMe47cvAY2kL709DzxFhjr6JpJSjXV5rZEAeU3VE0Hg==
   dependencies:
-    "@lerna/child-process" "3.14.2"
+    "@lerna/child-process" "3.16.5"
     chalk "^2.3.1"
     figgy-pudding "^3.5.1"
     npmlog "^4.1.2"
 
-"@lerna/collect-updates@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/collect-updates/-/collect-updates-3.16.0.tgz#6db3ce8a740a4e2b972c033a63bdfb77f2553d8c"
-  integrity sha512-HwAIl815X2TNlmcp28zCrSdXfoZWNP7GJPEqNWYk7xDJTYLqQ+SrmKUePjb3AMGBwYAraZSEJLbHdBpJ5+cHmQ==
+"@lerna/collect-updates@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/collect-updates/-/collect-updates-3.18.0.tgz#6086c64df3244993cc0a7f8fc0ddd6a0103008a6"
+  integrity sha512-LJMKgWsE/var1RSvpKDIxS8eJ7POADEc0HM3FQiTpEczhP6aZfv9x3wlDjaHpZm9MxJyQilqxZcasRANmRcNgw==
   dependencies:
-    "@lerna/child-process" "3.14.2"
-    "@lerna/describe-ref" "3.14.2"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/describe-ref" "3.16.5"
     minimatch "^3.0.4"
     npmlog "^4.1.2"
     slash "^2.0.0"
 
-"@lerna/command@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/command/-/command-3.16.0.tgz#ba3dba49cb5ce4d11b48269cf95becd86e30773f"
-  integrity sha512-u7tE4GC4/gfbPA9eQg+0ulnoJ+PMoMqomx033r/IxqZrHtmJR9+pF/37S0fsxJ2hX/RMFPC7c9Q/i8NEufSpdQ==
+"@lerna/command@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/command/-/command-3.18.0.tgz#1e40399324a69d26a78969d59cf60e19b2f13fc3"
+  integrity sha512-JQ0TGzuZc9Ky8xtwtSLywuvmkU8X62NTUT3rMNrUykIkOxBaO+tE0O98u2yo/9BYOeTRji9IsjKZEl5i9Qt0xQ==
   dependencies:
-    "@lerna/child-process" "3.14.2"
-    "@lerna/package-graph" "3.16.0"
-    "@lerna/project" "3.16.0"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/package-graph" "3.18.0"
+    "@lerna/project" "3.18.0"
     "@lerna/validation-error" "3.13.0"
     "@lerna/write-log-file" "3.13.0"
     dedent "^0.7.0"
@@ -1101,14 +1092,14 @@
     fs-extra "^8.1.0"
     npmlog "^4.1.2"
 
-"@lerna/create@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/create/-/create-3.16.0.tgz#4de841ec7d98b29bb19fb7d6ad982e65f7a150e8"
-  integrity sha512-OZApR1Iz7awutbmj4sAArwhqCyKgcrnw9rH0aWAUrkYWrD1w4TwkvAcYAsfx5GpQGbLQwoXhoyyPwPfZRRWz3Q==
+"@lerna/create@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/create/-/create-3.18.0.tgz#78ba4af5eced661944a12b9d7da8553c096c390d"
+  integrity sha512-y9oS7ND5T13c+cCTJHa2Y9in02ppzyjsNynVWFuS40eIzZ3z058d9+3qSBt1nkbbQlVyfLoP6+bZPsjyzap5ig==
   dependencies:
     "@evocateur/pacote" "^9.6.3"
-    "@lerna/child-process" "3.14.2"
-    "@lerna/command" "3.16.0"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/command" "3.18.0"
     "@lerna/npm-conf" "3.16.0"
     "@lerna/validation-error" "3.13.0"
     camelcase "^5.0.0"
@@ -1125,49 +1116,51 @@
     validate-npm-package-name "^3.0.0"
     whatwg-url "^7.0.0"
 
-"@lerna/describe-ref@3.14.2":
-  version "3.14.2"
-  resolved "https://registry.npmjs.org/@lerna/describe-ref/-/describe-ref-3.14.2.tgz#edc3c973f5ca9728d23358c4f4d3b55a21f65be5"
-  integrity sha512-qa5pzDRK2oBQXNjyRmRnN7E8a78NMYfQjjlRFB0KNHMsT6mCiL9+8kIS39sSE2NqT8p7xVNo2r2KAS8R/m3CoQ==
+"@lerna/describe-ref@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/describe-ref/-/describe-ref-3.16.5.tgz#a338c25aaed837d3dc70b8a72c447c5c66346ac0"
+  integrity sha512-c01+4gUF0saOOtDBzbLMFOTJDHTKbDFNErEY6q6i9QaXuzy9LNN62z+Hw4acAAZuJQhrVWncVathcmkkjvSVGw==
   dependencies:
-    "@lerna/child-process" "3.14.2"
+    "@lerna/child-process" "3.16.5"
     npmlog "^4.1.2"
 
-"@lerna/diff@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/diff/-/diff-3.16.0.tgz#6d09a786f9f5b343a2fdc460eb0be08a05b420aa"
-  integrity sha512-QUpVs5TPl8vBIne10/vyjUxanQBQQp7Lk3iaB8MnCysKr0O+oy7trWeFVDPEkBTCD177By7yPGyW5Yey1nCBbA==
+"@lerna/diff@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/diff/-/diff-3.18.0.tgz#9638ff4b46e2a8b0d4ebf54cf2f267ac2f8fdb29"
+  integrity sha512-3iLNlpurc2nV9k22w8ini2Zjm2UPo3xtQgWyqdA6eJjvge0+5AlNAWfPoV6cV+Hc1xDbJD2YDSFpZPJ1ZGilRw==
   dependencies:
-    "@lerna/child-process" "3.14.2"
-    "@lerna/command" "3.16.0"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/command" "3.18.0"
     "@lerna/validation-error" "3.13.0"
     npmlog "^4.1.2"
 
-"@lerna/exec@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/exec/-/exec-3.16.0.tgz#2b6c033cee46181b6eede0eb12aad5c2c0181e89"
-  integrity sha512-mH3O5NXf/O88jBaBBTUf+d56CUkxpg782s3Jxy7HWbVuSUULt3iMRPTh+zEXO5/555etsIVVDDyUR76meklrJA==
+"@lerna/exec@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/exec/-/exec-3.18.0.tgz#d9ec0b7ca06b7521f0b9f14a164e2d4ca5e1b3b9"
+  integrity sha512-hwkuzg1+38+pbzdZPhGtLIYJ59z498/BCNzR8d4/nfMYm8lFbw9RgJJajLcdbuJ9LJ08cZ93hf8OlzetL84TYg==
   dependencies:
-    "@lerna/child-process" "3.14.2"
-    "@lerna/command" "3.16.0"
-    "@lerna/filter-options" "3.16.0"
-    "@lerna/run-topologically" "3.16.0"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/command" "3.18.0"
+    "@lerna/filter-options" "3.18.0"
+    "@lerna/run-topologically" "3.18.0"
     "@lerna/validation-error" "3.13.0"
     p-map "^2.1.0"
 
-"@lerna/filter-options@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/filter-options/-/filter-options-3.16.0.tgz#b1660b4480c02a5c6efa4d0cd98b9afde4ed0bba"
-  integrity sha512-InIi1fF8+PxpCwir9bIy+pGxrdE6hvN0enIs1eNGCVS1TTE8osNgiZXa838bMQ1yaEccdcnVX6Z03BNKd56kNg==
+"@lerna/filter-options@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/filter-options/-/filter-options-3.18.0.tgz#406667dc75a8fc813c26a91bde754b6a73e1a868"
+  integrity sha512-UGVcixs3TGzD8XSmFSbwUVVQnAjaZ6Rmt8Vuq2RcR98ULkGB1LiGNMY89XaNBhaaA8vx7yQWiLmJi2AfmD63Qg==
   dependencies:
-    "@lerna/collect-updates" "3.16.0"
-    "@lerna/filter-packages" "3.16.0"
+    "@lerna/collect-updates" "3.18.0"
+    "@lerna/filter-packages" "3.18.0"
     dedent "^0.7.0"
+    figgy-pudding "^3.5.1"
+    npmlog "^4.1.2"
 
-"@lerna/filter-packages@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/filter-packages/-/filter-packages-3.16.0.tgz#7d34dc8530c71016263d6f67dc65308ecf11c9fc"
-  integrity sha512-eGFzQTx0ogkGDCnbTuXqssryR6ilp8+dcXt6B+aq1MaqL/vOJRZyqMm4TY3CUOUnzZCi9S2WWyMw3PnAJOF+kg==
+"@lerna/filter-packages@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/filter-packages/-/filter-packages-3.18.0.tgz#6a7a376d285208db03a82958cfb8172e179b4e70"
+  integrity sha512-6/0pMM04bCHNATIOkouuYmPg6KH3VkPCIgTfQmdkPJTullERyEQfNUKikrefjxo1vHOoCACDpy65JYyKiAbdwQ==
   dependencies:
     "@lerna/validation-error" "3.13.0"
     multimatch "^3.0.0"
@@ -1189,12 +1182,12 @@
     ssri "^6.0.1"
     tar "^4.4.8"
 
-"@lerna/github-client@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/github-client/-/github-client-3.16.0.tgz#619874e461641d4f59ab1b3f1a7ba22dba88125d"
-  integrity sha512-IVJjcKjkYaUEPJsDyAblHGEFFNKCRyMagbIDm14L7Ab94ccN6i4TKOqAFEJn2SJHYvKKBdp3Zj2zNlASOMe3DA==
+"@lerna/github-client@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/github-client/-/github-client-3.16.5.tgz#2eb0235c3bf7a7e5d92d73e09b3761ab21f35c2e"
+  integrity sha512-rHQdn8Dv/CJrO3VouOP66zAcJzrHsm+wFuZ4uGAai2At2NkgKH+tpNhQy2H1PSC0Ezj9LxvdaHYrUzULqVK5Hw==
   dependencies:
-    "@lerna/child-process" "3.14.2"
+    "@lerna/child-process" "3.16.5"
     "@octokit/plugin-enterprise-rest" "^3.6.1"
     "@octokit/rest" "^16.28.4"
     git-url-parse "^11.1.2"
@@ -1214,21 +1207,21 @@
   resolved "https://registry.npmjs.org/@lerna/global-options/-/global-options-3.13.0.tgz#217662290db06ad9cf2c49d8e3100ee28eaebae1"
   integrity sha512-SlZvh1gVRRzYLVluz9fryY1nJpZ0FHDGB66U9tFfvnnxmueckRQxLopn3tXj3NU1kc3QANT2I5BsQkOqZ4TEFQ==
 
-"@lerna/has-npm-version@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/has-npm-version/-/has-npm-version-3.16.0.tgz#55764a4ce792f0c8553cf996a17f554b9e843288"
-  integrity sha512-TIY036dA9J8OyTrZq9J+it2DVKifL65k7hK8HhkUPpitJkw6jwbMObA/8D40LOGgWNPweJWqmlrTbRSwsR7DrQ==
+"@lerna/has-npm-version@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/has-npm-version/-/has-npm-version-3.16.5.tgz#ab83956f211d8923ea6afe9b979b38cc73b15326"
+  integrity sha512-WL7LycR9bkftyqbYop5rEGJ9sRFIV55tSGmbN1HLrF9idwOCD7CLrT64t235t3t4O5gehDnwKI5h2U3oxTrF8Q==
   dependencies:
-    "@lerna/child-process" "3.14.2"
+    "@lerna/child-process" "3.16.5"
     semver "^6.2.0"
 
-"@lerna/import@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/import/-/import-3.16.0.tgz#b57cb453f4acfc60f6541fcbba10674055cb179d"
-  integrity sha512-trsOmGHzw0rL/f8BLNvd+9PjoTkXq2Dt4/V2UCha254hMQaYutbxcYu8iKPxz9x86jSPlH7FpbTkkHXDsoY7Yg==
+"@lerna/import@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/import/-/import-3.18.0.tgz#c6b124b346a097e6c0f3f1ed4921a278d18bc80b"
+  integrity sha512-2pYIkkBTZsEdccfc+dPsKZeSw3tBzKSyl0b2lGrfmNX2Y41qqOzsJCyI1WO1uvEIP8aOaLy4hPpqRIBe4ee7hw==
   dependencies:
-    "@lerna/child-process" "3.14.2"
-    "@lerna/command" "3.16.0"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/command" "3.18.0"
     "@lerna/prompt" "3.13.0"
     "@lerna/pulse-till-done" "3.13.0"
     "@lerna/validation-error" "3.13.0"
@@ -1236,44 +1229,44 @@
     fs-extra "^8.1.0"
     p-map-series "^1.0.0"
 
-"@lerna/init@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/init/-/init-3.16.0.tgz#31e0d66bbededee603338b487a42674a072b7a7d"
-  integrity sha512-Ybol/x5xMtBgokx4j7/Y3u0ZmNh0NiSWzBFVaOs2NOJKvuqrWimF67DKVz7yYtTYEjtaMdug64ohFF4jcT/iag==
+"@lerna/init@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/init/-/init-3.18.0.tgz#b23b9170cce1f4630170dd744e8ee75785ea898d"
+  integrity sha512-/vHpmXkMlSaJaq25v5K13mcs/2L7E32O6dSsEkHaZCDRiV2BOqsZng9jjbE/4ynfsWfLLlU9ZcydwG72C3I+mQ==
   dependencies:
-    "@lerna/child-process" "3.14.2"
-    "@lerna/command" "3.16.0"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/command" "3.18.0"
     fs-extra "^8.1.0"
     p-map "^2.1.0"
     write-json-file "^3.2.0"
 
-"@lerna/link@3.16.2":
-  version "3.16.2"
-  resolved "https://registry.npmjs.org/@lerna/link/-/link-3.16.2.tgz#6c3a5658f6448a64dddca93d9348ac756776f6f6"
-  integrity sha512-eCPg5Lo8HT525fIivNoYF3vWghO3UgEVFdbsiPmhzwI7IQyZro5HWYzLtywSAdEog5XZpd2Bbn0CsoHWBB3gww==
+"@lerna/link@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/link/-/link-3.18.0.tgz#bc72dc62ef4d8fb842b3286887980f98b764781d"
+  integrity sha512-FbbIpH0EpsC+dpAbvxCoF3cn7F1MAyJjEa5Lh3XkDGATOlinMFuKCbmX0NLpOPQZ5zghvrui97cx+jz5F2IlHw==
   dependencies:
-    "@lerna/command" "3.16.0"
-    "@lerna/package-graph" "3.16.0"
-    "@lerna/symlink-dependencies" "3.16.2"
+    "@lerna/command" "3.18.0"
+    "@lerna/package-graph" "3.18.0"
+    "@lerna/symlink-dependencies" "3.17.0"
     p-map "^2.1.0"
     slash "^2.0.0"
 
-"@lerna/list@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/list/-/list-3.16.0.tgz#883c00b2baf1e03c93e54391372f67a01b773c2f"
-  integrity sha512-TkvstoPsgKqqQ0KfRumpsdMXfRSEhdXqOLq519XyI5IRWYxhoqXqfi8gG37UoBPhBNoe64japn5OjphF3rOmQA==
+"@lerna/list@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/list/-/list-3.18.0.tgz#6e5fe545ce4ba7c1eeb6d6cf69240d06c02bd496"
+  integrity sha512-mpB7Q6T+n2CaiPFz0LuOE+rXphDfHm0mKIwShnyS/XDcii8jXv+z9Iytj8p3rfCH2I1L80j2qL6jWzyGy/uzKA==
   dependencies:
-    "@lerna/command" "3.16.0"
-    "@lerna/filter-options" "3.16.0"
-    "@lerna/listable" "3.16.0"
+    "@lerna/command" "3.18.0"
+    "@lerna/filter-options" "3.18.0"
+    "@lerna/listable" "3.18.0"
     "@lerna/output" "3.13.0"
 
-"@lerna/listable@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/listable/-/listable-3.16.0.tgz#e6dc47a2d5a6295222663486f50e5cffc580f043"
-  integrity sha512-mtdAT2EEECqrJSDm/aXlOUFr1MRE4p6hppzY//Klp05CogQy6uGaKk+iKG5yyCLaOXFFZvG4HfO11CmoGSDWzw==
+"@lerna/listable@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/listable/-/listable-3.18.0.tgz#752b014406a9a012486626d22e940edb8205973a"
+  integrity sha512-9gLGKYNLSKeurD+sJ2RA+nz4Ftulr91U127gefz0RlmAPpYSjwcJkxwa0UfJvpQTXv9C7yzHLnn0BjyAQRjuew==
   dependencies:
-    "@lerna/query-graph" "3.16.0"
+    "@lerna/query-graph" "3.18.0"
     chalk "^2.3.1"
     columnify "^1.5.4"
 
@@ -1295,10 +1288,10 @@
     config-chain "^1.1.11"
     pify "^4.0.1"
 
-"@lerna/npm-dist-tag@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/npm-dist-tag/-/npm-dist-tag-3.16.0.tgz#b2184cee5e1f291277396854820e1117a544b7ee"
-  integrity sha512-MQrBkqJJB9+eNphuj9w90QPMOs4NQXMuSRk9NqzeFunOmdDopPCV0Q7IThSxEuWnhJ2n3B7G0vWUP7tNMPdqIQ==
+"@lerna/npm-dist-tag@3.18.1":
+  version "3.18.1"
+  resolved "https://registry.npmjs.org/@lerna/npm-dist-tag/-/npm-dist-tag-3.18.1.tgz#d4dd82ea92e41e960b7117f83102ebcd7a23e511"
+  integrity sha512-vWkZh2T/O9OjPLDrba0BTWO7ug/C3sCwjw7Qyk1aEbxMBXB/eEJPqirwJTWT+EtRJQYB01ky3K8ZFOhElVyjLw==
   dependencies:
     "@evocateur/npm-registry-fetch" "^4.0.0"
     "@lerna/otplease" "3.16.0"
@@ -1306,12 +1299,12 @@
     npm-package-arg "^6.1.0"
     npmlog "^4.1.2"
 
-"@lerna/npm-install@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/npm-install/-/npm-install-3.16.0.tgz#8ec76a7a13b183bde438fd46296bf7a0d6f86017"
-  integrity sha512-APUOIilZCzDzce92uLEwzt1r7AEMKT/hWA1ThGJL+PO9Rn8A95Km3o2XZAYG4W0hR+P4O2nSVuKbsjQtz8CjFQ==
+"@lerna/npm-install@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/npm-install/-/npm-install-3.16.5.tgz#d6bfdc16f81285da66515ae47924d6e278d637d3"
+  integrity sha512-hfiKk8Eku6rB9uApqsalHHTHY+mOrrHeWEs+gtg7+meQZMTS3kzv4oVp5cBZigndQr3knTLjwthT/FX4KvseFg==
   dependencies:
-    "@lerna/child-process" "3.14.2"
+    "@lerna/child-process" "3.16.5"
     "@lerna/get-npm-exec-opts" "3.13.0"
     fs-extra "^8.1.0"
     npm-package-arg "^6.1.0"
@@ -1334,12 +1327,12 @@
     pify "^4.0.1"
     read-package-json "^2.0.13"
 
-"@lerna/npm-run-script@3.14.2":
-  version "3.14.2"
-  resolved "https://registry.npmjs.org/@lerna/npm-run-script/-/npm-run-script-3.14.2.tgz#8c518ea9d241a641273e77aad6f6fddc16779c3f"
-  integrity sha512-LbVFv+nvAoRTYLMrJlJ8RiakHXrLslL7Jp/m1R18vYrB8LYWA3ey+nz5Tel2OELzmjUiemAKZsD9h6i+Re5egg==
+"@lerna/npm-run-script@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/npm-run-script/-/npm-run-script-3.16.5.tgz#9c2ec82453a26c0b46edc0bb7c15816c821f5c15"
+  integrity sha512-1asRi+LjmVn3pMjEdpqKJZFT/3ZNpb+VVeJMwrJaV/3DivdNg7XlPK9LTrORuKU4PSvhdEZvJmSlxCKyDpiXsQ==
   dependencies:
-    "@lerna/child-process" "3.14.2"
+    "@lerna/child-process" "3.16.5"
     "@lerna/get-npm-exec-opts" "3.13.0"
     npmlog "^4.1.2"
 
@@ -1372,10 +1365,10 @@
     tar "^4.4.10"
     temp-write "^3.4.0"
 
-"@lerna/package-graph@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/package-graph/-/package-graph-3.16.0.tgz#909c90fb41e02f2c19387342d2a5eefc36d56836"
-  integrity sha512-A2mum/gNbv7zCtAwJqoxzqv89As73OQNK2MgSX1SHWya46qoxO9a9Z2c5lOFQ8UFN5ZxqWMfFYXRCz7qzwmFXw==
+"@lerna/package-graph@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/package-graph/-/package-graph-3.18.0.tgz#eb42d14404a55b26b2472081615e26b0817cd91a"
+  integrity sha512-BLYDHO5ihPh20i3zoXfLZ5ZWDCrPuGANgVhl7k5pCmRj90LCvT+C7V3zrw70fErGAfvkcYepMqxD+oBrAYwquQ==
   dependencies:
     "@lerna/prerelease-id-from-version" "3.16.0"
     "@lerna/validation-error" "3.13.0"
@@ -1399,10 +1392,10 @@
   dependencies:
     semver "^6.2.0"
 
-"@lerna/project@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/project/-/project-3.16.0.tgz#2469a4e346e623fd922f38f5a12931dfb8f2a946"
-  integrity sha512-NrKcKK1EqXqhrGvslz6Q36+ZHuK3zlDhGdghRqnxDcHxMPT01NgLcmsnymmQ+gjMljuLRmvKYYCuHrknzX8VrA==
+"@lerna/project@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/project/-/project-3.18.0.tgz#56feee01daeb42c03cbdf0ed8a2a10cbce32f670"
+  integrity sha512-+LDwvdAp0BurOAWmeHE3uuticsq9hNxBI0+FMHiIai8jrygpJGahaQrBYWpwbshbQyVLeQgx3+YJdW2TbEdFWA==
   dependencies:
     "@lerna/package" "3.16.0"
     "@lerna/validation-error" "3.13.0"
@@ -1425,22 +1418,22 @@
     inquirer "^6.2.0"
     npmlog "^4.1.2"
 
-"@lerna/publish@3.16.4":
-  version "3.16.4"
-  resolved "https://registry.npmjs.org/@lerna/publish/-/publish-3.16.4.tgz#4cd55d8be9943d9a68e316e930a90cda8590500e"
-  integrity sha512-XZY+gRuF7/v6PDQwl7lvZaGWs8CnX6WIPIu+OCcyFPSL/rdWegdN7HieKBHskgX798qRQc2GrveaY7bNoTKXAw==
+"@lerna/publish@3.18.3":
+  version "3.18.3"
+  resolved "https://registry.npmjs.org/@lerna/publish/-/publish-3.18.3.tgz#478bb94ee712a40b723413e437bcb9e307d3709c"
+  integrity sha512-XlfWOWIhaSK0Y2sX5ppNWI5Y3CDtlxMcQa1hTbZlC5rrDA6vD32iutbmH6Ix3c6wtvVbSkgA39GWsQEXxPS+7w==
   dependencies:
     "@evocateur/libnpmaccess" "^3.1.2"
     "@evocateur/npm-registry-fetch" "^4.0.0"
     "@evocateur/pacote" "^9.6.3"
-    "@lerna/check-working-tree" "3.14.2"
-    "@lerna/child-process" "3.14.2"
-    "@lerna/collect-updates" "3.16.0"
-    "@lerna/command" "3.16.0"
-    "@lerna/describe-ref" "3.14.2"
+    "@lerna/check-working-tree" "3.16.5"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/collect-updates" "3.18.0"
+    "@lerna/command" "3.18.0"
+    "@lerna/describe-ref" "3.16.5"
     "@lerna/log-packed" "3.16.0"
     "@lerna/npm-conf" "3.16.0"
-    "@lerna/npm-dist-tag" "3.16.0"
+    "@lerna/npm-dist-tag" "3.18.1"
     "@lerna/npm-publish" "3.16.2"
     "@lerna/otplease" "3.16.0"
     "@lerna/output" "3.13.0"
@@ -1449,9 +1442,9 @@
     "@lerna/prompt" "3.13.0"
     "@lerna/pulse-till-done" "3.13.0"
     "@lerna/run-lifecycle" "3.16.2"
-    "@lerna/run-topologically" "3.16.0"
+    "@lerna/run-topologically" "3.18.0"
     "@lerna/validation-error" "3.13.0"
-    "@lerna/version" "3.16.4"
+    "@lerna/version" "3.18.3"
     figgy-pudding "^3.5.1"
     fs-extra "^8.1.0"
     npm-package-arg "^6.1.0"
@@ -1468,12 +1461,12 @@
   dependencies:
     npmlog "^4.1.2"
 
-"@lerna/query-graph@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/query-graph/-/query-graph-3.16.0.tgz#e6a46ebcd9d5b03f018a06eca2b471735353953c"
-  integrity sha512-p0RO+xmHDO95ChJdWkcy9TNLysLkoDARXeRHzY5U54VCwl3Ot/2q8fMCVlA5UeGXDutEyyByl3URqEpcQCWI7Q==
+"@lerna/query-graph@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/query-graph/-/query-graph-3.18.0.tgz#43801a2f1b80a0ea0bfd9d42d470605326a3035d"
+  integrity sha512-fgUhLx6V0jDuKZaKj562jkuuhrfVcjl5sscdfttJ8dXNVADfDz76nzzwLY0ZU7/0m69jDedohn5Fx5p7hDEVEg==
   dependencies:
-    "@lerna/package-graph" "3.16.0"
+    "@lerna/package-graph" "3.18.0"
     figgy-pudding "^3.5.1"
 
 "@lerna/resolve-symlink@3.16.0":
@@ -1485,12 +1478,12 @@
     npmlog "^4.1.2"
     read-cmd-shim "^1.0.1"
 
-"@lerna/rimraf-dir@3.14.2":
-  version "3.14.2"
-  resolved "https://registry.npmjs.org/@lerna/rimraf-dir/-/rimraf-dir-3.14.2.tgz#103a49882abd85d42285d05cc76869b89f21ffd2"
-  integrity sha512-eFNkZsy44Bu9v1Hrj5Zk6omzg8O9h/7W6QYK1TTUHeyrjTEwytaNQlqF0lrTLmEvq55sviV42NC/8P3M2cvq8Q==
+"@lerna/rimraf-dir@3.16.5":
+  version "3.16.5"
+  resolved "https://registry.npmjs.org/@lerna/rimraf-dir/-/rimraf-dir-3.16.5.tgz#04316ab5ffd2909657aaf388ea502cb8c2f20a09"
+  integrity sha512-bQlKmO0pXUsXoF8lOLknhyQjOZsCc0bosQDoX4lujBXSWxHVTg1VxURtWf2lUjz/ACsJVDfvHZbDm8kyBk5okA==
   dependencies:
-    "@lerna/child-process" "3.14.2"
+    "@lerna/child-process" "3.16.5"
     npmlog "^4.1.2"
     path-exists "^3.0.0"
     rimraf "^2.6.2"
@@ -1505,55 +1498,47 @@
     npm-lifecycle "^3.1.2"
     npmlog "^4.1.2"
 
-"@lerna/run-parallel-batches@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/run-parallel-batches/-/run-parallel-batches-3.16.0.tgz#5ace7911a2dd31dfd1e53c61356034e27df0e1fb"
-  integrity sha512-2J/Nyv+MvogmQEfC7VcS21ifk7w0HVvzo2yOZRPvkCzGRu/rducxtB4RTcr58XCZ8h/Bt1aqQYKExu3c/3GXwg==
-  dependencies:
-    p-map "^2.1.0"
-    p-map-series "^1.0.0"
-
-"@lerna/run-topologically@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/run-topologically/-/run-topologically-3.16.0.tgz#39e29cfc628bbc8e736d8e0d0e984997ac01bbf5"
-  integrity sha512-4Hlpv4zDtKWa5Z0tPkeu0sK+bxZEKgkNESMGmWrUCNfj7xwvAJurcraK8+a2Y0TFYwf0qjSLY/MzX+ZbJA3Cgw==
+"@lerna/run-topologically@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/run-topologically/-/run-topologically-3.18.0.tgz#9508604553cfbeba106cd84b711fade17947f94a"
+  integrity sha512-lrfEewwuUMC3ioxf9Z9NdHUakN6ihekcPfdYbzR2slmdbjYKmIA5srkWdrK8NwOpQCAuekpOovH2s8X3FGEopg==
   dependencies:
-    "@lerna/query-graph" "3.16.0"
+    "@lerna/query-graph" "3.18.0"
     figgy-pudding "^3.5.1"
     p-queue "^4.0.0"
 
-"@lerna/run@3.16.0":
-  version "3.16.0"
-  resolved "https://registry.npmjs.org/@lerna/run/-/run-3.16.0.tgz#1ea568c6f303e47fa00b3403a457836d40738fd2"
-  integrity sha512-woTeLlB1OAAz4zzjdI6RyIxSGuxiUPHJZm89E1pDEPoWwtQV6HMdMgrsQd9ATsJ5Ez280HH4bF/LStAlqW8Ufg==
+"@lerna/run@3.18.0":
+  version "3.18.0"
+  resolved "https://registry.npmjs.org/@lerna/run/-/run-3.18.0.tgz#b7069880f6313e4c6026b564b7b76e5d0f30a521"
+  integrity sha512-sblxHBZ9djaaG7wefPcfEicDqzrB7CP1m/jIB0JvPEQwG4C2qp++ewBpkjRw/mBtjtzg0t7v0nNMXzaWYrQckQ==
   dependencies:
-    "@lerna/command" "3.16.0"
-    "@lerna/filter-options" "3.16.0"
-    "@lerna/npm-run-script" "3.14.2"
+    "@lerna/command" "3.18.0"
+    "@lerna/filter-options" "3.18.0"
+    "@lerna/npm-run-script" "3.16.5"
     "@lerna/output" "3.13.0"
-    "@lerna/run-topologically" "3.16.0"
+    "@lerna/run-topologically" "3.18.0"
     "@lerna/timer" "3.13.0"
     "@lerna/validation-error" "3.13.0"
     p-map "^2.1.0"
 
-"@lerna/symlink-binary@3.16.2":
-  version "3.16.2"
-  resolved "https://registry.npmjs.org/@lerna/symlink-binary/-/symlink-binary-3.16.2.tgz#f98a3d9da9e56f1d302dc0d5c2efeb951483ee66"
-  integrity sha512-kz9XVoFOGSF83gg4gBqH+mG6uxfJfTp8Uy+Cam40CvMiuzfODrGkjuBEFoM/uO2QOAwZvbQDYOBpKUa9ZxHS1Q==
+"@lerna/symlink-binary@3.17.0":
+  version "3.17.0"
+  resolved "https://registry.npmjs.org/@lerna/symlink-binary/-/symlink-binary-3.17.0.tgz#8f8031b309863814883d3f009877f82e38aef45a"
+  integrity sha512-RLpy9UY6+3nT5J+5jkM5MZyMmjNHxZIZvXLV+Q3MXrf7Eaa1hNqyynyj4RO95fxbS+EZc4XVSk25DGFQbcRNSQ==
   dependencies:
     "@lerna/create-symlink" "3.16.2"
     "@lerna/package" "3.16.0"
     fs-extra "^8.1.0"
     p-map "^2.1.0"
 
-"@lerna/symlink-dependencies@3.16.2":
-  version "3.16.2"
-  resolved "https://registry.npmjs.org/@lerna/symlink-dependencies/-/symlink-dependencies-3.16.2.tgz#91d9909d35897aebd76a03644a00cd03c4128240"
-  integrity sha512-wnZqGJQ+Jvr1I3inxrkffrFZfmQI7Ta8gySw/UWCy95QtZWF/f5yk8zVIocCAsjzD0wgb3jJE3CFJ9W5iwWk1A==
+"@lerna/symlink-dependencies@3.17.0":
+  version "3.17.0"
+  resolved "https://registry.npmjs.org/@lerna/symlink-dependencies/-/symlink-dependencies-3.17.0.tgz#48d6360e985865a0e56cd8b51b308a526308784a"
+  integrity sha512-KmjU5YT1bpt6coOmdFueTJ7DFJL4H1w5eF8yAQ2zsGNTtZ+i5SGFBWpb9AQaw168dydc3s4eu0W0Sirda+F59Q==
   dependencies:
     "@lerna/create-symlink" "3.16.2"
     "@lerna/resolve-symlink" "3.16.0"
-    "@lerna/symlink-binary" "3.16.2"
+    "@lerna/symlink-binary" "3.17.0"
     fs-extra "^8.1.0"
     p-finally "^1.0.0"
     p-map "^2.1.0"
@@ -1571,26 +1556,27 @@
   dependencies:
     npmlog "^4.1.2"
 
-"@lerna/version@3.16.4":
-  version "3.16.4"
-  resolved "https://registry.npmjs.org/@lerna/version/-/version-3.16.4.tgz#b5cc37f3ad98358d599c6196c30b6efc396d42bf"
-  integrity sha512-ikhbMeIn5ljCtWTlHDzO4YvTmpGTX1lWFFIZ79Vd1TNyOr+OUuKLo/+p06mCl2WEdZu0W2s5E9oxfAAQbyDxEg==
+"@lerna/version@3.18.3":
+  version "3.18.3"
+  resolved "https://registry.npmjs.org/@lerna/version/-/version-3.18.3.tgz#01344b39c0749fdeb6c178714733bacbde4d602f"
+  integrity sha512-IXXRlyM3Q/jrc+QZio+bgjG4ZaK+4LYmY4Yql1xyY0wZhAKsWP/Q6ho7e1EJNjNC5dUJO99Fq7qB05MkDf2OcQ==
   dependencies:
-    "@lerna/check-working-tree" "3.14.2"
-    "@lerna/child-process" "3.14.2"
-    "@lerna/collect-updates" "3.16.0"
-    "@lerna/command" "3.16.0"
+    "@lerna/check-working-tree" "3.16.5"
+    "@lerna/child-process" "3.16.5"
+    "@lerna/collect-updates" "3.18.0"
+    "@lerna/command" "3.18.0"
     "@lerna/conventional-commits" "3.16.4"
-    "@lerna/github-client" "3.16.0"
+    "@lerna/github-client" "3.16.5"
     "@lerna/gitlab-client" "3.15.0"
     "@lerna/output" "3.13.0"
     "@lerna/prerelease-id-from-version" "3.16.0"
     "@lerna/prompt" "3.13.0"
     "@lerna/run-lifecycle" "3.16.2"
-    "@lerna/run-topologically" "3.16.0"
+    "@lerna/run-topologically" "3.18.0"
     "@lerna/validation-error" "3.13.0"
     chalk "^2.3.1"
     dedent "^0.7.0"
+    load-json-file "^5.3.0"
     minimatch "^3.0.4"
     npmlog "^4.1.2"
     p-map "^2.1.0"
@@ -1600,6 +1586,7 @@
     semver "^6.2.0"
     slash "^2.0.0"
     temp-write "^3.4.0"
+    write-json-file "^3.2.0"
 
 "@lerna/write-log-file@3.13.0":
   version "3.13.0"
@@ -9446,26 +9433,26 @@ left-pad@^1.3.0:
   resolved "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz#5b8a3a7765dfe001261dde915589e782f8c94d1e"
   integrity sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==
 
-lerna@^3.14.1:
-  version "3.16.4"
-  resolved "https://registry.npmjs.org/lerna/-/lerna-3.16.4.tgz#158cb4f478b680f46f871d5891f531f3a2cb31ec"
-  integrity sha512-0HfwXIkqe72lBLZcNO9NMRfylh5Ng1l8tETgYQ260ZdHRbPuaLKE3Wqnd2YYRRkWfwPyEyZO8mZweBR+slVe1A==
-  dependencies:
-    "@lerna/add" "3.16.2"
-    "@lerna/bootstrap" "3.16.2"
-    "@lerna/changed" "3.16.4"
-    "@lerna/clean" "3.16.0"
-    "@lerna/cli" "3.13.0"
-    "@lerna/create" "3.16.0"
-    "@lerna/diff" "3.16.0"
-    "@lerna/exec" "3.16.0"
-    "@lerna/import" "3.16.0"
-    "@lerna/init" "3.16.0"
-    "@lerna/link" "3.16.2"
-    "@lerna/list" "3.16.0"
-    "@lerna/publish" "3.16.4"
-    "@lerna/run" "3.16.0"
-    "@lerna/version" "3.16.4"
+lerna@^3.16.4:
+  version "3.18.3"
+  resolved "https://registry.npmjs.org/lerna/-/lerna-3.18.3.tgz#c94556e76f98df9c7ae4ed3bc0166117cc42cd13"
+  integrity sha512-Bnr/RjyDSVA2Vu+NArK7do4UIpyy+EShOON7tignfAekPbi7cNDnMMGgSmbCQdKITkqPACMfCMdyq0hJlg6n3g==
+  dependencies:
+    "@lerna/add" "3.18.0"
+    "@lerna/bootstrap" "3.18.0"
+    "@lerna/changed" "3.18.3"
+    "@lerna/clean" "3.18.0"
+    "@lerna/cli" "3.18.0"
+    "@lerna/create" "3.18.0"
+    "@lerna/diff" "3.18.0"
+    "@lerna/exec" "3.18.0"
+    "@lerna/import" "3.18.0"
+    "@lerna/init" "3.18.0"
+    "@lerna/link" "3.18.0"
+    "@lerna/list" "3.18.0"
+    "@lerna/publish" "3.18.3"
+    "@lerna/run" "3.18.0"
+    "@lerna/version" "3.18.3"
     import-local "^2.0.0"
     npmlog "^4.1.2"
 
@@ -14813,7 +14800,7 @@ typescript@^3.0.1, typescript@^3.2.4, typescript@^3.3.4000, typescript@~3.6.2:
   resolved "https://registry.npmjs.org/typescript/-/typescript-3.6.2.tgz#105b0f1934119dde543ac8eb71af3a91009efe54"
   integrity sha512-lmQ4L+J6mnu3xweP8+rOrUwzmN+MRAj7TgtJtDaXE5PMyX2kCrklhg3rvOsOIfNeAWMQWO2F1GPc1kMD2vLAfw==
 
-typescript@^3.4.5:
+typescript@^3.4.5, typescript@^3.6.4:
   version "3.6.4"
   resolved "https://registry.npmjs.org/typescript/-/typescript-3.6.4.tgz#b18752bb3792bc1a0281335f7f6ebf1bbfc5b91d"
   integrity sha512-unoCll1+l+YK4i4F8f22TaNVPRHcD9PA3yCuZ8g5e0qGqlVlJ/8FSateOLLSagn+Yg5+ZwuPkL8LFUc0Jcvksg==
@@ -15688,6 +15675,14 @@ yargs-parser@^13.0.0, yargs-parser@^13.1.1:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
 
+yargs-parser@^15.0.0:
+  version "15.0.0"
+  resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.0.tgz#cdd7a97490ec836195f59f3f4dbe5ea9e8f75f08"
+  integrity sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ==
+  dependencies:
+    camelcase "^5.0.0"
+    decamelize "^1.2.0"
+
 yargs-parser@^5.0.0:
   version "5.0.0"
   resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a"
@@ -15702,7 +15697,7 @@ yargs-parser@^7.0.0:
   dependencies:
     camelcase "^4.1.0"
 
-yargs@12.0.5, yargs@^12.0.1, yargs@^12.0.5:
+yargs@12.0.5, yargs@^12.0.5:
   version "12.0.5"
   resolved "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13"
   integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==
@@ -15772,6 +15767,23 @@ yargs@^13.0.0, yargs@^13.2.1, yargs@^13.3.0:
     y18n "^4.0.0"
     yargs-parser "^13.1.1"
 
+yargs@^14.2.0:
+  version "14.2.0"
+  resolved "https://registry.npmjs.org/yargs/-/yargs-14.2.0.tgz#f116a9242c4ed8668790b40759b4906c276e76c3"
+  integrity sha512-/is78VKbKs70bVZH7w4YaZea6xcJWOAwkhbR0CFuZBmYtfTYF0xjGJF43AYd8g2Uii1yJwmS5GR2vBmrc32sbg==
+  dependencies:
+    cliui "^5.0.0"
+    decamelize "^1.2.0"
+    find-up "^3.0.0"
+    get-caller-file "^2.0.1"
+    require-directory "^2.1.1"
+    require-main-filename "^2.0.0"
+    set-blocking "^2.0.0"
+    string-width "^3.0.0"
+    which-module "^2.0.0"
+    y18n "^4.0.0"
+    yargs-parser "^15.0.0"
+
 yargs@^7.1.0:
   version "7.1.0"
   resolved "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8"