Skip to content

Commit 8b74b3d

Browse files
committed
[Jest] Lazy ES mock
1 parent a721815 commit 8b74b3d

File tree

1 file changed

+158
-50
lines changed
  • src/core/packages/elasticsearch/client-server-mocks/src

1 file changed

+158
-50
lines changed

src/core/packages/elasticsearch/client-server-mocks/src/mocks.ts

Lines changed: 158 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,10 @@ import { PRODUCT_RESPONSE_HEADER } from '@kbn/core-elasticsearch-client-server-i
1414
import { lazyObject } from '@kbn/lazy-object';
1515

1616
const omittedProps = [
17-
'diagnostic',
1817
'name',
1918
'connectionPool',
2019
'transport',
2120
'serializer',
22-
'helpers',
2321
'acceptedParams',
2422
] as Array<PublicKeys<Client>>;
2523

@@ -57,6 +55,7 @@ export interface ClientApiMockInstance<T, Y extends any[]> extends jest.MockInst
5755
): this;
5856
}
5957

58+
// Helper to create a jest mock function with response helpers
6059
const createMockedApi = <
6160
T = unknown,
6261
Y extends [any, TransportRequestOptions] = [any, TransportRequestOptions]
@@ -122,67 +121,176 @@ const createMockedApi = <
122121
return mock;
123122
};
124123

