|
| 1 | +/* |
| 2 | + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one |
| 3 | + * or more contributor license agreements. Licensed under the Elastic License |
| 4 | + * 2.0 and the Server Side Public License, v 1; you may not use this file except |
| 5 | + * in compliance with, at your election, the Elastic License 2.0 or the Server |
| 6 | + * Side Public License, v 1. |
| 7 | + */ |
| 8 | + |
| 9 | +import * as kbnTestServer from './kbn_server'; |
| 10 | +import { SavedObject } from '../types'; |
| 11 | + |
| 12 | +type ExportOptions = { type: string } | { objects: Array<{ id: string; type: string }> }; |
| 13 | + |
| 14 | +/** |
| 15 | + * Creates a test harness utility running migrations on a fully configured Kibana and Elasticsearch instance with all |
| 16 | + * Kibana plugins loaded. Useful for testing more complex migrations that have dependencies on other plugins. Should |
| 17 | + * only be used within the jest_integration suite. |
| 18 | + * |
| 19 | + * @example |
| 20 | + * ```ts |
| 21 | + * describe('my migrations', () => { |
| 22 | + * let testHarness: SavedObjectTestHarness; |
| 23 | + * beforeAll(async () => { |
| 24 | + * testHarness = createTestHarness(); |
| 25 | + * await testHarness.start(); |
| 26 | + * }); |
| 27 | + * afterAll(() => testHarness.stop()); |
| 28 | + * |
| 29 | + * |
| 30 | + * it('migrates the documents', async () => { |
| 31 | + * expect( |
| 32 | + * await testHarness.migrate( |
| 33 | + * { type: 'my-type', id: 'my-id', attributes: { ... }, references: [] } |
| 34 | + * ) |
| 35 | + * ).toEqual([ |
| 36 | + * expect.objectContaining({ type: 'my-type', id: 'my-id', attributes: { ... }, references: [] }) |
| 37 | + * ]); |
| 38 | + * }); |
| 39 | + * }); |
| 40 | + * ``` |
| 41 | + */ |
| 42 | +export const createTestHarness = () => { |
| 43 | + let started = false; |
| 44 | + let stopped = false; |
| 45 | + let esServer: kbnTestServer.TestElasticsearchUtils; |
| 46 | + const { startES } = kbnTestServer.createTestServers({ adjustTimeout: jest.setTimeout }); |
| 47 | + const root = kbnTestServer.createRootWithCorePlugins({}, { oss: false }); |
| 48 | + |
| 49 | + /** |
| 50 | + * Imports an array of objects into Kibana and applies migrations before persisting to Elasticsearch. Will overwrite |
| 51 | + * any existing objects with the same id. |
| 52 | + * @param objects |
| 53 | + */ |
| 54 | + const importObjects = async (objects: SavedObject[]) => { |
| 55 | + if (!started) |
| 56 | + throw new Error(`SavedObjectTestHarness must be started before objects can be imported`); |
| 57 | + if (stopped) throw new Error(`SavedObjectTestHarness cannot import objects after stopped`); |
| 58 | + |
| 59 | + const response = await kbnTestServer |
| 60 | + // Always use overwrite=true flag so we can isolate this harness to migrations |
| 61 | + .getSupertest(root, 'post', '/api/saved_objects/_import?overwrite=true') |
| 62 | + .set('Content-Type', 'multipart/form-data; boundary=EXAMPLE') |
| 63 | + .send( |
| 64 | + [ |
| 65 | + '--EXAMPLE', |
| 66 | + 'Content-Disposition: form-data; name="file"; filename="export.ndjson"', |
| 67 | + 'Content-Type: application/ndjson', |
| 68 | + '', |
| 69 | + ...objects.map((o) => JSON.stringify(o)), |
| 70 | + '--EXAMPLE--', |
| 71 | + ].join('\r\n') |
| 72 | + ) |
| 73 | + .expect(200); |
| 74 | + |
| 75 | + if (response.body.errors?.length > 0) { |
| 76 | + throw new Error( |
| 77 | + `Errors importing objects: ${JSON.stringify(response.body.errors, undefined, 2)}` |
| 78 | + ); |
| 79 | + } |
| 80 | + }; |
| 81 | + |
| 82 | + /** |
| 83 | + * Exports objects from Kibana with all migrations applied. |
| 84 | + * @param options |
| 85 | + */ |
| 86 | + const exportObjects = async (options: ExportOptions): Promise<SavedObject[]> => { |
| 87 | + if (!started) |
| 88 | + throw new Error(`SavedObjectTestHarness must be started before objects can be imported`); |
| 89 | + if (stopped) throw new Error(`SavedObjectTestHarness cannot import objects after stopped`); |
| 90 | + |
| 91 | + const response = await kbnTestServer |
| 92 | + .getSupertest(root, 'post', '/api/saved_objects/_export') |
| 93 | + .send({ |
| 94 | + ...options, |
| 95 | + excludeExportDetails: true, |
| 96 | + }) |
| 97 | + .expect(200); |
| 98 | + |
| 99 | + // Parse ndjson response |
| 100 | + return response.text.split('\n').map((s: string) => JSON.parse(s)); |
| 101 | + }; |
| 102 | + |
| 103 | + return { |
| 104 | + /** |
| 105 | + * Start Kibana and Elasticsearch for migration testing. Must be called before `migrate`. |
| 106 | + * In most cases, this can be called during your test's `beforeAll` hook and does not need to be called for each |
| 107 | + * individual test. |
| 108 | + */ |
| 109 | + start: async () => { |
| 110 | + if (started) |
| 111 | + throw new Error(`SavedObjectTestHarness already started! Cannot call start again`); |
| 112 | + if (stopped) |
| 113 | + throw new Error(`SavedObjectTestHarness already stopped! Cannot call start again`); |
| 114 | + |
| 115 | + started = true; |
| 116 | + esServer = await startES(); |
| 117 | + await root.setup(); |
| 118 | + await root.start(); |
| 119 | + |
| 120 | + console.log(`Waiting for Kibana to be ready...`); |
| 121 | + await waitForTrue(async () => { |
| 122 | + const statusApi = kbnTestServer.getSupertest(root, 'get', '/api/status'); |
| 123 | + const response = await statusApi.send(); |
| 124 | + return response.status === 200; |
| 125 | + }); |
| 126 | + }, |
| 127 | + |
| 128 | + /** |
| 129 | + * Stop Kibana and Elasticsearch for migration testing. Must be called after `start`. |
| 130 | + * In most cases, this can be called during your test's `afterAll` hook and does not need to be called for each |
| 131 | + * individual test. |
| 132 | + */ |
| 133 | + stop: async () => { |
| 134 | + if (!started) throw new Error(`SavedObjectTestHarness not started! Cannot call stop`); |
| 135 | + if (stopped) |
| 136 | + throw new Error(`SavedObjectTestHarness already stopped! Cannot call stop again`); |
| 137 | + |
| 138 | + stopped = true; |
| 139 | + await root.shutdown(); |
| 140 | + await esServer.stop(); |
| 141 | + }, |
| 142 | + |
| 143 | + /** |
| 144 | + * Migrates an array of SavedObjects and returns the results. Assumes that the objects will retain the same type |
| 145 | + * and id after the migration. When testing migrations that may change a document's type or id, use `importObjects` |
| 146 | + * and `exportObjects` directly. |
| 147 | + * @param objects |
| 148 | + */ |
| 149 | + migrate: async (objects: SavedObject[]) => { |
| 150 | + await importObjects(objects); |
| 151 | + return exportObjects({ |
| 152 | + objects: objects.map(({ type, id }) => ({ type, id })), |
| 153 | + }); |
| 154 | + }, |
| 155 | + |
| 156 | + importObjects, |
| 157 | + exportObjects, |
| 158 | + }; |
| 159 | +}; |
| 160 | + |
| 161 | +export type SavedObjectTestHarness = ReturnType<typeof createTestHarness>; |
| 162 | + |
| 163 | +const waitForTrue = async (predicate: () => Promise<boolean>) => { |
| 164 | + let attempt = 0; |
| 165 | + do { |
| 166 | + attempt++; |
| 167 | + const result = await predicate(); |
| 168 | + if (result) { |
| 169 | + return; |
| 170 | + } |
| 171 | + |
| 172 | + await new Promise((r) => setTimeout(r, attempt * 500)); |
| 173 | + } while (attempt <= 10); |
| 174 | + |
| 175 | + throw new Error(`Predicate never resolved after ${attempt} attempts`); |
| 176 | +}; |
0 commit comments