[Resolver] UI tests for the panel and bug fix (#74421)
* Change the way the resolver simulator works * refactor resolver tree and data access layer mocks * Fix bug where timestamp and pid sometimes don't show in the node detail view * add a few tests for the panel (not done, but worth committing.)
This commit is contained in:
parent
5d9f329a36
commit
7dc33f9ba8
|
@ -182,6 +182,15 @@ export interface ResolverRelatedEvents {
|
|||
nextEvent: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe version of `ResolverRelatedEvents`
|
||||
*/
|
||||
export interface SafeResolverRelatedEvents {
|
||||
entityID: string;
|
||||
events: SafeResolverEvent[];
|
||||
nextEvent: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response structure for the alerts route.
|
||||
*/
|
||||
|
|
|
@ -9,11 +9,8 @@ import {
|
|||
ResolverTree,
|
||||
ResolverEntityIndex,
|
||||
} from '../../../../common/endpoint/types';
|
||||
import { mockEndpointEvent } from '../../store/mocks/endpoint_event';
|
||||
import {
|
||||
mockTreeWithNoAncestorsAnd2Children,
|
||||
withRelatedEventsOnOrigin,
|
||||
} from '../../store/mocks/resolver_tree';
|
||||
import { mockEndpointEvent } from '../../mocks/endpoint_event';
|
||||
import { mockTreeWithNoAncestorsAnd2Children } from '../../mocks/resolver_tree';
|
||||
import { DataAccessLayer } from '../../types';
|
||||
|
||||
interface Metadata {
|
||||
|
@ -43,24 +40,11 @@ interface Metadata {
|
|||
/**
|
||||
* A simple mock dataAccessLayer possible that returns a tree with 0 ancestors and 2 direct children. 1 related event is returned. The parameter to `entities` is ignored.
|
||||
*/
|
||||
export function oneAncestorTwoChildren(
|
||||
{ withRelatedEvents }: { withRelatedEvents: Iterable<[string, string]> | null } = {
|
||||
withRelatedEvents: null,
|
||||
}
|
||||
): { dataAccessLayer: DataAccessLayer; metadata: Metadata } {
|
||||
export function noAncestorsTwoChildren(): { dataAccessLayer: DataAccessLayer; metadata: Metadata } {
|
||||
const metadata: Metadata = {
|
||||
databaseDocumentID: '_id',
|
||||
entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' },
|
||||
};
|
||||
const baseTree = mockTreeWithNoAncestorsAnd2Children({
|
||||
originID: metadata.entityIDs.origin,
|
||||
firstChildID: metadata.entityIDs.firstChild,
|
||||
secondChildID: metadata.entityIDs.secondChild,
|
||||
});
|
||||
const composedTree = withRelatedEvents
|
||||
? withRelatedEventsOnOrigin(baseTree, withRelatedEvents)
|
||||
: baseTree;
|
||||
|
||||
return {
|
||||
metadata,
|
||||
dataAccessLayer: {
|
||||
|
@ -70,17 +54,13 @@ export function oneAncestorTwoChildren(
|
|||
relatedEvents(entityID: string): Promise<ResolverRelatedEvents> {
|
||||
return Promise.resolve({
|
||||
entityID,
|
||||
events:
|
||||
/* Respond with the mocked related events when the origin's related events are fetched*/ withRelatedEvents &&
|
||||
entityID === metadata.entityIDs.origin
|
||||
? composedTree.relatedEvents.events
|
||||
: [
|
||||
mockEndpointEvent({
|
||||
entityID,
|
||||
name: 'event',
|
||||
timestamp: 0,
|
||||
}),
|
||||
],
|
||||
events: [
|
||||
mockEndpointEvent({
|
||||
entityID,
|
||||
name: 'event',
|
||||
timestamp: 0,
|
||||
}),
|
||||
],
|
||||
nextEvent: null,
|
||||
});
|
||||
},
|
||||
|
@ -89,7 +69,13 @@ export function oneAncestorTwoChildren(
|
|||
* Fetch a ResolverTree for a entityID
|
||||
*/
|
||||
resolverTree(): Promise<ResolverTree> {
|
||||
return Promise.resolve(composedTree);
|
||||
return Promise.resolve(
|
||||
mockTreeWithNoAncestorsAnd2Children({
|
||||
originID: metadata.entityIDs.origin,
|
||||
firstChildID: metadata.entityIDs.firstChild,
|
||||
secondChildID: metadata.entityIDs.secondChild,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { DataAccessLayer } from '../../types';
|
||||
import { mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin } from '../../mocks/resolver_tree';
|
||||
import {
|
||||
ResolverRelatedEvents,
|
||||
ResolverTree,
|
||||
ResolverEntityIndex,
|
||||
} from '../../../../common/endpoint/types';
|
||||
|
||||
interface Metadata {
|
||||
/**
|
||||
* The `_id` of the document being analyzed.
|
||||
*/
|
||||
databaseDocumentID: string;
|
||||
/**
|
||||
* A record of entityIDs to be used in tests assertions.
|
||||
*/
|
||||
entityIDs: {
|
||||
/**
|
||||
* The entityID of the node related to the document being analyzed.
|
||||
*/
|
||||
origin: 'origin';
|
||||
/**
|
||||
* The entityID of the first child of the origin.
|
||||
*/
|
||||
firstChild: 'firstChild';
|
||||
/**
|
||||
* The entityID of the second child of the origin.
|
||||
*/
|
||||
secondChild: 'secondChild';
|
||||
};
|
||||
}
|
||||
|
||||
export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): {
|
||||
dataAccessLayer: DataAccessLayer;
|
||||
metadata: Metadata;
|
||||
} {
|
||||
const metadata: Metadata = {
|
||||
databaseDocumentID: '_id',
|
||||
entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' },
|
||||
};
|
||||
const tree = mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({
|
||||
originID: metadata.entityIDs.origin,
|
||||
firstChildID: metadata.entityIDs.firstChild,
|
||||
secondChildID: metadata.entityIDs.secondChild,
|
||||
});
|
||||
|
||||
return {
|
||||
metadata,
|
||||
dataAccessLayer: {
|
||||
/**
|
||||
* Fetch related events for an entity ID
|
||||
*/
|
||||
relatedEvents(entityID: string): Promise<ResolverRelatedEvents> {
|
||||
/**
|
||||
* Respond with the mocked related events when the origin's related events are fetched.
|
||||
**/
|
||||
const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : [];
|
||||
|
||||
return Promise.resolve({
|
||||
entityID,
|
||||
events,
|
||||
nextEvent: null,
|
||||
} as ResolverRelatedEvents);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a ResolverTree for a entityID
|
||||
*/
|
||||
resolverTree(): Promise<ResolverTree> {
|
||||
return Promise.resolve(tree);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get an array of index patterns that contain events.
|
||||
*/
|
||||
indexPatterns(): string[] {
|
||||
return ['index pattern'];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get entities matching a document.
|
||||
*/
|
||||
entities(): Promise<ResolverEntityIndex> {
|
||||
return Promise.resolve([{ entity_id: metadata.entityIDs.origin }]);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { EndpointEvent } from '../../../../common/endpoint/types';
|
||||
import { EndpointEvent } from '../../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
* Simple mock endpoint event that works for tree layouts.
|
||||
|
@ -28,10 +28,29 @@ export function mockEndpointEvent({
|
|||
type: lifecycleType ? lifecycleType : 'start',
|
||||
category: 'process',
|
||||
},
|
||||
agent: {
|
||||
id: 'agent.id',
|
||||
version: 'agent.version',
|
||||
type: 'agent.type',
|
||||
},
|
||||
ecs: {
|
||||
version: 'ecs.version',
|
||||
},
|
||||
user: {
|
||||
name: 'user.name',
|
||||
domain: 'user.domain',
|
||||
},
|
||||
process: {
|
||||
entity_id: entityID,
|
||||
executable: 'executable',
|
||||
args: 'args',
|
||||
name,
|
||||
pid: 0,
|
||||
hash: {
|
||||
md5: 'hash.md5',
|
||||
},
|
||||
parent: {
|
||||
pid: 0,
|
||||
entity_id: parentEntityId,
|
||||
},
|
||||
},
|
|
@ -5,8 +5,7 @@
|
|||
*/
|
||||
|
||||
import { mockEndpointEvent } from './endpoint_event';
|
||||
import { mockRelatedEvent } from './related_event';
|
||||
import { ResolverTree, ResolverEvent } from '../../../../common/endpoint/types';
|
||||
import { ResolverTree, ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types';
|
||||
|
||||
export function mockTreeWith2AncestorsAndNoChildren({
|
||||
originID,
|
||||
|
@ -125,11 +124,11 @@ type RelatedEventType = string;
|
|||
* @param treeToAddRelatedEventsTo the ResolverTree to modify
|
||||
* @param relatedEventsToAddByCategoryAndType Iterable of `[category, type]` pairs describing related events. e.g. [['dns','info'],['registry','access']]
|
||||
*/
|
||||
export function withRelatedEventsOnOrigin(
|
||||
function withRelatedEventsOnOrigin(
|
||||
treeToAddRelatedEventsTo: ResolverTree,
|
||||
relatedEventsToAddByCategoryAndType: Iterable<[RelatedEventCategory, RelatedEventType]>
|
||||
): ResolverTree {
|
||||
const events = [];
|
||||
const events: SafeResolverEvent[] = [];
|
||||
const byCategory: Record<string, number> = {};
|
||||
const stats = {
|
||||
totalAlerts: 0,
|
||||
|
@ -139,14 +138,18 @@ export function withRelatedEventsOnOrigin(
|
|||
},
|
||||
};
|
||||
for (const [category, type] of relatedEventsToAddByCategoryAndType) {
|
||||
events.push(
|
||||
mockRelatedEvent({
|
||||
entityID: treeToAddRelatedEventsTo.entityID,
|
||||
timestamp: 1,
|
||||
category,
|
||||
events.push({
|
||||
'@timestamp': 1,
|
||||
event: {
|
||||
kind: 'event',
|
||||
type,
|
||||
})
|
||||
);
|
||||
category,
|
||||
id: 'xyz',
|
||||
},
|
||||
process: {
|
||||
entity_id: treeToAddRelatedEventsTo.entityID,
|
||||
},
|
||||
});
|
||||
stats.events.total++;
|
||||
stats.events.byCategory[category] = stats.events.byCategory[category]
|
||||
? stats.events.byCategory[category] + 1
|
||||
|
@ -156,7 +159,7 @@ export function withRelatedEventsOnOrigin(
|
|||
...treeToAddRelatedEventsTo,
|
||||
stats,
|
||||
relatedEvents: {
|
||||
events,
|
||||
events: events as ResolverEvent[],
|
||||
nextEvent: null,
|
||||
},
|
||||
};
|
||||
|
@ -309,3 +312,24 @@ export function mockTreeWithNoProcessEvents(): ResolverTree {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({
|
||||
originID,
|
||||
firstChildID,
|
||||
secondChildID,
|
||||
}: {
|
||||
originID: string;
|
||||
firstChildID: string;
|
||||
secondChildID: string;
|
||||
}) {
|
||||
const baseTree = mockTreeWithNoAncestorsAnd2Children({
|
||||
originID,
|
||||
firstChildID,
|
||||
secondChildID,
|
||||
});
|
||||
const withRelatedEvents: Array<[string, string]> = [
|
||||
['registry', 'access'],
|
||||
['registry', 'access'],
|
||||
];
|
||||
return withRelatedEventsOnOrigin(baseTree, withRelatedEvents);
|
||||
}
|
|
@ -15,7 +15,7 @@ import {
|
|||
mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents,
|
||||
mockTreeWithAllProcessesTerminated,
|
||||
mockTreeWithNoProcessEvents,
|
||||
} from '../mocks/resolver_tree';
|
||||
} from '../../mocks/resolver_tree';
|
||||
import { uniquePidForProcess } from '../../models/process_event';
|
||||
import { EndpointEvent } from '../../../../common/endpoint/types';
|
||||
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { EndpointEvent } from '../../../../common/endpoint/types';
|
||||
|
||||
/**
|
||||
* Simple mock related event.
|
||||
*/
|
||||
export function mockRelatedEvent({
|
||||
entityID,
|
||||
timestamp,
|
||||
category,
|
||||
type,
|
||||
id,
|
||||
}: {
|
||||
entityID: string;
|
||||
timestamp: number;
|
||||
category: string;
|
||||
type: string;
|
||||
id?: string;
|
||||
}): EndpointEvent {
|
||||
return {
|
||||
'@timestamp': timestamp,
|
||||
event: {
|
||||
kind: 'event',
|
||||
type,
|
||||
category,
|
||||
id: id ?? 'xyz',
|
||||
},
|
||||
process: {
|
||||
entity_id: entityID,
|
||||
},
|
||||
} as EndpointEvent;
|
||||
}
|
|
@ -12,7 +12,7 @@ import * as selectors from './selectors';
|
|||
import {
|
||||
mockTreeWith2AncestorsAndNoChildren,
|
||||
mockTreeWithNoAncestorsAnd2Children,
|
||||
} from './mocks/resolver_tree';
|
||||
} from '../mocks/resolver_tree';
|
||||
import { SafeResolverEvent } from '../../../common/endpoint/types';
|
||||
|
||||
describe('resolver selectors', () => {
|
||||
|
|
|
@ -113,83 +113,21 @@ export class Simulator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Return a promise that resolves after the `store`'s next state transition.
|
||||
* Used by `mapStateTransitions`
|
||||
* Yield the result of `mapper` over and over, once per event-loop cycle.
|
||||
* After 10 times, quit.
|
||||
* Use this to continually check a value. See `toYieldEqualTo`.
|
||||
*/
|
||||
private stateTransitioned(): Promise<void> {
|
||||
// keep track of the resolve function of the promise that has been returned.
|
||||
let resolveState: (() => void) | null = null;
|
||||
|
||||
const promise: Promise<undefined> = new Promise((resolve) => {
|
||||
// Immediately expose the resolve function in the outer scope. It will be resolved when the next state transition occurs.
|
||||
resolveState = resolve;
|
||||
});
|
||||
|
||||
// Subscribe to the store
|
||||
const unsubscribe = this.store.subscribe(() => {
|
||||
// Once a state transition occurs, unsubscribe.
|
||||
unsubscribe();
|
||||
// Resolve the promise. The null assertion is safe here as Promise initializers run immediately (according to spec and node/browser implementations.)
|
||||
// NB: the state is not resolved here. Code using the simulator should not rely on state or selectors of state.
|
||||
resolveState!();
|
||||
});
|
||||
|
||||
// Return the promise that will be resolved on the next state transition, allowing code to `await` for the next state transition.
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* This will yield the return value of `mapper` after each state transition. If no state transition occurs for 10 event loops in a row, this will give up.
|
||||
*/
|
||||
public async *mapStateTransitions<R>(mapper: () => R): AsyncIterable<R> {
|
||||
// Yield the value before any state transitions have occurred.
|
||||
yield mapper();
|
||||
|
||||
/** Increment this each time an event loop completes without a state transition.
|
||||
* If this value hits `10`, end the loop.
|
||||
*
|
||||
* Code will test assertions after each state transition. If the assertion hasn't passed and no further state transitions occur,
|
||||
* then the jest timeout will happen. The timeout doesn't give a useful message about the assertion.
|
||||
* By short-circuiting this function, code that uses it can short circuit the test timeout and print a useful error message.
|
||||
*
|
||||
* NB: the logic to short-circuit the loop is here because knowledge of state is a concern of the simulator, not tests.
|
||||
*/
|
||||
public async *map<R>(mapper: () => R): AsyncIterable<R> {
|
||||
let timeoutCount = 0;
|
||||
while (true) {
|
||||
/**
|
||||
* `await` a race between the next state transition and a timeout that happens after `0`ms.
|
||||
* If the timeout wins, no `dispatch` call caused a state transition in the last loop.
|
||||
* If this keeps happening, assume that Resolver isn't going to do anything else.
|
||||
*
|
||||
* If Resolver adds intentional delay logic (e.g. waiting before making a request), this code might have to change.
|
||||
* In that case, Resolver should use the side effect context to schedule future work. This code could then subscribe to some event published by the side effect context. That way, this code will be aware of Resolver's intention to do work.
|
||||
*/
|
||||
const timedOut: boolean = await Promise.race([
|
||||
(async (): Promise<false> => {
|
||||
await this.stateTransitioned();
|
||||
// If a state transition occurs, return false for `timedOut`
|
||||
return false;
|
||||
})(),
|
||||
new Promise<true>((resolve) => {
|
||||
setTimeout(() => {
|
||||
// If a timeout occurs, resolve `timedOut` as true
|
||||
return resolve(true);
|
||||
}, 0);
|
||||
}),
|
||||
]);
|
||||
|
||||
if (timedOut) {
|
||||
// If a timout occurred, note it.
|
||||
timeoutCount++;
|
||||
if (timeoutCount === 10) {
|
||||
// if 10 timeouts happen in a row, end the loop early
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// If a state transition occurs, reset the timeout count and yield the value
|
||||
timeoutCount = 0;
|
||||
yield mapper();
|
||||
}
|
||||
while (timeoutCount < 10) {
|
||||
timeoutCount++;
|
||||
yield mapper();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
this.wrapper.update();
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,25 +136,22 @@ export class Simulator {
|
|||
* returns a `ReactWrapper` even if nothing is found, as that is how `enzyme` does things.
|
||||
*/
|
||||
public processNodeElements(options: ProcessNodeElementSelectorOptions = {}): ReactWrapper {
|
||||
return this.findInDOM(processNodeElementSelector(options));
|
||||
return this.domNodes(processNodeElementSelector(options));
|
||||
}
|
||||
|
||||
/**
|
||||
* true if a process node element is found for the entityID and if it has an [aria-selected] attribute.
|
||||
* Return the node element with the given `entityID`.
|
||||
*/
|
||||
public processNodeElementLooksSelected(entityID: string): boolean {
|
||||
return this.processNodeElements({ entityID, selected: true }).length === 1;
|
||||
public selectedProcessNode(entityID: string): ReactWrapper {
|
||||
return this.processNodeElements({ entityID, selected: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* true if a process node element is found for the entityID and if it *does not have* an [aria-selected] attribute.
|
||||
* Return the node element with the given `entityID`. It will only be returned if it is not selected.
|
||||
*/
|
||||
public processNodeElementLooksUnselected(entityID: string): boolean {
|
||||
// find the process node, then exclude it if its selected.
|
||||
return (
|
||||
this.processNodeElements({ entityID }).not(
|
||||
processNodeElementSelector({ entityID, selected: true })
|
||||
).length === 1
|
||||
public unselectedProcessNode(entityID: string): ReactWrapper {
|
||||
return this.processNodeElements({ entityID }).not(
|
||||
processNodeElementSelector({ entityID, selected: true })
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -234,11 +169,8 @@ export class Simulator {
|
|||
* @param entityID The entity ID of the proocess node to select in
|
||||
*/
|
||||
public processNodeRelatedEventButton(entityID: string): ReactWrapper {
|
||||
return this.processNodeElements({ entityID }).findWhere(
|
||||
(wrapper) =>
|
||||
// Filter out React components
|
||||
typeof wrapper.type() === 'string' &&
|
||||
wrapper.prop('data-test-subj') === 'resolver:submenu:button'
|
||||
return this.domNodes(
|
||||
`${processNodeElementSelector({ entityID })} [data-test-subj="resolver:submenu:button"]`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -256,42 +188,98 @@ export class Simulator {
|
|||
* The element that shows when Resolver is waiting for the graph data.
|
||||
*/
|
||||
public graphLoadingElement(): ReactWrapper {
|
||||
return this.findInDOM('[data-test-subj="resolver:graph:loading"]');
|
||||
return this.domNodes('[data-test-subj="resolver:graph:loading"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that shows if Resolver couldn't draw the graph.
|
||||
*/
|
||||
public graphErrorElement(): ReactWrapper {
|
||||
return this.findInDOM('[data-test-subj="resolver:graph:error"]');
|
||||
return this.domNodes('[data-test-subj="resolver:graph:error"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The element where nodes get drawn.
|
||||
*/
|
||||
public graphElement(): ReactWrapper {
|
||||
return this.findInDOM('[data-test-subj="resolver:graph"]');
|
||||
return this.domNodes('[data-test-subj="resolver:graph"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The outer panel container.
|
||||
* An element with a list of all nodes.
|
||||
*/
|
||||
public panelElement(): ReactWrapper {
|
||||
return this.findInDOM('[data-test-subj="resolver:panel"]');
|
||||
public nodeListElement(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-list"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel content element (which may include tables, lists, other data depending on the view).
|
||||
* Return the items in the node list (the default panel view.)
|
||||
*/
|
||||
public panelContentElement(): ReactWrapper {
|
||||
return this.findInDOM('[data-test-subj^="resolver:panel:"]');
|
||||
public nodeListItems(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-list:item"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `this.wrapper.find` but only returns DOM nodes.
|
||||
* The element containing the details for the selected node.
|
||||
*/
|
||||
private findInDOM(selector: string): ReactWrapper {
|
||||
return this.wrapper.find(selector).filterWhere((wrapper) => typeof wrapper.type() === 'string');
|
||||
public nodeDetailElement(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The details of the selected node are shown in a description list. This returns the title elements of the description list.
|
||||
*/
|
||||
private nodeDetailEntryTitle(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail:entry-title"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* The details of the selected node are shown in a description list. This returns the description elements of the description list.
|
||||
*/
|
||||
private nodeDetailEntryDescription(): ReactWrapper {
|
||||
return this.domNodes('[data-test-subj="resolver:node-detail:entry-description"]');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return DOM nodes that match `enzymeSelector`.
|
||||
*/
|
||||
private domNodes(enzymeSelector: string): ReactWrapper {
|
||||
return this.wrapper
|
||||
.find(enzymeSelector)
|
||||
.filterWhere((wrapper) => typeof wrapper.type() === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* The titles and descriptions (as text) from the node detail panel.
|
||||
*/
|
||||
public nodeDetailDescriptionListEntries(): Array<[string, string]> {
|
||||
const titles = this.nodeDetailEntryTitle();
|
||||
const descriptions = this.nodeDetailEntryDescription();
|
||||
const entries: Array<[string, string]> = [];
|
||||
for (let index = 0; index < Math.min(titles.length, descriptions.length); index++) {
|
||||
const title = titles.at(index).text();
|
||||
const description = descriptions.at(index).text();
|
||||
|
||||
// Exclude timestamp since we can't currently calculate the expected description for it from tests
|
||||
if (title !== '@timestamp') {
|
||||
entries.push([title, description]);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the wrapper returned by `wrapperFactory` only once it has at least 1 element in it.
|
||||
*/
|
||||
public async resolveWrapper(
|
||||
wrapperFactory: () => ReactWrapper,
|
||||
predicate: (wrapper: ReactWrapper) => boolean = (wrapper) => wrapper.length > 0
|
||||
): Promise<ReactWrapper | void> {
|
||||
for await (const wrapper of this.map(wrapperFactory)) {
|
||||
if (predicate(wrapper)) {
|
||||
return wrapper;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { oneAncestorTwoChildren } from '../data_access_layer/mocks/one_ancestor_two_children';
|
||||
import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children';
|
||||
import { Simulator } from '../test_utilities/simulator';
|
||||
// Extend jest with a custom matcher
|
||||
import '../test_utilities/extend_jest';
|
||||
import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin';
|
||||
|
||||
let simulator: Simulator;
|
||||
let databaseDocumentID: string;
|
||||
|
@ -16,10 +17,10 @@ let entityIDs: { origin: string; firstChild: string; secondChild: string };
|
|||
// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances
|
||||
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
||||
|
||||
describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', () => {
|
||||
describe('Resolver, when analyzing a tree that has no ancestors and 2 children', () => {
|
||||
beforeEach(async () => {
|
||||
// create a mock data access layer
|
||||
const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren();
|
||||
const { metadata: dataAccessLayerMetadata, dataAccessLayer } = noAncestorsTwoChildren();
|
||||
|
||||
// save a reference to the entity IDs exposed by the mock data layer
|
||||
entityIDs = dataAccessLayerMetadata.entityIDs;
|
||||
|
@ -40,7 +41,7 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', (
|
|||
*
|
||||
* For example, there might be no loading element at one point, and 1 graph element at one point, but never a single time when there is both 1 graph element and 0 loading elements.
|
||||
*/
|
||||
simulator.mapStateTransitions(() => ({
|
||||
simulator.map(() => ({
|
||||
graphElements: simulator.graphElement().length,
|
||||
graphLoadingElements: simulator.graphLoadingElement().length,
|
||||
graphErrorElements: simulator.graphErrorElement().length,
|
||||
|
@ -55,22 +56,23 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', (
|
|||
|
||||
// Combining assertions here for performance. Unfortunately, Enzyme + jsdom + React is slow.
|
||||
it(`should have 3 nodes, with the entityID's 'origin', 'firstChild', and 'secondChild'. 'origin' should be selected.`, async () => {
|
||||
expect(simulator.processNodeElementLooksSelected(entityIDs.origin)).toBe(true);
|
||||
|
||||
expect(simulator.processNodeElementLooksUnselected(entityIDs.firstChild)).toBe(true);
|
||||
expect(simulator.processNodeElementLooksUnselected(entityIDs.secondChild)).toBe(true);
|
||||
|
||||
expect(simulator.processNodeElements().length).toBe(3);
|
||||
await expect(
|
||||
simulator.map(() => ({
|
||||
selectedOriginCount: simulator.selectedProcessNode(entityIDs.origin).length,
|
||||
unselectedFirstChildCount: simulator.unselectedProcessNode(entityIDs.firstChild).length,
|
||||
unselectedSecondChildCount: simulator.unselectedProcessNode(entityIDs.secondChild).length,
|
||||
processNodeCount: simulator.processNodeElements().length,
|
||||
}))
|
||||
).toYieldEqualTo({
|
||||
selectedOriginCount: 1,
|
||||
unselectedFirstChildCount: 1,
|
||||
unselectedSecondChildCount: 1,
|
||||
processNodeCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it(`should have the default "process list" panel present`, async () => {
|
||||
expect(simulator.panelElement().length).toBe(1);
|
||||
expect(simulator.panelContentElement().length).toBe(1);
|
||||
const testSubjectName = simulator
|
||||
.panelContentElement()
|
||||
.getDOMNode()
|
||||
.getAttribute('data-test-subj');
|
||||
expect(testSubjectName).toMatch(/process-list/g);
|
||||
it(`should show the node list`, async () => {
|
||||
await expect(simulator.map(() => simulator.nodeListElement().length)).toYieldEqualTo(1);
|
||||
});
|
||||
|
||||
describe("when the second child node's first button has been clicked", () => {
|
||||
|
@ -82,42 +84,37 @@ describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', (
|
|||
.first()
|
||||
.simulate('click');
|
||||
});
|
||||
it('should render the second child node as selected, and the first child not as not selected, and the query string should indicate that the second child is selected', async () => {
|
||||
it('should render the second child node as selected, and the origin as not selected, and the query string should indicate that the second child is selected', async () => {
|
||||
await expect(
|
||||
simulator.mapStateTransitions(function value() {
|
||||
return {
|
||||
// the query string has a key showing that the second child is selected
|
||||
queryStringSelectedNode: simulator.queryStringValues().selectedNode,
|
||||
// the second child is rendered in the DOM, and shows up as selected
|
||||
secondChildLooksSelected: simulator.processNodeElementLooksSelected(
|
||||
entityIDs.secondChild
|
||||
),
|
||||
// the origin is in the DOM, but shows up as unselected
|
||||
originLooksUnselected: simulator.processNodeElementLooksUnselected(entityIDs.origin),
|
||||
};
|
||||
})
|
||||
simulator.map(() => ({
|
||||
// the query string has a key showing that the second child is selected
|
||||
queryStringSelectedNode: simulator.queryStringValues().selectedNode,
|
||||
// the second child is rendered in the DOM, and shows up as selected
|
||||
selectedSecondChildNodeCount: simulator.selectedProcessNode(entityIDs.secondChild)
|
||||
.length,
|
||||
// the origin is in the DOM, but shows up as unselected
|
||||
unselectedOriginNodeCount: simulator.unselectedProcessNode(entityIDs.origin).length,
|
||||
}))
|
||||
).toYieldEqualTo({
|
||||
// Just the second child should be marked as selected in the query string
|
||||
queryStringSelectedNode: [entityIDs.secondChild],
|
||||
// The second child is rendered and has `[aria-selected]`
|
||||
secondChildLooksSelected: true,
|
||||
selectedSecondChildNodeCount: 1,
|
||||
// The origin child is rendered and doesn't have `[aria-selected]`
|
||||
originLooksUnselected: true,
|
||||
unselectedOriginNodeCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resolver, when analyzing a tree that has some related events', () => {
|
||||
describe('Resolver, when analyzing a tree that has two related events for the origin', () => {
|
||||
beforeEach(async () => {
|
||||
// create a mock data access layer with related events
|
||||
const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren({
|
||||
withRelatedEvents: [
|
||||
['registry', 'access'],
|
||||
['registry', 'access'],
|
||||
],
|
||||
});
|
||||
const {
|
||||
metadata: dataAccessLayerMetadata,
|
||||
dataAccessLayer,
|
||||
} = noAncestorsTwoChildrenWithRelatedEventsOnOrigin();
|
||||
|
||||
// save a reference to the entity IDs exposed by the mock data layer
|
||||
entityIDs = dataAccessLayerMetadata.entityIDs;
|
||||
|
@ -132,7 +129,7 @@ describe('Resolver, when analyzing a tree that has some related events', () => {
|
|||
describe('when it has loaded', () => {
|
||||
beforeEach(async () => {
|
||||
await expect(
|
||||
simulator.mapStateTransitions(() => ({
|
||||
simulator.map(() => ({
|
||||
graphElements: simulator.graphElement().length,
|
||||
graphLoadingElements: simulator.graphLoadingElement().length,
|
||||
graphErrorElements: simulator.graphErrorElement().length,
|
||||
|
@ -148,7 +145,7 @@ describe('Resolver, when analyzing a tree that has some related events', () => {
|
|||
|
||||
it('should render a related events button', async () => {
|
||||
await expect(
|
||||
simulator.mapStateTransitions(() => ({
|
||||
simulator.map(() => ({
|
||||
relatedEventButtons: simulator.processNodeRelatedEventButton(entityIDs.origin).length,
|
||||
}))
|
||||
).toYieldEqualTo({
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children';
|
||||
import { Simulator } from '../test_utilities/simulator';
|
||||
// Extend jest with a custom matcher
|
||||
import '../test_utilities/extend_jest';
|
||||
|
||||
describe('Resolver: when analyzing a tree with no ancestors and two children', () => {
|
||||
let simulator: Simulator;
|
||||
let databaseDocumentID: string;
|
||||
|
||||
// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances
|
||||
const resolverComponentInstanceID = 'resolverComponentInstanceID';
|
||||
|
||||
beforeEach(async () => {
|
||||
// create a mock data access layer
|
||||
const { metadata: dataAccessLayerMetadata, dataAccessLayer } = noAncestorsTwoChildren();
|
||||
|
||||
// save a reference to the `_id` supported by the mock data layer
|
||||
databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID;
|
||||
|
||||
// create a resolver simulator, using the data access layer and an arbitrary component instance ID
|
||||
simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID });
|
||||
});
|
||||
|
||||
it('should show the node list', async () => {
|
||||
await expect(simulator.map(() => simulator.nodeListElement().length)).toYieldEqualTo(1);
|
||||
});
|
||||
|
||||
it('should have 3 nodes in the node list', async () => {
|
||||
await expect(simulator.map(() => simulator.nodeListItems().length)).toYieldEqualTo(3);
|
||||
});
|
||||
describe('when there is an item in the node list and it has been clicked', () => {
|
||||
beforeEach(async () => {
|
||||
const nodeListItems = await simulator.resolveWrapper(() => simulator.nodeListItems());
|
||||
expect(nodeListItems && nodeListItems.length).toBeTruthy();
|
||||
if (nodeListItems) {
|
||||
nodeListItems.first().find('button').simulate('click');
|
||||
}
|
||||
});
|
||||
it('should show the details for the first node', async () => {
|
||||
await expect(
|
||||
simulator.map(() => simulator.nodeDetailDescriptionListEntries())
|
||||
).toYieldEqualTo([
|
||||
['process.executable', 'executable'],
|
||||
['process.pid', '0'],
|
||||
['user.name', 'user.name'],
|
||||
['user.domain', 'user.domain'],
|
||||
['process.parent.pid', '0'],
|
||||
['process.hash.md5', 'hash.md5'],
|
||||
['process.args', 'args'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,7 +3,7 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo, useMemo, HTMLAttributes } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
|
@ -16,6 +16,7 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list';
|
||||
import * as selectors from '../../store/selectors';
|
||||
import * as event from '../../../../common/endpoint/models/event';
|
||||
import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities';
|
||||
|
@ -51,9 +52,9 @@ export const ProcessDetails = memo(function ProcessDetails({
|
|||
const processName = event.eventName(processEvent);
|
||||
const entityId = event.entityId(processEvent);
|
||||
const isProcessTerminated = useSelector(selectors.isProcessTerminated)(entityId);
|
||||
const processInfoEntry = useMemo(() => {
|
||||
const processInfoEntry: EuiDescriptionListProps['listItems'] = useMemo(() => {
|
||||
const eventTime = event.eventTimestamp(processEvent);
|
||||
const dateTime = eventTime ? formatDate(eventTime) : '';
|
||||
const dateTime = eventTime === undefined ? null : formatDate(eventTime);
|
||||
|
||||
const createdEntry = {
|
||||
title: '@timestamp',
|
||||
|
@ -95,7 +96,7 @@ export const ProcessDetails = memo(function ProcessDetails({
|
|||
description: argsForProcess(processEvent),
|
||||
};
|
||||
|
||||
// This is the data in {title, description} form for the EUIDescriptionList to display
|
||||
// This is the data in {title, description} form for the EuiDescriptionList to display
|
||||
const processDescriptionListData = [
|
||||
createdEntry,
|
||||
pathEntry,
|
||||
|
@ -107,7 +108,7 @@ export const ProcessDetails = memo(function ProcessDetails({
|
|||
commandLineEntry,
|
||||
]
|
||||
.filter((entry) => {
|
||||
return entry.description;
|
||||
return entry.description !== undefined;
|
||||
})
|
||||
.map((entry) => {
|
||||
return {
|
||||
|
@ -172,13 +173,24 @@ export const ProcessDetails = memo(function ProcessDetails({
|
|||
</EuiText>
|
||||
<EuiSpacer size="l" />
|
||||
<StyledDescriptionList
|
||||
data-test-subj="resolver:node-detail"
|
||||
type="column"
|
||||
align="left"
|
||||
titleProps={{ className: 'desc-title' }}
|
||||
titleProps={
|
||||
{
|
||||
'data-test-subj': 'resolver:node-detail:entry-title',
|
||||
className: 'desc-title',
|
||||
// Casting this to allow data attribute
|
||||
} as HTMLAttributes<HTMLElement>
|
||||
}
|
||||
descriptionProps={
|
||||
{ 'data-test-subj': 'resolver:node-detail:entry-description' } as HTMLAttributes<
|
||||
HTMLElement
|
||||
>
|
||||
}
|
||||
compressed
|
||||
listItems={processInfoEntry}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
ProcessDetails.displayName = 'ProcessDetails';
|
||||
|
|
|
@ -150,7 +150,7 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({
|
|||
const processTableView: ProcessTableView[] = useMemo(
|
||||
() =>
|
||||
[...processNodePositions.keys()].map((processEvent) => {
|
||||
let dateTime;
|
||||
let dateTime: Date | undefined;
|
||||
const eventTime = event.timestampSafeVersion(processEvent);
|
||||
const name = event.processNameSafeVersion(processEvent);
|
||||
if (eventTime) {
|
||||
|
@ -186,13 +186,15 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({
|
|||
const children = useSelector(selectors.hasMoreChildren);
|
||||
const ancestors = useSelector(selectors.hasMoreAncestors);
|
||||
const showWarning = children === true || ancestors === true;
|
||||
const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []);
|
||||
return (
|
||||
<>
|
||||
<StyledBreadcrumbs breadcrumbs={crumbs} />
|
||||
{showWarning && <StyledLimitWarning numberDisplayed={numberOfProcesses} />}
|
||||
<EuiSpacer size="l" />
|
||||
<EuiInMemoryTable<ProcessTableView>
|
||||
data-test-subj="resolver:panel:process-list"
|
||||
rowProps={rowProps}
|
||||
data-test-subj="resolver:node-list"
|
||||
items={processTableView}
|
||||
columns={columns}
|
||||
sorting
|
||||
|
@ -200,4 +202,3 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({
|
|||
</>
|
||||
);
|
||||
});
|
||||
ProcessListWithCounts.displayName = 'ProcessListWithCounts';
|
||||
|
|
Loading…
Reference in a new issue