124+
// Build a shape of the Elasticsearch client once, using a hoisted real client instance
125125
// use jest.requireActual() to prevent weird errors when people mock @elastic/elasticsearch
126126
const { Client: UnmockedClient } = jest.requireActual('@elastic/elasticsearch');
127-
const createInternalClientMock = (res?: Promise<unknown>): DeeplyMockedApi<Client> => {
128-
// we mimic 'reflection' on a concrete instance of the client to generate the mocked functions.
129-
const client = new UnmockedClient({
130-
node: 'http://127.0.0.1',
131-
});
132127

133-
const getAllPropertyDescriptors = (obj: Record<string, any>) => {
134-
const descriptors = Object.entries(Object.getOwnPropertyDescriptors(obj));
135-
let prototype = Object.getPrototypeOf(obj);
136-
while (prototype != null && prototype !== Object.prototype) {
137-
descriptors.push(...Object.entries(Object.getOwnPropertyDescriptors(prototype)));
138-
prototype = Object.getPrototypeOf(prototype);
128+
type ShapeNode = { type: 'method' } | { type: 'object'; props: Record<string, ShapeNode> };
129+
130+
let cachedShape: ShapeNode | null = null;
131+
132+
function getAllPropertyDescriptors(obj: Record<string, any>) {
133+
const map: Record<string, PropertyDescriptor> = {};
134+
let cur: any = obj;
135+
while (cur && cur !== Object.prototype) {
136+
const descs = Object.getOwnPropertyDescriptors(cur);
137+
for (const [k, d] of Object.entries(descs)) {
138+
if (!(k in map)) map[k] = d;
139139
}
140-
return descriptors;
141-
};
140+
cur = Object.getPrototypeOf(cur);
141+
}
142+
return map;
143+
}
142144

143-
const mockify = (obj: Record<string, any>, omitted: string[] = []) => {
144-
// the @elastic/elasticsearch::Client uses prototypical inheritance
145-
// so we have to crawl up the prototype chain and get all descriptors
146-
// to find everything that we should be mocking
147-
const descriptors = getAllPropertyDescriptors(obj);
148-
descriptors
149-
.filter(([key]) => !omitted.includes(key))
150-
.forEach(([key, descriptor]) => {
151-
if (typeof descriptor.value === 'function') {
152-
const mock = createMockedApi();
153-
mock.mockImplementation(() => res ?? createSuccessTransportRequestPromise({}));
154-
obj[key] = mock;
155-
} else if (typeof obj[key] === 'object' && obj[key] != null) {
156-
mockify(obj[key], omitted);
157-
}
158-
});
159-
};
145+
function buildShapeRecursive(obj: any, isTopLevel: boolean, seen: WeakSet<object>): ShapeNode {
146+
const props: Record<string, ShapeNode> = {};
147+
const descriptors = getAllPropertyDescriptors(obj);
148+
for (const [key, desc] of Object.entries(descriptors)) {
149+
if (key === 'constructor') continue;
150+
if (isTopLevel && (omittedProps as string[]).includes(key)) continue;
151+
152+
let value: any;
153+
if ('value' in desc) value = (desc as any).value;
154+
else if (typeof desc.get === 'function') {
155+
try {
156+
value = desc.get.call(obj);
157+
} catch {
158+
value = undefined;
159+
}
160+
}
160161

161-
mockify(client, omittedProps as string[]);
162+
if (typeof value === 'function') {
163+
props[key] = { type: 'method' };
164+
} else if (value && typeof value === 'object') {
165+
if (seen.has(value)) continue;
166+
seen.add(value);
167+
props[key] = buildShapeRecursive(value, false, seen);
168+
}
169+
}
170+
return { type: 'object', props };
171+
}
172+
173+
function getClientShape(): ShapeNode {
174+
if (cachedShape) return cachedShape;
175+
const client = new UnmockedClient({ node: 'http://127.0.0.1' });
176+
try {
177+
const shape = buildShapeRecursive(client, true, new WeakSet());
178+
cachedShape = shape;
179+
return shape;
180+
} finally {
181+
try {
182+
// ensure we close the actual client instance (ignore errors)
183+
void client.close();
184+
} catch {
185+
// ignore
186+
}
187+
}
188+
}
162189

163-
client.close = jest.fn().mockReturnValue(Promise.resolve());
164-
client.child = jest.fn().mockImplementation(() => createInternalClientMock());
190+
function buildLazyMockFromShape(shape: ShapeNode, res?: Promise<unknown>): any {
191+
if (shape.type !== 'object') return {};
192+
const target: Record<string, any> = {};
165193

166-
const mockGetter = (obj: Record<string, any>, propertyName: string) => {
167-
Object.defineProperty(obj, propertyName, {
194+
const defineLazyMethod = (obj: Record<string, any>, key: string) => {
195+
Object.defineProperty(obj, key, {
168196
configurable: true,
169-
enumerable: false,
170-
get: () => jest.fn(),
171-
set: undefined,
197+
enumerable: true,
198+
get() {
199+
const fn = createMockedApi();
200+
fn.mockImplementation(() => res ?? createSuccessTransportRequestPromise({}));
201+
Object.defineProperty(obj, key, {
202+
value: fn,
203+
configurable: true,
204+
enumerable: true,
205+
writable: true,
206+
});
207+
return fn;
208+
},
209+
set(value) {
210+
Object.defineProperty(obj, key, {
211+
value,
212+
configurable: true,
213+
enumerable: true,
214+
writable: true,
215+
});
216+
},
172217
});
173218
};
174219

175-
// `on`, `off`, and `once` are properties without a setter.
176-
// We can't `client.diagnostic.on = jest.fn()` because the following error will be thrown:
177-
// TypeError: Cannot set property on of #<Client> which has only a getter
178-
mockGetter(client.diagnostic, 'on');
179-
mockGetter(client.diagnostic, 'off');
180-
mockGetter(client.diagnostic, 'once');
181-
client.transport = {
182-
request: jest.fn(),
220+
const defineLazyObject = (obj: Record<string, any>, key: string, childShape: ShapeNode) => {
221+
Object.defineProperty(obj, key, {
222+
configurable: true,
223+
enumerable: true,
224+
get() {
225+
const child = buildLazyMockFromShape(childShape, res);
226+
Object.defineProperty(obj, key, {
227+
value: child,
228+
configurable: true,
229+
enumerable: true,
230+
writable: true,
231+
});
232+
return child;
233+
},
234+
});
183235
};
184236

185-
return client as DeeplyMockedApi<Client>;
237+
for (const [key, node] of Object.entries(shape.props)) {
238+
if (node.type === 'method') {
239+
defineLazyMethod(target, key);
240+
} else if (node.type === 'object') {
241+
defineLazyObject(target, key, node);
242+
}
243+
}
244+
245+
// Special cases based on prior behavior
246+
Object.defineProperty(target, 'diagnostic', {
247+
configurable: true,
248+
enumerable: false,
249+
get() {
250+
const d: any = {};
251+
for (const k of ['on', 'off', 'once']) {
252+
Object.defineProperty(d, k, {
253+
configurable: true,
254+
enumerable: false,
255+
get: () => jest.fn(),
256+
});
257+
}
258+
Object.defineProperty(target, 'diagnostic', {
259+
value: d,
260+
configurable: true,
261+
enumerable: false,
262+
writable: true,
263+
});
264+
return d;
265+
},
266+
});
267+
268+
Object.defineProperty(target, 'transport', {
269+
configurable: true,
270+
enumerable: true,
271+
get() {
272+
const t = { request: jest.fn() } as any;
273+
Object.defineProperty(target, 'transport', {
274+
value: t,
275+
configurable: true,
276+
enumerable: true,
277+
writable: true,
278+
});
279+
return t;
280+
},
281+
});
282+
283+
return target;
284+
}
285+
286+
const createInternalClientMock = (res?: Promise<unknown>): DeeplyMockedApi<Client> => {
287+
const shape = getClientShape();
288+
const mockClient: any = buildLazyMockFromShape(shape, res);
289+
290+
mockClient.close = jest.fn().mockReturnValue(Promise.resolve());
291+
mockClient.child = jest.fn().mockImplementation(() => createInternalClientMock());
292+
293+
return mockClient as DeeplyMockedApi<Client>;
186294
};
187295

188296
export type ElasticsearchClientMock = DeeplyMockedApi<ElasticsearchClient>;
@@ -226,7 +334,7 @@ const createCustomClusterClientMock = () => {
226334
const mock: CustomClusterClientMock = lazyObject({
227335
asInternalUser: createClientMock(),
228336
asScoped: jest.fn().mockReturnValue(createScopedClusterClientMock()),
229-
close: jest.fn().mockResolvedValue(Promise.resolve()),
337+
close: jest.fn().mockReturnValue(Promise.resolve()),
230338
});
231339

232340
return mock;

0 commit comments

Comments
 (0)